Compare commits
No commits in common. "dfd68f2a07edf759f169888e90e38562bcc48105" and "031c6028ac0040912a563ac2cbd4ea765b2bbf3c" have entirely different histories.
dfd68f2a07
...
031c6028ac
30 changed files with 53 additions and 6993 deletions
11
Makefile
11
Makefile
|
|
@ -128,17 +128,6 @@ staging: check-ssh
|
||||||
@echo " ✓ Staging fertig — https://staging.banyaro.app"
|
@echo " ✓ Staging fertig — https://staging.banyaro.app"
|
||||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10"
|
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10"
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# STAGING-DB — Produktions-DB in Staging kopieren (interaktiv, braucht sudo)
|
|
||||||
# Aufruf: make staging-db
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
staging-db: check-ssh
|
|
||||||
@echo "→ Produktions-DB nach Staging kopieren..."
|
|
||||||
@ssh -t $(DS_HOST) " \
|
|
||||||
sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \
|
|
||||||
sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \
|
|
||||||
echo '✓ DB kopiert'"
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# RELEASE — develop → main → Production (VERSION= pflichtangabe)
|
# RELEASE — develop → main → Production (VERSION= pflichtangabe)
|
||||||
# Beispiel: make release VERSION=1.1.0
|
# Beispiel: make release VERSION=1.1.0
|
||||||
|
|
|
||||||
|
|
@ -1657,221 +1657,3 @@ def _migrate(conn_factory):
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
|
CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Trainings-Streak-Tabelle
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS training_streaks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
current_streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
longest_streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_training_date TEXT,
|
|
||||||
UNIQUE(user_id, dog_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)")
|
|
||||||
|
|
||||||
# Ausgaben-Tracker
|
|
||||||
conn.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS expenses (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
|
|
||||||
kategorie TEXT NOT NULL,
|
|
||||||
betrag REAL NOT NULL,
|
|
||||||
datum TEXT NOT NULL,
|
|
||||||
notiz TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC);
|
|
||||||
""")
|
|
||||||
|
|
||||||
# KI-Tierarztfragen Rate-Limit-Log
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS ki_tierarzt_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
dog_id INTEGER,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User)
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS ki_rasse_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user
|
|
||||||
ON ki_rasse_log(user_id, created_at DESC)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# feed_recalls — Rückruf-Alarm für Tierfutter (RASFF)
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS feed_recalls (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
external_id TEXT NOT NULL UNIQUE,
|
|
||||||
titel TEXT NOT NULL,
|
|
||||||
produkt TEXT,
|
|
||||||
gefahr TEXT,
|
|
||||||
herkunft TEXT,
|
|
||||||
datum TEXT NOT NULL,
|
|
||||||
quelle TEXT NOT NULL DEFAULT 'rasff',
|
|
||||||
url TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)")
|
|
||||||
|
|
||||||
# Adoption-Cache
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS adoption_cache (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
external_id TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
rasse TEXT,
|
|
||||||
alter_jahre REAL,
|
|
||||||
geschlecht TEXT,
|
|
||||||
foto_url TEXT,
|
|
||||||
tierheim TEXT,
|
|
||||||
tierheim_plz TEXT,
|
|
||||||
tierheim_lat REAL,
|
|
||||||
tierheim_lon REAL,
|
|
||||||
adoptions_url TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
expires_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# ---- Favoriten-Tierarzt + Gesundheitsdokumente ----
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS favorite_vets (
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (user_id, vet_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS health_documents (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
typ TEXT NOT NULL,
|
|
||||||
titel TEXT NOT NULL,
|
|
||||||
beschreibung TEXT,
|
|
||||||
file_path TEXT NOT NULL,
|
|
||||||
file_type TEXT NOT NULL,
|
|
||||||
datum TEXT,
|
|
||||||
vet_id INTEGER REFERENCES tieraerzte(id),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)")
|
|
||||||
|
|
||||||
# Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links
|
|
||||||
try:
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS vaccinations (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
krankheit TEXT NOT NULL,
|
|
||||||
datum TEXT NOT NULL,
|
|
||||||
naechste TEXT,
|
|
||||||
tierarzt TEXT,
|
|
||||||
charge_nr TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS medications (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
dosierung TEXT,
|
|
||||||
von TEXT,
|
|
||||||
bis TEXT,
|
|
||||||
notiz TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS dog_passport_meta (
|
|
||||||
dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
blutgruppe TEXT,
|
|
||||||
allergien TEXT,
|
|
||||||
besonderheiten TEXT,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS passport_shares (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
valid_until TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token)
|
|
||||||
""")
|
|
||||||
logger.info("Migration: Hundepass-Tabellen bereit.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Migration Hundepass: {e}")
|
|
||||||
|
|
||||||
# ---- Playdate ----
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS playdate_listings (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
lat REAL NOT NULL,
|
|
||||||
lon REAL NOT NULL,
|
|
||||||
ort_name TEXT,
|
|
||||||
radius_km INTEGER NOT NULL DEFAULT 10,
|
|
||||||
beschreibung TEXT,
|
|
||||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(dog_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo
|
|
||||||
ON playdate_listings(lat, lon) WHERE aktiv=1
|
|
||||||
""")
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS playdate_requests (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
|
||||||
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
nachricht TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(from_dog_id, to_dog_id)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Wiederkehrende Ausgaben (Daueraufträge)
|
|
||||||
conn.executescript("""
|
|
||||||
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
|
|
||||||
kategorie TEXT NOT NULL,
|
|
||||||
betrag REAL NOT NULL,
|
|
||||||
haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich
|
|
||||||
startdatum TEXT NOT NULL,
|
|
||||||
naechste_faelligkeit TEXT NOT NULL,
|
|
||||||
notiz TEXT,
|
|
||||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
|
|
||||||
""")
|
|
||||||
|
|
|
||||||
160
backend/main.py
160
backend/main.py
|
|
@ -189,13 +189,6 @@ from routes.zucht_ki import router as zucht_ki_router
|
||||||
from routes.partner import router as partner_router
|
from routes.partner import router as partner_router
|
||||||
from routes.outreach import router as outreach_router
|
from routes.outreach import router as outreach_router
|
||||||
from routes.jobs import router as jobs_router
|
from routes.jobs import router as jobs_router
|
||||||
from routes.streak import router as streak_router
|
|
||||||
from routes.expenses import router as expenses_router
|
|
||||||
from routes.recalls import router as recalls_router
|
|
||||||
from routes.adoption import router as adoption_router
|
|
||||||
from routes.health_docs import router as health_docs_router
|
|
||||||
from routes.passport import router as passport_router
|
|
||||||
from routes.playdate import router as playdate_router
|
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -247,13 +240,6 @@ app.include_router(training_router, prefix="/api/training", tags=
|
||||||
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
|
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
|
||||||
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
||||||
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
|
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
|
||||||
app.include_router(streak_router, prefix="/api", tags=["Streak"])
|
|
||||||
app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"])
|
|
||||||
app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"])
|
|
||||||
app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"])
|
|
||||||
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
|
|
||||||
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
|
|
||||||
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -1688,152 +1674,6 @@ for _hp in _HONEYPOT_PATHS:
|
||||||
app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
|
app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@app.get("/pass/{token}")
|
|
||||||
async def passport_share_page(token: str):
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from database import db as _db
|
|
||||||
from datetime import date as _date
|
|
||||||
|
|
||||||
with _db() as conn:
|
|
||||||
share = conn.execute(
|
|
||||||
"SELECT * FROM passport_shares WHERE token=?", (token,)
|
|
||||||
).fetchone()
|
|
||||||
if not share:
|
|
||||||
return HTMLResponse(
|
|
||||||
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
|
|
||||||
'<h2>Link nicht gefunden</h2><p>Dieser Hundepass-Link ist ungültig.</p>',
|
|
||||||
status_code=404
|
|
||||||
)
|
|
||||||
if share["valid_until"] < _date.today().isoformat():
|
|
||||||
return HTMLResponse(
|
|
||||||
'<meta charset="UTF-8"><style>body{font-family:sans-serif;padding:2rem;color:#333}</style>'
|
|
||||||
'<h2>Link abgelaufen</h2><p>Dieser Hundepass-Link ist nicht mehr gültig.</p>',
|
|
||||||
status_code=410
|
|
||||||
)
|
|
||||||
dog_id = share["dog_id"]
|
|
||||||
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
|
|
||||||
meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone()
|
|
||||||
vaccs = conn.execute(
|
|
||||||
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
meds = conn.execute(
|
|
||||||
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
def _fmt(d):
|
|
||||||
if not d:
|
|
||||||
return "–"
|
|
||||||
try:
|
|
||||||
from datetime import datetime as _dt
|
|
||||||
return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
|
|
||||||
except Exception:
|
|
||||||
return d
|
|
||||||
|
|
||||||
dog = dict(dog)
|
|
||||||
meta = dict(meta) if meta else {}
|
|
||||||
vaccs = [dict(v) for v in vaccs]
|
|
||||||
meds = [dict(m) for m in meds]
|
|
||||||
|
|
||||||
_g_map = {"m": "Rüde", "w": "Hündin"}
|
|
||||||
|
|
||||||
vacc_rows = "".join(f"""
|
|
||||||
<tr>
|
|
||||||
<td>{v['krankheit'] or ''}</td>
|
|
||||||
<td>{_fmt(v['datum'])}</td>
|
|
||||||
<td>{_fmt(v['naechste'])}</td>
|
|
||||||
<td>{v['tierarzt'] or '–'}</td>
|
|
||||||
<td>{v['charge_nr'] or '–'}</td>
|
|
||||||
</tr>""" for v in vaccs) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
|
|
||||||
|
|
||||||
med_rows = "".join(f"""
|
|
||||||
<tr>
|
|
||||||
<td>{m['name'] or ''}</td>
|
|
||||||
<td>{m['dosierung'] or '–'}</td>
|
|
||||||
<td>{_fmt(m['von'])}</td>
|
|
||||||
<td>{_fmt(m['bis']) if m['bis'] else 'dauerhaft'}</td>
|
|
||||||
<td>{m['notiz'] or '–'}</td>
|
|
||||||
</tr>""" for m in meds) or "<tr><td colspan='5' style='color:#999'>Keine Einträge</td></tr>"
|
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Hundepass — {dog['name']}</title>
|
|
||||||
<style>
|
|
||||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
||||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background: #f5f7f5; color: #222; }}
|
|
||||||
.header {{ background: #28a764; color: #fff; padding: 24px 20px; text-align: center; }}
|
|
||||||
.header h1 {{ font-size: 1.5rem; margin-bottom: 4px; }}
|
|
||||||
.header p {{ font-size: 0.9rem; opacity: 0.85; }}
|
|
||||||
.container {{ max-width: 760px; margin: 24px auto; padding: 0 16px; }}
|
|
||||||
.card {{ background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,.08); }}
|
|
||||||
.card h2 {{ font-size: 1rem; color: #28a764; margin-bottom: 14px; display: flex;
|
|
||||||
align-items: center; gap: 8px; border-bottom: 1px solid #e8f5ee; padding-bottom: 10px; }}
|
|
||||||
.info-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }}
|
|
||||||
.info-item label {{ font-size: 0.75rem; color: #888; display: block; margin-bottom: 2px; }}
|
|
||||||
.info-item span {{ font-size: 0.9rem; font-weight: 500; }}
|
|
||||||
table {{ width: 100%; border-collapse: collapse; font-size: 0.85rem; }}
|
|
||||||
th {{ background: #e8f5ee; text-align: left; padding: 8px; font-size: 0.8rem;
|
|
||||||
color: #444; font-weight: 600; }}
|
|
||||||
td {{ padding: 8px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }}
|
|
||||||
tr:last-child td {{ border-bottom: none; }}
|
|
||||||
.footer {{ text-align: center; font-size: 0.75rem; color: #aaa; margin: 24px 0; }}
|
|
||||||
@media (max-width: 500px) {{ .info-grid {{ grid-template-columns: 1fr; }} }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<h1>Ban Yaro</h1>
|
|
||||||
<p>Digitaler Hundepass — {dog['name']}</p>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<h2>Hundeangaben</h2>
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-item"><label>Name</label><span>{dog['name']}</span></div>
|
|
||||||
<div class="info-item"><label>Rasse</label><span>{dog.get('rasse') or '–'}</span></div>
|
|
||||||
<div class="info-item"><label>Geburtstag</label><span>{_fmt(dog.get('geburtstag'))}</span></div>
|
|
||||||
<div class="info-item"><label>Geschlecht</label><span>{_g_map.get(dog.get('geschlecht',''), '–')}</span></div>
|
|
||||||
<div class="info-item"><label>Chip-Nr.</label><span>{dog.get('chip_nr') or '–'}</span></div>
|
|
||||||
<div class="info-item"><label>Blutgruppe</label><span>{meta.get('blutgruppe') or '–'}</span></div>
|
|
||||||
</div>
|
|
||||||
{('<div style="margin-top:14px"><label style="font-size:.75rem;color:#888">Allergien</label>'
|
|
||||||
f'<div style="font-size:.9rem">{meta["allergien"]}</div></div>') if meta.get("allergien") else ''}
|
|
||||||
{('<div style="margin-top:10px"><label style="font-size:.75rem;color:#888">Besonderheiten</label>'
|
|
||||||
f'<div style="font-size:.9rem">{meta["besonderheiten"]}</div></div>') if meta.get("besonderheiten") else ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Impfungen</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr>
|
|
||||||
<th>Krankheit</th><th>Datum</th><th>Nächste</th><th>Tierarzt</th><th>Charge</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>{vacc_rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Medikamente</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr>
|
|
||||||
<th>Medikament</th><th>Dosierung</th><th>Von</th><th>Bis</th><th>Notiz</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>{med_rows}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer">Erstellt mit Ban Yaro — banyaro.app</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
return HTMLResponse(html)
|
|
||||||
|
|
||||||
|
|
||||||
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def spa_fallback(full_path: str):
|
async def spa_fallback(full_path: str):
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,3 @@ pywebpush==2.0.0
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
odfpy==1.4.1
|
odfpy==1.4.1
|
||||||
polyline==2.0.2
|
polyline==2.0.2
|
||||||
fpdf2==2.8.3
|
|
||||||
python-dateutil>=2.9
|
|
||||||
|
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
"""
|
|
||||||
BAN YARO — Adoption (Tierheim-Hunde in der Nähe)
|
|
||||||
|
|
||||||
Strategie:
|
|
||||||
1. PetFinder API (falls API-Key gesetzt) → hat kaum deutsche Tierheime, nur als Bonus
|
|
||||||
2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten
|
|
||||||
3. Fallback: Weiterleitung zu tierheimhelden.de
|
|
||||||
|
|
||||||
Caching: adoption_cache Tabelle, 24h TTL.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import math
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import httpx
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import APIRouter, Query, BackgroundTasks
|
|
||||||
from database import db
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "")
|
|
||||||
PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "")
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Haversine — Distanz in km
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
||||||
R = 6371.0
|
|
||||||
p1 = math.radians(lat1)
|
|
||||||
p2 = math.radians(lat2)
|
|
||||||
dp = math.radians(lat2 - lat1)
|
|
||||||
dl = math.radians(lon2 - lon1)
|
|
||||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
||||||
return 2 * R * math.asin(math.sqrt(a))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Statische Tierheim-Daten (große deutsche Tierheime)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
GERMAN_SHELTERS = [
|
|
||||||
# (id, name, plz, stadt, lat, lon, url)
|
|
||||||
("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"),
|
|
||||||
("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"),
|
|
||||||
("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"),
|
|
||||||
("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"),
|
|
||||||
("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"),
|
|
||||||
("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"),
|
|
||||||
("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"),
|
|
||||||
("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"),
|
|
||||||
("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"),
|
|
||||||
("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"),
|
|
||||||
("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"),
|
|
||||||
("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"),
|
|
||||||
("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"),
|
|
||||||
("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"),
|
|
||||||
("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"),
|
|
||||||
("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"),
|
|
||||||
("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"),
|
|
||||||
("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"),
|
|
||||||
("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"),
|
|
||||||
("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"),
|
|
||||||
("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"),
|
|
||||||
("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"),
|
|
||||||
("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"),
|
|
||||||
("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"),
|
|
||||||
("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# PetFinder OAuth2 Token
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
_pf_token = None
|
|
||||||
_pf_token_exp = 0.0
|
|
||||||
|
|
||||||
async def _get_pf_token() -> str | None:
|
|
||||||
global _pf_token, _pf_token_exp
|
|
||||||
if not (PETFINDER_KEY and PETFINDER_SECRET):
|
|
||||||
return None
|
|
||||||
now = asyncio.get_event_loop().time()
|
|
||||||
if _pf_token and now < _pf_token_exp - 60:
|
|
||||||
return _pf_token
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=8) as client:
|
|
||||||
r = await client.post(
|
|
||||||
"https://api.petfinder.com/v2/oauth2/token",
|
|
||||||
data={"grant_type": "client_credentials",
|
|
||||||
"client_id": PETFINDER_KEY,
|
|
||||||
"client_secret": PETFINDER_SECRET},
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()
|
|
||||||
_pf_token = data.get("access_token")
|
|
||||||
_pf_token_exp = now + data.get("expires_in", 3600)
|
|
||||||
return _pf_token
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"PetFinder OAuth: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# PetFinder: Hunde in der Nähe holen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]:
|
|
||||||
token = await _get_pf_token()
|
|
||||||
if not token:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=12) as client:
|
|
||||||
r = await client.get(
|
|
||||||
"https://api.petfinder.com/v2/animals",
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
params={
|
|
||||||
"type": "dog",
|
|
||||||
"location": f"{lat},{lon}",
|
|
||||||
"distance": radius,
|
|
||||||
"limit": 20,
|
|
||||||
"sort": "distance",
|
|
||||||
"status": "adoptable",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if r.status_code != 200:
|
|
||||||
logger.warning(f"PetFinder API: HTTP {r.status_code}")
|
|
||||||
return []
|
|
||||||
animals = r.json().get("animals", [])
|
|
||||||
result = []
|
|
||||||
for a in animals:
|
|
||||||
org = a.get("organization_id", "")
|
|
||||||
loc = a.get("contact", {}).get("address", {})
|
|
||||||
photos = a.get("photos", [])
|
|
||||||
foto = photos[0].get("medium") if photos else None
|
|
||||||
age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0}
|
|
||||||
result.append({
|
|
||||||
"external_id": f"pf_{a['id']}",
|
|
||||||
"name": a.get("name", "Unbekannt"),
|
|
||||||
"rasse": ", ".join(
|
|
||||||
filter(None, [
|
|
||||||
a.get("breeds", {}).get("primary"),
|
|
||||||
a.get("breeds", {}).get("secondary"),
|
|
||||||
])
|
|
||||||
) or None,
|
|
||||||
"alter_jahre": age_map.get(a.get("age"), None),
|
|
||||||
"geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None),
|
|
||||||
"foto_url": foto,
|
|
||||||
"tierheim": org,
|
|
||||||
"tierheim_plz": loc.get("postcode"),
|
|
||||||
"tierheim_lat": None,
|
|
||||||
"tierheim_lon": None,
|
|
||||||
"adoptions_url": a.get("url", "https://www.petfinder.com/"),
|
|
||||||
"quelle": "petfinder",
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"PetFinder Fetch: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Cache befüllen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _refresh_cache(lat: float, lon: float, radius: int):
|
|
||||||
"""Holt frische Daten und schreibt sie in adoption_cache."""
|
|
||||||
animals = await _fetch_petfinder(lat, lon, radius)
|
|
||||||
if not animals:
|
|
||||||
return
|
|
||||||
expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
with db() as conn:
|
|
||||||
for a in animals:
|
|
||||||
try:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO adoption_cache
|
|
||||||
(external_id, name, rasse, alter_jahre, geschlecht,
|
|
||||||
foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon,
|
|
||||||
adoptions_url, expires_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
ON CONFLICT(external_id) DO UPDATE SET
|
|
||||||
name=excluded.name,
|
|
||||||
rasse=excluded.rasse,
|
|
||||||
alter_jahre=excluded.alter_jahre,
|
|
||||||
geschlecht=excluded.geschlecht,
|
|
||||||
foto_url=excluded.foto_url,
|
|
||||||
tierheim=excluded.tierheim,
|
|
||||||
tierheim_plz=excluded.tierheim_plz,
|
|
||||||
tierheim_lat=excluded.tierheim_lat,
|
|
||||||
tierheim_lon=excluded.tierheim_lon,
|
|
||||||
adoptions_url=excluded.adoptions_url,
|
|
||||||
expires_at=excluded.expires_at
|
|
||||||
""", (
|
|
||||||
a["external_id"], a["name"], a["rasse"], a["alter_jahre"],
|
|
||||||
a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"],
|
|
||||||
a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires,
|
|
||||||
))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Cache insert: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/adoption/nearby
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/nearby")
|
|
||||||
async def adoption_nearby(
|
|
||||||
lat: float = Query(..., description="Breitengrad"),
|
|
||||||
lon: float = Query(..., description="Längengrad"),
|
|
||||||
radius: int = Query(50, ge=5, le=200, description="Radius in km"),
|
|
||||||
background_tasks: BackgroundTasks = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Gibt Adoptionshunde in der Nähe zurück.
|
|
||||||
|
|
||||||
Priorisierung:
|
|
||||||
1. Frische PetFinder-Einträge aus Cache
|
|
||||||
2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung)
|
|
||||||
"""
|
|
||||||
now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
# ------ Cache lesen ------
|
|
||||||
cached_animals = []
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT * FROM adoption_cache
|
|
||||||
WHERE expires_at > ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
""", (now_str,)).fetchall()
|
|
||||||
for row in rows:
|
|
||||||
d = dict(row)
|
|
||||||
if d.get("tierheim_lat") and d.get("tierheim_lon"):
|
|
||||||
dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
|
|
||||||
if dist <= radius:
|
|
||||||
d["distanz_km"] = round(dist, 1)
|
|
||||||
cached_animals.append(d)
|
|
||||||
else:
|
|
||||||
# PetFinder-Einträge ohne Koordinaten: immer anzeigen
|
|
||||||
d["distanz_km"] = None
|
|
||||||
cached_animals.append(d)
|
|
||||||
|
|
||||||
# ------ Cache refreshen wenn leer oder alt ------
|
|
||||||
if not cached_animals and background_tasks is not None:
|
|
||||||
background_tasks.add_task(_refresh_cache, lat, lon, radius)
|
|
||||||
|
|
||||||
# ------ Statische Tierheime (immer) ------
|
|
||||||
shelters = []
|
|
||||||
for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
|
|
||||||
dist = _haversine(lat, lon, slat, slon)
|
|
||||||
if dist <= radius:
|
|
||||||
shelters.append({
|
|
||||||
"id": sid,
|
|
||||||
"name": name,
|
|
||||||
"plz": plz,
|
|
||||||
"stadt": stadt,
|
|
||||||
"lat": slat,
|
|
||||||
"lon": slon,
|
|
||||||
"url": url,
|
|
||||||
"distanz_km": round(dist, 1),
|
|
||||||
})
|
|
||||||
|
|
||||||
shelters.sort(key=lambda x: x["distanz_km"])
|
|
||||||
|
|
||||||
return {
|
|
||||||
"animals": cached_animals[:40],
|
|
||||||
"shelters": shelters[:10],
|
|
||||||
"has_petfinder": bool(PETFINDER_KEY),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/geocode")
|
|
||||||
async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
|
|
||||||
"""Wandelt eine PLZ in Koordinaten um (via Nominatim)."""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=8) as client:
|
|
||||||
r = await client.get(
|
|
||||||
"https://nominatim.openstreetmap.org/search",
|
|
||||||
params={
|
|
||||||
"q": f"{plz}, Germany",
|
|
||||||
"format": "json",
|
|
||||||
"limit": 1,
|
|
||||||
"accept-language": "de",
|
|
||||||
"countrycodes": "de",
|
|
||||||
},
|
|
||||||
headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"},
|
|
||||||
)
|
|
||||||
results = r.json()
|
|
||||||
if results:
|
|
||||||
return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Geocode PLZ {plz}: {e}")
|
|
||||||
return {"lat": None, "lon": None, "display": plz}
|
|
||||||
|
|
@ -1,396 +0,0 @@
|
||||||
"""BAN YARO — Ausgaben-Tracker Routes"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
from database import db
|
|
||||||
from auth import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Schemas
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
class ExpenseCreate(BaseModel):
|
|
||||||
dog_id: Optional[int] = None
|
|
||||||
kategorie: str
|
|
||||||
betrag: float
|
|
||||||
datum: str
|
|
||||||
notiz: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ExpenseUpdate(BaseModel):
|
|
||||||
dog_id: Optional[int] = None
|
|
||||||
kategorie: Optional[str] = None
|
|
||||||
betrag: Optional[float] = None
|
|
||||||
datum: Optional[str] = None
|
|
||||||
notiz: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RecurringCreate(BaseModel):
|
|
||||||
dog_id: Optional[int] = None
|
|
||||||
kategorie: str
|
|
||||||
betrag: float
|
|
||||||
haeufigkeit: str # monatlich | quartalsweise | jaehrlich
|
|
||||||
startdatum: str # ISO date
|
|
||||||
notiz: Optional[str] = None
|
|
||||||
|
|
||||||
class RecurringUpdate(BaseModel):
|
|
||||||
dog_id: Optional[int] = None
|
|
||||||
kategorie: Optional[str] = None
|
|
||||||
betrag: Optional[float] = None
|
|
||||||
haeufigkeit: Optional[str] = None
|
|
||||||
startdatum: Optional[str] = None
|
|
||||||
notiz: Optional[str] = None
|
|
||||||
aktiv: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"}
|
|
||||||
|
|
||||||
|
|
||||||
def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date:
|
|
||||||
"""Berechnet das nächste Fälligkeitsdatum nach `after`."""
|
|
||||||
d = date.fromisoformat(startdatum)
|
|
||||||
if d > after:
|
|
||||||
return d
|
|
||||||
if haeufigkeit == "monatlich":
|
|
||||||
delta = relativedelta(months=1)
|
|
||||||
elif haeufigkeit == "quartalsweise":
|
|
||||||
delta = relativedelta(months=3)
|
|
||||||
else:
|
|
||||||
delta = relativedelta(years=1)
|
|
||||||
while d <= after:
|
|
||||||
d += delta
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row) -> dict:
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/expenses/summary — Monats- und Jahressummen
|
|
||||||
# WICHTIG: Diese Route muss VOR /{id} stehen!
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/summary")
|
|
||||||
async def get_summary(
|
|
||||||
dog_id: Optional[int] = Query(default=None),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
today = date.today()
|
|
||||||
monat_prefix = today.strftime("%Y-%m")
|
|
||||||
jahr_prefix = today.strftime("%Y")
|
|
||||||
|
|
||||||
extra_cond = ""
|
|
||||||
extra_params: list = []
|
|
||||||
if dog_id is not None:
|
|
||||||
extra_cond = " AND dog_id=?"
|
|
||||||
extra_params = [dog_id]
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
# Monats-Summen pro Kategorie
|
|
||||||
rows_monat = conn.execute(
|
|
||||||
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
|
|
||||||
FROM expenses
|
|
||||||
WHERE user_id=? AND datum LIKE ?{extra_cond}
|
|
||||||
GROUP BY kategorie""",
|
|
||||||
[user["id"], f"{monat_prefix}%"] + extra_params,
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
# Jahres-Summen pro Kategorie
|
|
||||||
rows_jahr = conn.execute(
|
|
||||||
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
|
|
||||||
FROM expenses
|
|
||||||
WHERE user_id=? AND datum LIKE ?{extra_cond}
|
|
||||||
GROUP BY kategorie""",
|
|
||||||
[user["id"], f"{jahr_prefix}%"] + extra_params,
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat}
|
|
||||||
jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr}
|
|
||||||
|
|
||||||
gesamt_monat = round(sum(monat.values()), 2)
|
|
||||||
gesamt_jahr = round(sum(jahr.values()), 2)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"monat": monat,
|
|
||||||
"jahr": jahr,
|
|
||||||
"gesamt_monat": gesamt_monat,
|
|
||||||
"gesamt_jahr": gesamt_jahr,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/expenses — Liste mit optionalen Filtern
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("")
|
|
||||||
async def list_expenses(
|
|
||||||
dog_id: Optional[int] = Query(default=None),
|
|
||||||
von: Optional[str] = Query(default=None),
|
|
||||||
bis: Optional[str] = Query(default=None),
|
|
||||||
limit: int = Query(default=100, le=500),
|
|
||||||
offset: int = Query(default=0),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
conditions = ["e.user_id=?"]
|
|
||||||
params: list = [user["id"]]
|
|
||||||
|
|
||||||
if dog_id is not None:
|
|
||||||
conditions.append("e.dog_id=?")
|
|
||||||
params.append(dog_id)
|
|
||||||
if von:
|
|
||||||
conditions.append("e.datum >= ?")
|
|
||||||
params.append(von)
|
|
||||||
if bis:
|
|
||||||
conditions.append("e.datum <= ?")
|
|
||||||
params.append(bis)
|
|
||||||
|
|
||||||
where = " AND ".join(conditions)
|
|
||||||
params += [limit, offset]
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
f"""SELECT e.*, d.name AS dog_name
|
|
||||||
FROM expenses e
|
|
||||||
LEFT JOIN dogs d ON d.id = e.dog_id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY e.datum DESC, e.id DESC
|
|
||||||
LIMIT ? OFFSET ?""",
|
|
||||||
params,
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return [_serialize(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /api/expenses — neuer Eintrag
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("", status_code=201)
|
|
||||||
async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)):
|
|
||||||
if data.kategorie not in KATEGORIEN:
|
|
||||||
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
|
|
||||||
if data.betrag <= 0:
|
|
||||||
raise HTTPException(400, "Betrag muss größer als 0 sein.")
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
# dog_id prüfen — muss dem User gehören
|
|
||||||
if data.dog_id is not None:
|
|
||||||
dog = conn.execute(
|
|
||||||
"SELECT id FROM dogs WHERE id=? AND user_id=?",
|
|
||||||
(data.dog_id, user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
if not dog:
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
||||||
(user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz),
|
|
||||||
)
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
return _serialize(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# PATCH /api/expenses/{id} — bearbeiten
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.patch("/{expense_id}")
|
|
||||||
async def update_expense(
|
|
||||||
expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user)
|
|
||||||
):
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM expenses WHERE id=? AND user_id=?",
|
|
||||||
(expense_id, user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
|
||||||
|
|
||||||
updates = {}
|
|
||||||
if data.kategorie is not None:
|
|
||||||
if data.kategorie not in KATEGORIEN:
|
|
||||||
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
|
|
||||||
updates["kategorie"] = data.kategorie
|
|
||||||
if data.betrag is not None:
|
|
||||||
if data.betrag <= 0:
|
|
||||||
raise HTTPException(400, "Betrag muss größer als 0 sein.")
|
|
||||||
updates["betrag"] = data.betrag
|
|
||||||
if data.datum is not None:
|
|
||||||
updates["datum"] = data.datum
|
|
||||||
if data.notiz is not None:
|
|
||||||
updates["notiz"] = data.notiz
|
|
||||||
if data.dog_id is not None:
|
|
||||||
dog = conn.execute(
|
|
||||||
"SELECT id FROM dogs WHERE id=? AND user_id=?",
|
|
||||||
(data.dog_id, user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
if not dog:
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
updates["dog_id"] = data.dog_id
|
|
||||||
|
|
||||||
if not updates:
|
|
||||||
return _serialize(row)
|
|
||||||
|
|
||||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
|
||||||
values = list(updates.values()) + [expense_id]
|
|
||||||
conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values)
|
|
||||||
row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone()
|
|
||||||
|
|
||||||
return _serialize(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DELETE /api/expenses/{id} — löschen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.delete("/{expense_id}", status_code=204)
|
|
||||||
async def delete_expense(expense_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT id FROM expenses WHERE id=? AND user_id=?",
|
|
||||||
(expense_id, user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
|
||||||
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Wiederkehrende Ausgaben
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/recurring")
|
|
||||||
async def list_recurring(user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"""SELECT r.*, d.name AS dog_name
|
|
||||||
FROM recurring_expenses r
|
|
||||||
LEFT JOIN dogs d ON d.id = r.dog_id
|
|
||||||
WHERE r.user_id=? ORDER BY r.startdatum DESC""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recurring", status_code=201)
|
|
||||||
async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)):
|
|
||||||
if data.kategorie not in KATEGORIEN:
|
|
||||||
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
|
|
||||||
if data.haeufigkeit not in HAEUFIGKEITEN:
|
|
||||||
raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}")
|
|
||||||
if data.betrag <= 0:
|
|
||||||
raise HTTPException(400, "Betrag muss größer als 0 sein.")
|
|
||||||
|
|
||||||
today = date.today()
|
|
||||||
naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1))
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
if data.dog_id:
|
|
||||||
if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?",
|
|
||||||
(data.dog_id, user["id"])).fetchone():
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO recurring_expenses
|
|
||||||
(user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?)""",
|
|
||||||
(user["id"], data.dog_id, data.kategorie, data.betrag,
|
|
||||||
data.haeufigkeit, data.startdatum, str(naechste), data.notiz),
|
|
||||||
)
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/recurring/{rid}")
|
|
||||||
async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"])
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
|
|
||||||
updates: dict = {}
|
|
||||||
if data.kategorie is not None:
|
|
||||||
if data.kategorie not in KATEGORIEN:
|
|
||||||
raise HTTPException(400, f"Ungültige Kategorie.")
|
|
||||||
updates["kategorie"] = data.kategorie
|
|
||||||
if data.betrag is not None:
|
|
||||||
updates["betrag"] = data.betrag
|
|
||||||
if data.haeufigkeit is not None:
|
|
||||||
if data.haeufigkeit not in HAEUFIGKEITEN:
|
|
||||||
raise HTTPException(400, "Ungültige Häufigkeit.")
|
|
||||||
updates["haeufigkeit"] = data.haeufigkeit
|
|
||||||
if data.startdatum is not None:
|
|
||||||
updates["startdatum"] = data.startdatum
|
|
||||||
if data.notiz is not None:
|
|
||||||
updates["notiz"] = data.notiz
|
|
||||||
if data.aktiv is not None:
|
|
||||||
updates["aktiv"] = 1 if data.aktiv else 0
|
|
||||||
if updates:
|
|
||||||
# naechste_faelligkeit neu berechnen wenn relevante Felder geändert
|
|
||||||
startdatum = updates.get("startdatum", row["startdatum"])
|
|
||||||
haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"])
|
|
||||||
today = date.today()
|
|
||||||
updates["naechste_faelligkeit"] = str(
|
|
||||||
_next_due(startdatum, haeufigkeit, today - timedelta(days=1))
|
|
||||||
)
|
|
||||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
|
||||||
conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?",
|
|
||||||
[*updates.values(), rid])
|
|
||||||
row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone()
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/recurring/{rid}", status_code=204)
|
|
||||||
async def delete_recurring(rid: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?",
|
|
||||||
(rid, user["id"])).fetchone():
|
|
||||||
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
|
|
||||||
conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def process_due_recurring(user_id: int | None = None):
|
|
||||||
"""Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen."""
|
|
||||||
today = date.today()
|
|
||||||
today_str = str(today)
|
|
||||||
with db() as conn:
|
|
||||||
where = "aktiv=1 AND naechste_faelligkeit <= ?"
|
|
||||||
params: list = [today_str]
|
|
||||||
if user_id:
|
|
||||||
where += " AND user_id=?"
|
|
||||||
params.append(user_id)
|
|
||||||
rows = conn.execute(
|
|
||||||
f"SELECT * FROM recurring_expenses WHERE {where}", params
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
for r in rows:
|
|
||||||
# Eintrag anlegen
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
|
|
||||||
VALUES (?,?,?,?,?,?)""",
|
|
||||||
(r["user_id"], r["dog_id"], r["kategorie"], r["betrag"],
|
|
||||||
r["naechste_faelligkeit"],
|
|
||||||
f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"),
|
|
||||||
)
|
|
||||||
# Nächste Fälligkeit berechnen
|
|
||||||
naechste = _next_due(r["startdatum"], r["haeufigkeit"],
|
|
||||||
date.fromisoformat(r["naechste_faelligkeit"]))
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?",
|
|
||||||
(str(naechste), r["id"]),
|
|
||||||
)
|
|
||||||
return len(rows) if rows else 0
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
"""BAN YARO — Gesundheitsdokumente (Befunde, Röntgen, Rezepte …)"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
|
||||||
from typing import Optional
|
|
||||||
from database import db
|
|
||||||
from auth import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
|
||||||
|
|
||||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"}
|
|
||||||
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
||||||
|
|
||||||
ERLAUBTE_TYPEN = {"blutbild", "roentgen", "rezept", "impfausweis", "sonstiges"}
|
|
||||||
|
|
||||||
|
|
||||||
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/health-docs?dog_id=...
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("")
|
|
||||||
async def list_docs(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_owner(conn, dog_id, user["id"])
|
|
||||||
rows = conn.execute(
|
|
||||||
"""SELECT hd.*, t.name AS vet_name
|
|
||||||
FROM health_documents hd
|
|
||||||
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
|
|
||||||
WHERE hd.dog_id=?
|
|
||||||
ORDER BY hd.created_at DESC""",
|
|
||||||
(dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /api/health-docs/upload (multipart/form-data)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/upload", status_code=201)
|
|
||||||
async def upload_doc(
|
|
||||||
dog_id: int = Form(...),
|
|
||||||
typ: str = Form(...),
|
|
||||||
titel: str = Form(...),
|
|
||||||
beschreibung: Optional[str] = Form(None),
|
|
||||||
datum: Optional[str] = Form(None),
|
|
||||||
vet_id: Optional[int] = Form(None),
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if typ not in ERLAUBTE_TYPEN:
|
|
||||||
raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(sorted(ERLAUBTE_TYPEN))}")
|
|
||||||
|
|
||||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
|
||||||
if not ext:
|
|
||||||
ext = ".jpg"
|
|
||||||
if ext not in ALLOWED_EXTENSIONS:
|
|
||||||
raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
|
|
||||||
|
|
||||||
content = await file.read()
|
|
||||||
if len(content) > MAX_SIZE_BYTES:
|
|
||||||
raise HTTPException(413, "Datei zu groß. Maximal 10 MB erlaubt.")
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
_check_dog_owner(conn, dog_id, user["id"])
|
|
||||||
if vet_id:
|
|
||||||
vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (vet_id,)).fetchone()
|
|
||||||
if not vet:
|
|
||||||
vet_id = None
|
|
||||||
|
|
||||||
# Datei speichern
|
|
||||||
dog_dir = os.path.join(MEDIA_DIR, "health_docs", str(dog_id))
|
|
||||||
os.makedirs(dog_dir, exist_ok=True)
|
|
||||||
filename = f"{uuid.uuid4().hex}{ext}"
|
|
||||||
filepath = os.path.join(dog_dir, filename)
|
|
||||||
with open(filepath, "wb") as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
file_url = f"/media/health_docs/{dog_id}/{filename}"
|
|
||||||
file_type = "pdf" if ext == ".pdf" else ext.lstrip(".")
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO health_documents
|
|
||||||
(dog_id, user_id, typ, titel, beschreibung, file_path, file_type, datum, vet_id)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
||||||
(dog_id, user["id"], typ, titel.strip(), beschreibung,
|
|
||||||
file_url, file_type, datum or None, vet_id)
|
|
||||||
)
|
|
||||||
row = conn.execute(
|
|
||||||
"""SELECT hd.*, t.name AS vet_name
|
|
||||||
FROM health_documents hd
|
|
||||||
LEFT JOIN tieraerzte t ON t.id = hd.vet_id
|
|
||||||
WHERE hd.id = last_insert_rowid()"""
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DELETE /api/health-docs/{id}
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.delete("/{doc_id}", status_code=204)
|
|
||||||
async def delete_doc(doc_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM health_documents WHERE id=? AND user_id=?",
|
|
||||||
(doc_id, user["id"])
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Dokument nicht gefunden.")
|
|
||||||
|
|
||||||
# Datei löschen — file_path ist z.B. /media/health_docs/42/abc123.pdf
|
|
||||||
file_path = row["file_path"]
|
|
||||||
if file_path:
|
|
||||||
# /media/... → MEDIA_DIR/...
|
|
||||||
rel = file_path.lstrip("/")
|
|
||||||
if rel.startswith("media/"):
|
|
||||||
rel = rel[len("media/"):]
|
|
||||||
abs_path = os.path.join(MEDIA_DIR, rel)
|
|
||||||
if os.path.isfile(abs_path):
|
|
||||||
try:
|
|
||||||
os.remove(abs_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
conn.execute("DELETE FROM health_documents WHERE id=?", (doc_id,))
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"""BAN YARO — KI Routes"""
|
"""BAN YARO — KI Routes"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import ki as ki_module
|
import ki as ki_module
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from ratelimit import check as rl_check
|
from ratelimit import check as rl_check
|
||||||
from database import db
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -63,224 +62,3 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
|
||||||
raise HTTPException(503, str(e))
|
raise HTTPException(503, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /ki/tierarzt — KI-Tierarztfragen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
class TierarztRequest(BaseModel):
|
|
||||||
symptom: str
|
|
||||||
dog_id: Optional[int] = None
|
|
||||||
dog_name: Optional[str] = None
|
|
||||||
rasse: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tierarzt")
|
|
||||||
async def ki_tierarzt(req: TierarztRequest, request: Request,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
|
|
||||||
if not req.symptom or len(req.symptom.strip()) < 5:
|
|
||||||
raise HTTPException(400, "Bitte beschreibe das Symptom genauer.")
|
|
||||||
if len(req.symptom) > 1000:
|
|
||||||
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
|
|
||||||
|
|
||||||
# Rate-Limit: max 5 Anfragen pro User pro Tag
|
|
||||||
with db() as conn:
|
|
||||||
count = conn.execute(
|
|
||||||
"SELECT COUNT(*) FROM ki_tierarzt_log "
|
|
||||||
"WHERE user_id=? AND created_at >= datetime('now','-1 day')",
|
|
||||||
(user["id"],)
|
|
||||||
).fetchone()[0]
|
|
||||||
if count >= 5:
|
|
||||||
raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.")
|
|
||||||
|
|
||||||
dog_name = req.dog_name or "unbekannt"
|
|
||||||
rasse = req.rasse or "unbekannt"
|
|
||||||
|
|
||||||
system = (
|
|
||||||
"Du bist ein erfahrener Tierarzt-Assistent für Hunde. "
|
|
||||||
"Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — "
|
|
||||||
"kein Ersatz für eine echte tierärztliche Untersuchung. "
|
|
||||||
"Antworte immer auf Deutsch, klar und verständlich. "
|
|
||||||
"Stelle keine medizinischen Diagnosen. "
|
|
||||||
"Empfehle im Zweifel immer den Gang zum Tierarzt."
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = f"""Hund: {dog_name}, Rasse: {rasse}
|
|
||||||
Symptom: {req.symptom.strip()}
|
|
||||||
|
|
||||||
Gib eine strukturierte, verständliche Einschätzung:
|
|
||||||
1. Mögliche Ursachen (2-3 wahrscheinlichste)
|
|
||||||
2. Was der Besitzer jetzt tun kann (Erstmaßnahmen)
|
|
||||||
3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort)
|
|
||||||
|
|
||||||
Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter.
|
|
||||||
Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
antwort = await ki_module.complete(
|
|
||||||
prompt=prompt,
|
|
||||||
system=system,
|
|
||||||
max_tokens=600,
|
|
||||||
requires_premium=False,
|
|
||||||
user_id=user["id"],
|
|
||||||
)
|
|
||||||
# Erfolg: Rate-Limit-Eintrag speichern
|
|
||||||
with db() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)",
|
|
||||||
(user["id"], req.dog_id)
|
|
||||||
)
|
|
||||||
return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5}
|
|
||||||
except ki_module.KIUnavailableError as e:
|
|
||||||
raise HTTPException(503, str(e))
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Rate-Limit-Helfer für Rassen-Erkennung
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
_RASSE_DAILY_LIMIT = 10
|
|
||||||
|
|
||||||
|
|
||||||
def _check_rasse_limit(user_id: int) -> int:
|
|
||||||
"""Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht."""
|
|
||||||
with db() as conn:
|
|
||||||
used = conn.execute(
|
|
||||||
"""SELECT COUNT(*) FROM ki_rasse_log
|
|
||||||
WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""",
|
|
||||||
(user_id,)
|
|
||||||
).fetchone()[0]
|
|
||||||
remaining = _RASSE_DAILY_LIMIT - used
|
|
||||||
if remaining <= 0:
|
|
||||||
raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.")
|
|
||||||
return remaining
|
|
||||||
|
|
||||||
|
|
||||||
def _log_rasse_request(user_id: int):
|
|
||||||
with db() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/rasse-erkennung")
|
|
||||||
async def ki_rasse_erkennung(
|
|
||||||
request: Request,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag)."""
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import anthropic
|
|
||||||
|
|
||||||
# Dateigröße prüfen
|
|
||||||
content = await file.read()
|
|
||||||
if len(content) > 5 * 1024 * 1024:
|
|
||||||
raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.")
|
|
||||||
|
|
||||||
# MIME-Typ prüfen
|
|
||||||
ct = (file.content_type or "").lower()
|
|
||||||
if not ct.startswith("image/"):
|
|
||||||
raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).")
|
|
||||||
|
|
||||||
# MIME-Typ auf erlaubte Werte beschränken
|
|
||||||
allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
||||||
mime_type = ct if ct in allowed_mimes else "image/jpeg"
|
|
||||||
|
|
||||||
# Rate-Limit prüfen
|
|
||||||
remaining_before = _check_rasse_limit(user["id"])
|
|
||||||
|
|
||||||
# Anthropic-Client holen (nutzt cached Instanz aus ki.py)
|
|
||||||
if not ki_module.ANTHROPIC_KEY:
|
|
||||||
raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.")
|
|
||||||
|
|
||||||
api_key = ki_module.ANTHROPIC_KEY
|
|
||||||
base64_data = base64.standard_b64encode(content).decode("utf-8")
|
|
||||||
|
|
||||||
prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n).
|
|
||||||
|
|
||||||
Antworte NUR im folgenden JSON-Format (kein anderer Text):
|
|
||||||
{
|
|
||||||
"rassen": [
|
|
||||||
{"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"},
|
|
||||||
{"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"}
|
|
||||||
],
|
|
||||||
"ist_hund": true,
|
|
||||||
"hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität"
|
|
||||||
}
|
|
||||||
|
|
||||||
Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100).
|
|
||||||
Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
def _sync_call():
|
|
||||||
client = anthropic.Anthropic(api_key=api_key)
|
|
||||||
return client.messages.create(
|
|
||||||
model="claude-opus-4-7",
|
|
||||||
max_tokens=500,
|
|
||||||
messages=[{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "image",
|
|
||||||
"source": {
|
|
||||||
"type": "base64",
|
|
||||||
"media_type": mime_type,
|
|
||||||
"data": base64_data,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"text": prompt_text,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
)
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
response = await asyncio.get_event_loop().run_in_executor(None, _sync_call)
|
|
||||||
raw = response.content[0].text.strip()
|
|
||||||
|
|
||||||
except anthropic.APIError as e:
|
|
||||||
raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(500, "Fehler bei der Bildanalyse.")
|
|
||||||
|
|
||||||
# JSON parsen — Claude kann manchmal ```json ... ``` wrappen
|
|
||||||
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip()
|
|
||||||
try:
|
|
||||||
parsed = json.loads(cleaned)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.")
|
|
||||||
|
|
||||||
# Usage loggen (erst nach erfolgreicher KI-Antwort)
|
|
||||||
_log_rasse_request(user["id"])
|
|
||||||
remaining_after = remaining_before - 1
|
|
||||||
|
|
||||||
# Wiki-Slugs für erkannte Rassen nachschlagen
|
|
||||||
rassen = parsed.get("rassen", [])
|
|
||||||
if rassen:
|
|
||||||
with db() as conn:
|
|
||||||
for r in rassen:
|
|
||||||
name = r.get("name", "")
|
|
||||||
# Exakter Name-Match (case-insensitive)
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,)
|
|
||||||
).fetchone()
|
|
||||||
r["wiki_slug"] = row["slug"] if row else None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"rassen": rassen,
|
|
||||||
"ist_hund": parsed.get("ist_hund", False),
|
|
||||||
"hinweis": parsed.get("hinweis") or None,
|
|
||||||
"verbleibende_anfragen": remaining_after,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
"""BAN YARO — Digitaler Hundepass"""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import secrets
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
from database import db
|
|
||||||
from auth import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Schemas
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
class PassportMeta(BaseModel):
|
|
||||||
blutgruppe: Optional[str] = None
|
|
||||||
allergien: Optional[str] = None
|
|
||||||
besonderheiten: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class VaccinationCreate(BaseModel):
|
|
||||||
krankheit: str
|
|
||||||
datum: str
|
|
||||||
naechste: Optional[str] = None
|
|
||||||
tierarzt: Optional[str] = None
|
|
||||||
charge_nr: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class MedicationCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
dosierung: Optional[str] = None
|
|
||||||
von: Optional[str] = None
|
|
||||||
bis: Optional[str] = None
|
|
||||||
notiz: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Hilfsfunktion: Eigentümer-Prüfung
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _get_own_dog(conn, dog_id: int, user_id: int):
|
|
||||||
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.")
|
|
||||||
return dog
|
|
||||||
|
|
||||||
|
|
||||||
def _load_passport_data(conn, dog_id: int) -> dict:
|
|
||||||
dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone()
|
|
||||||
if not dog:
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
|
|
||||||
meta = conn.execute(
|
|
||||||
"SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
vaccinations = conn.execute(
|
|
||||||
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
medications = conn.execute(
|
|
||||||
"SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,)
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"dog": dict(dog),
|
|
||||||
"meta": dict(meta) if meta else {},
|
|
||||||
"vaccinations": [dict(v) for v in vaccinations],
|
|
||||||
"medications": [dict(m) for m in medications],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /passport/{dog_id} — vollständige Passdaten
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/{dog_id}")
|
|
||||||
async def get_passport(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
return _load_passport_data(conn, dog_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# PUT /passport/{dog_id}/meta
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.put("/{dog_id}/meta")
|
|
||||||
async def update_meta(dog_id: int, data: PassportMeta, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO dog_passport_meta (dog_id, blutgruppe, allergien, besonderheiten, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT(dog_id) DO UPDATE SET
|
|
||||||
blutgruppe = excluded.blutgruppe,
|
|
||||||
allergien = excluded.allergien,
|
|
||||||
besonderheiten = excluded.besonderheiten,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
""", (dog_id, data.blutgruppe, data.allergien, data.besonderheiten))
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /passport/{dog_id}/vaccinations
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/{dog_id}/vaccinations")
|
|
||||||
async def add_vaccination(dog_id: int, data: VaccinationCreate, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO vaccinations (dog_id, krankheit, datum, naechste, tierarzt, charge_nr)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""", (dog_id, data.krankheit, data.datum, data.naechste, data.tierarzt, data.charge_nr))
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM vaccinations WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DELETE /passport/{dog_id}/vaccinations/{vacc_id}
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.delete("/{dog_id}/vaccinations/{vacc_id}", status_code=204)
|
|
||||||
async def delete_vaccination(dog_id: int, vacc_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM vaccinations WHERE id=? AND dog_id=?", (vacc_id, dog_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /passport/{dog_id}/medications
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/{dog_id}/medications")
|
|
||||||
async def add_medication(dog_id: int, data: MedicationCreate, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO medications (dog_id, name, dosierung, von, bis, notiz)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""", (dog_id, data.name, data.dosierung, data.von, data.bis, data.notiz))
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM medications WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# DELETE /passport/{dog_id}/medications/{med_id}
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.delete("/{dog_id}/medications/{med_id}", status_code=204)
|
|
||||||
async def delete_medication(dog_id: int, med_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM medications WHERE id=? AND dog_id=?", (med_id, dog_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /passport/{dog_id}/share — Share-Token erstellen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/{dog_id}/share")
|
|
||||||
async def create_share(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
valid_until = (date.today() + timedelta(days=30)).isoformat()
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO passport_shares (dog_id, token, valid_until)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
""", (dog_id, token, valid_until))
|
|
||||||
return {
|
|
||||||
"token": token,
|
|
||||||
"valid_until": valid_until,
|
|
||||||
"url": f"/pass/{token}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /passport/share/{token} — öffentlicher Endpunkt (kein Auth)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/share/{token}")
|
|
||||||
async def get_shared_passport(token: str):
|
|
||||||
with db() as conn:
|
|
||||||
share = conn.execute(
|
|
||||||
"SELECT * FROM passport_shares WHERE token=?", (token,)
|
|
||||||
).fetchone()
|
|
||||||
if not share:
|
|
||||||
raise HTTPException(404, "Link nicht gefunden.")
|
|
||||||
if share["valid_until"] < date.today().isoformat():
|
|
||||||
raise HTTPException(410, "Dieser Link ist abgelaufen.")
|
|
||||||
return _load_passport_data(conn, share["dog_id"])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /passport/{dog_id}/pdf — PDF generieren
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/{dog_id}/pdf")
|
|
||||||
async def download_pdf(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
_get_own_dog(conn, dog_id, user["id"])
|
|
||||||
data = _load_passport_data(conn, dog_id)
|
|
||||||
|
|
||||||
pdf_bytes = _generate_pdf(data)
|
|
||||||
dog_name = data["dog"]["name"].replace(" ", "_")
|
|
||||||
filename = f"Hundepass_{dog_name}.pdf"
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
io.BytesIO(pdf_bytes),
|
|
||||||
media_type="application/pdf",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# PDF-Generierung mit fpdf2
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _generate_pdf(data: dict) -> bytes:
|
|
||||||
try:
|
|
||||||
from fpdf import FPDF
|
|
||||||
except ImportError:
|
|
||||||
raise HTTPException(500, "PDF-Bibliothek nicht verfügbar. Bitte fpdf2 installieren.")
|
|
||||||
|
|
||||||
dog = data["dog"]
|
|
||||||
meta = data["meta"]
|
|
||||||
vaccs = data["vaccinations"]
|
|
||||||
meds = data["medications"]
|
|
||||||
|
|
||||||
# Datumsformatierung DE
|
|
||||||
def _fmt_date(d):
|
|
||||||
if not d:
|
|
||||||
return "–"
|
|
||||||
try:
|
|
||||||
return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y")
|
|
||||||
except Exception:
|
|
||||||
return d
|
|
||||||
|
|
||||||
# Geschlecht
|
|
||||||
geschlecht_map = {"m": "Rüde", "w": "Hündin"}
|
|
||||||
|
|
||||||
pdf = FPDF()
|
|
||||||
pdf.set_auto_page_break(auto=True, margin=20)
|
|
||||||
pdf.add_page()
|
|
||||||
|
|
||||||
# ---- Header ----
|
|
||||||
pdf.set_fill_color(40, 167, 100) # Ban Yaro Grün
|
|
||||||
pdf.rect(0, 0, 210, 38, style="F")
|
|
||||||
|
|
||||||
pdf.set_text_color(255, 255, 255)
|
|
||||||
pdf.set_font("Helvetica", style="B", size=20)
|
|
||||||
pdf.set_y(8)
|
|
||||||
pdf.cell(0, 10, "Ban Yaro", align="C", ln=True)
|
|
||||||
pdf.set_font("Helvetica", size=11)
|
|
||||||
pdf.cell(0, 8, f"Digitaler Hundepass — {dog['name']}", align="C", ln=True)
|
|
||||||
pdf.set_font("Helvetica", size=8)
|
|
||||||
pdf.cell(0, 6, f"Erstellt am {date.today().strftime('%d.%m.%Y')}", align="C", ln=True)
|
|
||||||
|
|
||||||
pdf.set_text_color(30, 30, 30)
|
|
||||||
pdf.set_y(46)
|
|
||||||
|
|
||||||
# ---- Hundedaten ----
|
|
||||||
pdf.set_fill_color(245, 250, 247)
|
|
||||||
pdf.set_draw_color(200, 200, 200)
|
|
||||||
pdf.set_font("Helvetica", style="B", size=12)
|
|
||||||
pdf.set_fill_color(235, 247, 240)
|
|
||||||
pdf.cell(0, 8, " Hundeangaben", ln=True, fill=True, border="B")
|
|
||||||
pdf.ln(3)
|
|
||||||
|
|
||||||
def _info_row(label, value):
|
|
||||||
pdf.set_font("Helvetica", style="B", size=9)
|
|
||||||
pdf.cell(45, 6, label + ":", ln=False)
|
|
||||||
pdf.set_font("Helvetica", size=9)
|
|
||||||
pdf.cell(0, 6, str(value) if value else "–", ln=True)
|
|
||||||
|
|
||||||
_info_row("Name", dog["name"])
|
|
||||||
_info_row("Rasse", dog.get("rasse") or "–")
|
|
||||||
_info_row("Geburtstag", _fmt_date(dog.get("geburtstag")))
|
|
||||||
_info_row("Geschlecht", geschlecht_map.get(dog.get("geschlecht", ""), "–"))
|
|
||||||
_info_row("Chip-Nr.", dog.get("chip_nr") or "–")
|
|
||||||
if meta.get("blutgruppe"):
|
|
||||||
_info_row("Blutgruppe", meta["blutgruppe"])
|
|
||||||
|
|
||||||
pdf.ln(5)
|
|
||||||
|
|
||||||
# ---- Allergien & Besonderheiten ----
|
|
||||||
if meta.get("allergien") or meta.get("besonderheiten"):
|
|
||||||
pdf.set_font("Helvetica", style="B", size=12)
|
|
||||||
pdf.set_fill_color(235, 247, 240)
|
|
||||||
pdf.cell(0, 8, " Allergien & Besonderheiten", ln=True, fill=True, border="B")
|
|
||||||
pdf.ln(3)
|
|
||||||
if meta.get("allergien"):
|
|
||||||
pdf.set_font("Helvetica", style="B", size=9)
|
|
||||||
pdf.cell(45, 6, "Allergien:", ln=False)
|
|
||||||
pdf.set_font("Helvetica", size=9)
|
|
||||||
pdf.multi_cell(0, 6, meta["allergien"])
|
|
||||||
if meta.get("besonderheiten"):
|
|
||||||
pdf.set_font("Helvetica", style="B", size=9)
|
|
||||||
pdf.cell(45, 6, "Besonderheiten:", ln=False)
|
|
||||||
pdf.set_font("Helvetica", size=9)
|
|
||||||
pdf.multi_cell(0, 6, meta["besonderheiten"])
|
|
||||||
pdf.ln(5)
|
|
||||||
|
|
||||||
# ---- Impfungen ----
|
|
||||||
pdf.set_font("Helvetica", style="B", size=12)
|
|
||||||
pdf.set_fill_color(235, 247, 240)
|
|
||||||
pdf.cell(0, 8, " Impfungen", ln=True, fill=True, border="B")
|
|
||||||
pdf.ln(3)
|
|
||||||
|
|
||||||
if vaccs:
|
|
||||||
# Tabellen-Header
|
|
||||||
pdf.set_fill_color(220, 240, 228)
|
|
||||||
pdf.set_font("Helvetica", style="B", size=8)
|
|
||||||
pdf.cell(50, 6, "Krankheit", border=1, fill=True)
|
|
||||||
pdf.cell(25, 6, "Datum", border=1, fill=True)
|
|
||||||
pdf.cell(25, 6, "Nächste fällig", border=1, fill=True)
|
|
||||||
pdf.cell(55, 6, "Tierarzt", border=1, fill=True)
|
|
||||||
pdf.cell(35, 6, "Charge-Nr.", border=1, fill=True, ln=True)
|
|
||||||
|
|
||||||
pdf.set_font("Helvetica", size=8)
|
|
||||||
for i, v in enumerate(vaccs):
|
|
||||||
fill = (i % 2 == 0)
|
|
||||||
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
|
|
||||||
pdf.cell(50, 6, (v["krankheit"] or "")[:28], border=1, fill=fill)
|
|
||||||
pdf.cell(25, 6, _fmt_date(v["datum"]), border=1, fill=fill)
|
|
||||||
pdf.cell(25, 6, _fmt_date(v["naechste"]), border=1, fill=fill)
|
|
||||||
pdf.cell(55, 6, (v["tierarzt"] or "–")[:32], border=1, fill=fill)
|
|
||||||
pdf.cell(35, 6, (v["charge_nr"] or "–")[:20], border=1, fill=fill, ln=True)
|
|
||||||
else:
|
|
||||||
pdf.set_font("Helvetica", style="I", size=9)
|
|
||||||
pdf.set_text_color(140, 140, 140)
|
|
||||||
pdf.cell(0, 6, "Keine Impfungen eingetragen.", ln=True)
|
|
||||||
pdf.set_text_color(30, 30, 30)
|
|
||||||
|
|
||||||
pdf.ln(5)
|
|
||||||
|
|
||||||
# ---- Medikamente ----
|
|
||||||
pdf.set_font("Helvetica", style="B", size=12)
|
|
||||||
pdf.set_fill_color(235, 247, 240)
|
|
||||||
pdf.cell(0, 8, " Medikamente", ln=True, fill=True, border="B")
|
|
||||||
pdf.ln(3)
|
|
||||||
|
|
||||||
if meds:
|
|
||||||
pdf.set_fill_color(220, 240, 228)
|
|
||||||
pdf.set_font("Helvetica", style="B", size=8)
|
|
||||||
pdf.cell(55, 6, "Medikament", border=1, fill=True)
|
|
||||||
pdf.cell(35, 6, "Dosierung", border=1, fill=True)
|
|
||||||
pdf.cell(25, 6, "Von", border=1, fill=True)
|
|
||||||
pdf.cell(25, 6, "Bis", border=1, fill=True)
|
|
||||||
pdf.cell(50, 6, "Notiz", border=1, fill=True, ln=True)
|
|
||||||
|
|
||||||
pdf.set_font("Helvetica", size=8)
|
|
||||||
for i, m in enumerate(meds):
|
|
||||||
fill = (i % 2 == 0)
|
|
||||||
pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255)
|
|
||||||
pdf.cell(55, 6, (m["name"] or "")[:32], border=1, fill=fill)
|
|
||||||
pdf.cell(35, 6, (m["dosierung"] or "–")[:22], border=1, fill=fill)
|
|
||||||
pdf.cell(25, 6, _fmt_date(m["von"]), border=1, fill=fill)
|
|
||||||
bis = _fmt_date(m["bis"]) if m["bis"] else "dauerhaft"
|
|
||||||
pdf.cell(25, 6, bis, border=1, fill=fill)
|
|
||||||
pdf.cell(50, 6, (m["notiz"] or "–")[:30], border=1, fill=fill, ln=True)
|
|
||||||
else:
|
|
||||||
pdf.set_font("Helvetica", style="I", size=9)
|
|
||||||
pdf.set_text_color(140, 140, 140)
|
|
||||||
pdf.cell(0, 6, "Keine Medikamente eingetragen.", ln=True)
|
|
||||||
pdf.set_text_color(30, 30, 30)
|
|
||||||
|
|
||||||
# ---- Footer ----
|
|
||||||
pdf.set_y(-15)
|
|
||||||
pdf.set_font("Helvetica", style="I", size=8)
|
|
||||||
pdf.set_text_color(140, 140, 140)
|
|
||||||
pdf.cell(0, 5, "Erstellt mit Ban Yaro — banyaro.app", align="C", ln=True)
|
|
||||||
|
|
||||||
return bytes(pdf.output())
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
"""BAN YARO — Playdate-Matching"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional
|
|
||||||
from database import db
|
|
||||||
from auth import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Haversine
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
||||||
R = 6371.0
|
|
||||||
dlat = math.radians(lat2 - lat1)
|
|
||||||
dlon = math.radians(lon2 - lon1)
|
|
||||||
a = (math.sin(dlat / 2) ** 2
|
|
||||||
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
|
|
||||||
* math.sin(dlon / 2) ** 2)
|
|
||||||
return R * 2 * math.asin(math.sqrt(a))
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_alter(geburtstag: Optional[str]) -> Optional[str]:
|
|
||||||
"""Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
|
|
||||||
if not geburtstag:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
from datetime import date
|
|
||||||
geb = date.fromisoformat(geburtstag[:10])
|
|
||||||
today = date.today()
|
|
||||||
monate = (today.year - geb.year) * 12 + (today.month - geb.month)
|
|
||||||
if today.day < geb.day:
|
|
||||||
monate -= 1
|
|
||||||
if monate < 0:
|
|
||||||
return None
|
|
||||||
if monate < 24:
|
|
||||||
return f"{monate} {'Monat' if monate == 1 else 'Monate'}"
|
|
||||||
jahre = monate // 12
|
|
||||||
return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}"
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Schemas
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
class ListingUpsert(BaseModel):
|
|
||||||
dog_id: int
|
|
||||||
lat: float
|
|
||||||
lon: float
|
|
||||||
ort_name: Optional[str] = None
|
|
||||||
radius_km: int = 10
|
|
||||||
beschreibung: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RequestCreate(BaseModel):
|
|
||||||
to_dog_id: int
|
|
||||||
nachricht: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class RequestPatch(BaseModel):
|
|
||||||
status: str # accepted | declined
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def _ensure_conversation(conn, user_a: int, user_b: int) -> int:
|
|
||||||
a, b = (min(user_a, user_b), max(user_a, user_b))
|
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?",
|
|
||||||
(a, b)
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
return existing["id"]
|
|
||||||
cur = conn.execute(
|
|
||||||
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)",
|
|
||||||
(a, b)
|
|
||||||
)
|
|
||||||
return cur.lastrowid
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Routes
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/nearby")
|
|
||||||
async def nearby(lat: float, lon: float, radius: int = 10,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT pl.id AS listing_id,
|
|
||||||
pl.lat, pl.lon, pl.ort_name, pl.beschreibung,
|
|
||||||
d.id AS dog_id, d.name AS dog_name, d.rasse,
|
|
||||||
d.geburtstag, d.foto_url, d.geschlecht
|
|
||||||
FROM playdate_listings pl
|
|
||||||
JOIN dogs d ON d.id = pl.dog_id
|
|
||||||
WHERE pl.aktiv = 1
|
|
||||||
AND pl.user_id != ?
|
|
||||||
""", (uid,)).fetchall()
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for r in rows:
|
|
||||||
dist = _haversine(lat, lon, r["lat"], r["lon"])
|
|
||||||
if dist <= radius:
|
|
||||||
result.append({
|
|
||||||
"listing_id": r["listing_id"],
|
|
||||||
"dog_id": r["dog_id"],
|
|
||||||
"dog_name": r["dog_name"],
|
|
||||||
"rasse": r["rasse"],
|
|
||||||
"alter": _calc_alter(r["geburtstag"]),
|
|
||||||
"geschlecht": r["geschlecht"],
|
|
||||||
"foto_url": r["foto_url"],
|
|
||||||
"ort_name": r["ort_name"],
|
|
||||||
"beschreibung": r["beschreibung"],
|
|
||||||
"entfernung_km": round(dist, 1),
|
|
||||||
})
|
|
||||||
|
|
||||||
result.sort(key=lambda x: x["entfernung_km"])
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/listing", status_code=200)
|
|
||||||
async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
with db() as conn:
|
|
||||||
# Sicherstellen dass der Hund dem User gehört
|
|
||||||
dog = conn.execute(
|
|
||||||
"SELECT id FROM dogs WHERE id=? AND user_id=?",
|
|
||||||
(data.dog_id, uid)
|
|
||||||
).fetchone()
|
|
||||||
if not dog:
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
|
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT id FROM playdate_listings WHERE dog_id=?",
|
|
||||||
(data.dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
conn.execute("""
|
|
||||||
UPDATE playdate_listings
|
|
||||||
SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?,
|
|
||||||
aktiv=1, updated_at=datetime('now')
|
|
||||||
WHERE dog_id=?
|
|
||||||
""", (data.lat, data.lon, data.ort_name, data.radius_km,
|
|
||||||
data.beschreibung, data.dog_id))
|
|
||||||
return {"ok": True, "id": existing["id"]}
|
|
||||||
else:
|
|
||||||
cur = conn.execute("""
|
|
||||||
INSERT INTO playdate_listings
|
|
||||||
(dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung)
|
|
||||||
VALUES (?,?,?,?,?,?,?)
|
|
||||||
""", (data.dog_id, uid, data.lat, data.lon, data.ort_name,
|
|
||||||
data.radius_km, data.beschreibung))
|
|
||||||
return {"ok": True, "id": cur.lastrowid}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/listing/{dog_id}", status_code=200)
|
|
||||||
async def deactivate_listing(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?",
|
|
||||||
(dog_id, uid)
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Inserat nicht gefunden.")
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?",
|
|
||||||
(dog_id,)
|
|
||||||
)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/my-listing/{dog_id}")
|
|
||||||
async def my_listing(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"""SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv
|
|
||||||
FROM playdate_listings WHERE dog_id=? AND user_id=?""",
|
|
||||||
(dog_id, uid)
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request", status_code=201)
|
|
||||||
async def create_request(data: RequestCreate, user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
with db() as conn:
|
|
||||||
# Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users
|
|
||||||
own_dog = conn.execute(
|
|
||||||
"SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1",
|
|
||||||
(uid,)
|
|
||||||
).fetchone()
|
|
||||||
if not own_dog:
|
|
||||||
raise HTTPException(400, "Du hast noch keinen Hund eingetragen.")
|
|
||||||
|
|
||||||
from_dog_id = own_dog["id"]
|
|
||||||
|
|
||||||
# Zielhund + Besitzer prüfen
|
|
||||||
target = conn.execute(
|
|
||||||
"SELECT d.id, d.user_id FROM dogs d WHERE d.id=?",
|
|
||||||
(data.to_dog_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not target:
|
|
||||||
raise HTTPException(404, "Zielhund nicht gefunden.")
|
|
||||||
if target["user_id"] == uid:
|
|
||||||
raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.")
|
|
||||||
|
|
||||||
to_user_id = target["user_id"]
|
|
||||||
|
|
||||||
# Doppelte Anfrage verhindern
|
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?",
|
|
||||||
(from_dog_id, data.to_dog_id)
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
if existing["status"] == "pending":
|
|
||||||
raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.")
|
|
||||||
# Alte abgelehnte Anfrage: löschen und neu anlegen
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM playdate_requests WHERE id=?",
|
|
||||||
(existing["id"],)
|
|
||||||
)
|
|
||||||
|
|
||||||
cur = conn.execute("""
|
|
||||||
INSERT INTO playdate_requests
|
|
||||||
(from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht)
|
|
||||||
VALUES (?,?,?,?,?)
|
|
||||||
""", (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht))
|
|
||||||
request_id = cur.lastrowid
|
|
||||||
|
|
||||||
# Chat-Konversation anlegen (ohne Freundschaftspflicht)
|
|
||||||
conv_id = _ensure_conversation(conn, uid, to_user_id)
|
|
||||||
|
|
||||||
# Erste Nachricht mit Kontext senden
|
|
||||||
intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt."
|
|
||||||
if data.nachricht:
|
|
||||||
intro += f" Meine Nachricht: {data.nachricht}"
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO direct_messages (conversation_id, sender_id, text)
|
|
||||||
VALUES (?,?,?)
|
|
||||||
""", (conv_id, uid, intro))
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
|
|
||||||
(conv_id,)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from routes.push import send_push_to_user
|
|
||||||
send_push_to_user(to_user_id, {
|
|
||||||
"title": "Playdate-Anfrage",
|
|
||||||
"body": f"{user['name']} möchte ein Treffen vereinbaren!",
|
|
||||||
"type": "playdate_request",
|
|
||||||
"tag": f"playdate-{request_id}",
|
|
||||||
"data": {"page": "playdate"},
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"ok": True, "request_id": request_id, "conversation_id": conv_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/requests")
|
|
||||||
async def list_requests(user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
with db() as conn:
|
|
||||||
incoming = conn.execute("""
|
|
||||||
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
|
|
||||||
pr.from_user_id,
|
|
||||||
uf.name AS from_user_name,
|
|
||||||
df.name AS from_dog_name, df.rasse AS from_dog_rasse,
|
|
||||||
df.foto_url AS from_dog_foto,
|
|
||||||
df.geburtstag AS from_dog_geburtstag,
|
|
||||||
dt.name AS to_dog_name
|
|
||||||
FROM playdate_requests pr
|
|
||||||
JOIN users uf ON uf.id = pr.from_user_id
|
|
||||||
JOIN dogs df ON df.id = pr.from_dog_id
|
|
||||||
JOIN dogs dt ON dt.id = pr.to_dog_id
|
|
||||||
WHERE pr.to_user_id = ?
|
|
||||||
ORDER BY pr.created_at DESC
|
|
||||||
""", (uid,)).fetchall()
|
|
||||||
|
|
||||||
outgoing = conn.execute("""
|
|
||||||
SELECT pr.id, pr.status, pr.nachricht, pr.created_at,
|
|
||||||
pr.to_user_id,
|
|
||||||
ut.name AS to_user_name,
|
|
||||||
dt.name AS to_dog_name, dt.rasse AS to_dog_rasse,
|
|
||||||
dt.foto_url AS to_dog_foto,
|
|
||||||
df.name AS from_dog_name
|
|
||||||
FROM playdate_requests pr
|
|
||||||
JOIN users ut ON ut.id = pr.to_user_id
|
|
||||||
JOIN dogs dt ON dt.id = pr.to_dog_id
|
|
||||||
JOIN dogs df ON df.id = pr.from_dog_id
|
|
||||||
WHERE pr.from_user_id = ?
|
|
||||||
ORDER BY pr.created_at DESC
|
|
||||||
""", (uid,)).fetchall()
|
|
||||||
|
|
||||||
def _enrich(rows, direction):
|
|
||||||
result = []
|
|
||||||
for r in rows:
|
|
||||||
d = dict(r)
|
|
||||||
d["direction"] = direction
|
|
||||||
if direction == "incoming":
|
|
||||||
d["alter"] = _calc_alter(d.get("from_dog_geburtstag"))
|
|
||||||
result.append(d)
|
|
||||||
return result
|
|
||||||
|
|
||||||
return {
|
|
||||||
"incoming": _enrich(incoming, "incoming"),
|
|
||||||
"outgoing": _enrich(outgoing, "outgoing"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/requests/{req_id}", status_code=200)
|
|
||||||
async def patch_request(req_id: int, data: RequestPatch,
|
|
||||||
user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
if data.status not in ("accepted", "declined"):
|
|
||||||
raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.")
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
req = conn.execute(
|
|
||||||
"SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?",
|
|
||||||
(req_id, uid)
|
|
||||||
).fetchone()
|
|
||||||
if not req:
|
|
||||||
raise HTTPException(404, "Anfrage nicht gefunden.")
|
|
||||||
if req["status"] != "pending":
|
|
||||||
raise HTTPException(409, "Anfrage wurde bereits beantwortet.")
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE playdate_requests SET status=? WHERE id=?",
|
|
||||||
(data.status, req_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
conv_id = None
|
|
||||||
if data.status == "accepted":
|
|
||||||
conv_id = _ensure_conversation(conn, uid, req["from_user_id"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
from routes.push import send_push_to_user
|
|
||||||
verb = "angenommen" if data.status == "accepted" else "abgelehnt"
|
|
||||||
send_push_to_user(req["from_user_id"], {
|
|
||||||
"title": f"Playdate {verb}!",
|
|
||||||
"body": f"{user['name']} hat deine Anfrage {verb}.",
|
|
||||||
"type": "playdate_response",
|
|
||||||
"tag": f"playdate-{req_id}",
|
|
||||||
"data": {"page": "playdate"},
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {"ok": True, "conversation_id": conv_id}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
"""BAN YARO — Rückruf-Alarm (Tierfutter)
|
|
||||||
RASFF EU Rapid Alert System for Food and Feed
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from database import db
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
RASFF_URL = "https://webgate.ec.europa.eu/rasff-window/backend/public/notification/list/with-filters"
|
|
||||||
RASFF_PARAMS = {
|
|
||||||
"filters": '{"subject.product_category":["pet food and animal feed"]}',
|
|
||||||
"pageNumber": 0,
|
|
||||||
"pageSize": 20,
|
|
||||||
"sortColumn": "notificationDate",
|
|
||||||
"sortDirection": "DESC",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /api/recalls — Letzte 50 Rückrufe
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("")
|
|
||||||
async def list_recalls(q: str = ""):
|
|
||||||
with db() as conn:
|
|
||||||
if q:
|
|
||||||
like = f"%{q}%"
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
|
|
||||||
FROM feed_recalls
|
|
||||||
WHERE titel LIKE ? OR produkt LIKE ? OR gefahr LIKE ? OR herkunft LIKE ?
|
|
||||||
ORDER BY datum DESC
|
|
||||||
LIMIT 50
|
|
||||||
""", (like, like, like, like)).fetchall()
|
|
||||||
else:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at
|
|
||||||
FROM feed_recalls
|
|
||||||
ORDER BY datum DESC
|
|
||||||
LIMIT 50
|
|
||||||
""").fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Interne Hilfsfunktion: RASFF API abfragen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def fetch_rasff_recalls() -> list[dict]:
|
|
||||||
"""Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück."""
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
resp = await client.get(RASFF_URL, params=RASFF_PARAMS)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"RASFF API-Fehler: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
entries = []
|
|
||||||
try:
|
|
||||||
items = data.get("data", {}).get("list", [])
|
|
||||||
for item in items:
|
|
||||||
reference = item.get("reference", "")
|
|
||||||
if not reference:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Datum
|
|
||||||
datum_raw = item.get("notificationDate", "")
|
|
||||||
datum = datum_raw[:10] if datum_raw else ""
|
|
||||||
|
|
||||||
# Produkt
|
|
||||||
subject = item.get("subject") or {}
|
|
||||||
produkt = subject.get("product", "") or ""
|
|
||||||
|
|
||||||
# Gefahr
|
|
||||||
hazards = subject.get("hazard") or []
|
|
||||||
gefahr = ""
|
|
||||||
if hazards:
|
|
||||||
gefahr = hazards[0].get("hazardDescription", "") or ""
|
|
||||||
|
|
||||||
# Herkunft
|
|
||||||
origin = item.get("origin") or {}
|
|
||||||
herkunft = origin.get("name", "") or ""
|
|
||||||
|
|
||||||
# URL zur RASFF-Seite
|
|
||||||
url = f"https://webgate.ec.europa.eu/rasff-window/screen/notificationDetail?notifRef={reference}"
|
|
||||||
|
|
||||||
entries.append({
|
|
||||||
"external_id": reference,
|
|
||||||
"titel": produkt or reference,
|
|
||||||
"produkt": produkt,
|
|
||||||
"gefahr": gefahr,
|
|
||||||
"herkunft": herkunft,
|
|
||||||
"datum": datum,
|
|
||||||
"quelle": "rasff",
|
|
||||||
"url": url,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"RASFF Parsing-Fehler: {e}")
|
|
||||||
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Interne Hilfsfunktion: Neue Einträge in DB speichern
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
def save_new_recalls(entries: list[dict]) -> list[dict]:
|
|
||||||
"""Speichert neue Einträge und gibt die Liste der neuen Einträge zurück."""
|
|
||||||
new_entries = []
|
|
||||||
for entry in entries:
|
|
||||||
try:
|
|
||||||
with db() as conn:
|
|
||||||
exists = conn.execute(
|
|
||||||
"SELECT id FROM feed_recalls WHERE external_id=?",
|
|
||||||
(entry["external_id"],)
|
|
||||||
).fetchone()
|
|
||||||
if not exists:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO feed_recalls
|
|
||||||
(external_id, titel, produkt, gefahr, herkunft, datum, quelle, url)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""", (
|
|
||||||
entry["external_id"],
|
|
||||||
entry["titel"],
|
|
||||||
entry["produkt"],
|
|
||||||
entry["gefahr"],
|
|
||||||
entry["herkunft"],
|
|
||||||
entry["datum"],
|
|
||||||
entry["quelle"],
|
|
||||||
entry["url"],
|
|
||||||
))
|
|
||||||
new_entries.append(entry)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Recall DB-Fehler für {entry.get('external_id')}: {e}")
|
|
||||||
return new_entries
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
"""BAN YARO — Trainings-Streak"""
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from database import db
|
|
||||||
from auth import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
_today = lambda: datetime.date.today().isoformat()
|
|
||||||
_yesterday = lambda: (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /streak/leaderboard — Top-10 Streaks (öffentliche Hunde)
|
|
||||||
# Muss VOR /{dog_id} stehen, sonst greift der int-Parameter zuerst.
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/streak/leaderboard")
|
|
||||||
async def get_leaderboard(user=Depends(get_current_user)):
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT
|
|
||||||
u.name AS user_name,
|
|
||||||
d.name AS dog_name,
|
|
||||||
d.rasse,
|
|
||||||
d.foto_url,
|
|
||||||
ts.current_streak
|
|
||||||
FROM training_streaks ts
|
|
||||||
JOIN dogs d ON d.id = ts.dog_id
|
|
||||||
JOIN users u ON u.id = ts.user_id
|
|
||||||
WHERE ts.current_streak > 0
|
|
||||||
AND (d.is_public = 1 OR d.user_id = ts.user_id)
|
|
||||||
ORDER BY ts.current_streak DESC
|
|
||||||
LIMIT 10
|
|
||||||
""").fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# GET /streak/{dog_id} — aktueller Streak eines Hundes
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.get("/streak/{dog_id}")
|
|
||||||
async def get_streak(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
with db() as conn:
|
|
||||||
dog = conn.execute(
|
|
||||||
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
|
|
||||||
).fetchone()
|
|
||||||
if not dog:
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT current_streak, longest_streak, last_training_date "
|
|
||||||
"FROM training_streaks WHERE user_id=? AND dog_id=?",
|
|
||||||
(uid, dog_id)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return {"current_streak": 0, "longest_streak": 0, "last_training_date": None}
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# POST /streak/{dog_id}/ping — Training heute registrieren
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
@router.post("/streak/{dog_id}/ping")
|
|
||||||
async def ping_streak(dog_id: int, user=Depends(get_current_user)):
|
|
||||||
uid = user["id"]
|
|
||||||
today = _today()
|
|
||||||
yest = _yesterday()
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
dog = conn.execute(
|
|
||||||
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid)
|
|
||||||
).fetchone()
|
|
||||||
if not dog:
|
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
|
||||||
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT current_streak, longest_streak, last_training_date "
|
|
||||||
"FROM training_streaks WHERE user_id=? AND dog_id=?",
|
|
||||||
(uid, dog_id)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
cur = row["current_streak"]
|
|
||||||
longest = row["longest_streak"]
|
|
||||||
last = row["last_training_date"]
|
|
||||||
|
|
||||||
if last == today:
|
|
||||||
# Bereits heute gepingt — nichts tun
|
|
||||||
return {"current_streak": cur, "longest_streak": longest, "last_training_date": last}
|
|
||||||
elif last == yest:
|
|
||||||
cur += 1
|
|
||||||
else:
|
|
||||||
cur = 1
|
|
||||||
|
|
||||||
longest = max(longest, cur)
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE training_streaks SET current_streak=?, longest_streak=?, last_training_date=? "
|
|
||||||
"WHERE user_id=? AND dog_id=?",
|
|
||||||
(cur, longest, today, uid, dog_id)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur = 1
|
|
||||||
longest = 1
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO training_streaks (user_id, dog_id, current_streak, longest_streak, last_training_date) "
|
|
||||||
"VALUES (?,?,?,?,?)",
|
|
||||||
(uid, dog_id, cur, longest, today)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"current_streak": cur, "longest_streak": longest, "last_training_date": today}
|
|
||||||
|
|
@ -63,68 +63,15 @@ def _fmt_opening_hours(raw: str | None) -> str | None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/my-favorite")
|
|
||||||
async def get_my_favorite(user=Depends(get_current_user)):
|
|
||||||
"""Favoriten-Tierarzt des Users (oder null)."""
|
|
||||||
with db() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"""SELECT t.* FROM tieraerzte t
|
|
||||||
JOIN favorite_vets fv ON fv.vet_id = t.id
|
|
||||||
WHERE fv.user_id = ?
|
|
||||||
LIMIT 1""",
|
|
||||||
(user["id"],)
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return dict(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{vet_id}/favorite")
|
|
||||||
async def toggle_favorite(vet_id: int, user=Depends(get_current_user)):
|
|
||||||
"""Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück."""
|
|
||||||
with db() as conn:
|
|
||||||
vet = conn.execute(
|
|
||||||
"SELECT id FROM tieraerzte WHERE id=?", (vet_id,)
|
|
||||||
).fetchone()
|
|
||||||
if not vet:
|
|
||||||
raise HTTPException(404, "Tierarzt nicht gefunden.")
|
|
||||||
|
|
||||||
existing = conn.execute(
|
|
||||||
"SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?",
|
|
||||||
(user["id"], vet_id)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?",
|
|
||||||
(user["id"], vet_id)
|
|
||||||
)
|
|
||||||
return {"is_favorite": False}
|
|
||||||
else:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)",
|
|
||||||
(user["id"], vet_id)
|
|
||||||
)
|
|
||||||
return {"is_favorite": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_tieraerzte(user=Depends(get_current_user)):
|
async def list_tieraerzte(user=Depends(get_current_user)):
|
||||||
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite."""
|
"""Alle Tierärzte des Users — aktive zuerst, dann inaktive."""
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
|
"SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
favs = {r["vet_id"] for r in conn.execute(
|
return [dict(r) for r in rows]
|
||||||
"SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],)
|
|
||||||
).fetchall()}
|
|
||||||
result = []
|
|
||||||
for r in rows:
|
|
||||||
d = dict(r)
|
|
||||||
d["is_favorite"] = r["id"] in favs
|
|
||||||
result.append(d)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/osm-nearby")
|
@router.get("/osm-nearby")
|
||||||
|
|
|
||||||
|
|
@ -124,14 +124,6 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
misfire_grace_time=3600,
|
||||||
)
|
)
|
||||||
# Täglich 06:30 — Wiederkehrende Ausgaben anlegen
|
|
||||||
_scheduler.add_job(
|
|
||||||
_job_recurring_expenses,
|
|
||||||
CronTrigger(hour=6, minute=30),
|
|
||||||
id="recurring_expenses",
|
|
||||||
replace_existing=True,
|
|
||||||
misfire_grace_time=3600,
|
|
||||||
)
|
|
||||||
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen
|
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_hdm_winner,
|
_job_hdm_winner,
|
||||||
|
|
@ -140,24 +132,8 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
misfire_grace_time=3600,
|
||||||
)
|
)
|
||||||
# Täglich 19:00 Uhr — Streak-Erinnerung
|
|
||||||
_scheduler.add_job(
|
|
||||||
_job_streak_reminder,
|
|
||||||
CronTrigger(hour=19, minute=0),
|
|
||||||
id="streak_reminder",
|
|
||||||
replace_existing=True,
|
|
||||||
misfire_grace_time=3600,
|
|
||||||
)
|
|
||||||
# Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF)
|
|
||||||
_scheduler.add_job(
|
|
||||||
_job_recall_check,
|
|
||||||
CronTrigger(hour=8, minute=0),
|
|
||||||
id="recall_check",
|
|
||||||
replace_existing=True,
|
|
||||||
misfire_grace_time=3600,
|
|
||||||
)
|
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00. OSM-Cache: on-demand (kein Prewarm).")
|
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
|
||||||
|
|
||||||
|
|
||||||
def stop():
|
def stop():
|
||||||
|
|
@ -879,8 +855,6 @@ async def _job_status_report():
|
||||||
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
||||||
"ki_health_report": "KI-Gesundheitsberichte",
|
"ki_health_report": "KI-Gesundheitsberichte",
|
||||||
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
|
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
|
||||||
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
|
|
||||||
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
|
|
||||||
}
|
}
|
||||||
job_rows_html = ""
|
job_rows_html = ""
|
||||||
job_rows_txt = ""
|
job_rows_txt = ""
|
||||||
|
|
@ -1198,93 +1172,3 @@ async def _job_hdm_winner():
|
||||||
|
|
||||||
logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.")
|
logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.")
|
||||||
_log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)")
|
_log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# JOB: Streak-Erinnerung (täglich 19:00)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _job_streak_reminder():
|
|
||||||
"""
|
|
||||||
Findet alle User die heute noch nicht trainiert haben (last_training_date < heute)
|
|
||||||
und deren current_streak > 0. Sendet einen motivierenden Push pro Hund.
|
|
||||||
"""
|
|
||||||
today = str(date.today())
|
|
||||||
logger.info(f"Streak-Reminder Job läuft für {today}")
|
|
||||||
|
|
||||||
with db() as conn:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT ts.user_id, ts.dog_id, ts.current_streak, d.name AS dog_name
|
|
||||||
FROM training_streaks ts
|
|
||||||
JOIN dogs d ON d.id = ts.dog_id
|
|
||||||
WHERE ts.current_streak > 0
|
|
||||||
AND (ts.last_training_date IS NULL OR ts.last_training_date < ?)
|
|
||||||
""", (today,)).fetchall()
|
|
||||||
|
|
||||||
sent_total = 0
|
|
||||||
for r in rows:
|
|
||||||
n = r["current_streak"]
|
|
||||||
sent = send_push_to_user(r["user_id"], {
|
|
||||||
"type": "streak_reminder",
|
|
||||||
"title": f"🔥 {r['dog_name']} wartet auf sein Training!",
|
|
||||||
"body": f"Streak: {n} {'Tag' if n == 1 else 'Tage'} — nicht jetzt aufhören.",
|
|
||||||
"data": {"page": "uebungen"},
|
|
||||||
"tag": f"streak-{r['dog_id']}-{today}",
|
|
||||||
})
|
|
||||||
sent_total += sent
|
|
||||||
|
|
||||||
logger.info(f"Streak-Reminder Job fertig — {len(rows)} Hunde geprüft, {sent_total} Push gesendet.")
|
|
||||||
_log_job("streak_reminder", "ok", f"{sent_total} Push an {len(rows)} Hunde")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _job_recall_check():
|
|
||||||
"""
|
|
||||||
Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab.
|
|
||||||
Neue Einträge werden in DB gespeichert, für jeden wird ein Push
|
|
||||||
an alle abonnierten User gesendet.
|
|
||||||
"""
|
|
||||||
logger.info("Rückruf-Check Job läuft")
|
|
||||||
try:
|
|
||||||
from routes.recalls import fetch_rasff_recalls, save_new_recalls
|
|
||||||
entries = await fetch_rasff_recalls()
|
|
||||||
if not entries:
|
|
||||||
logger.info("Rückruf-Check: Keine Einträge von RASFF erhalten (API-Fehler oder leer).")
|
|
||||||
_log_job("recall_check", "ok", "0 neue Rückrufe (API leer)")
|
|
||||||
return
|
|
||||||
|
|
||||||
new_entries = save_new_recalls(entries)
|
|
||||||
logger.info(f"Rückruf-Check: {len(new_entries)} neue von {len(entries)} geprüften Einträgen.")
|
|
||||||
|
|
||||||
for entry in new_entries:
|
|
||||||
produkt = entry.get("produkt") or entry.get("titel") or "Unbekanntes Produkt"
|
|
||||||
gefahr = entry.get("gefahr") or "Bitte Produktdetails prüfen"
|
|
||||||
ext_id = entry["external_id"]
|
|
||||||
body = f"{produkt} — {gefahr[:80]}"
|
|
||||||
send_push_to_all({
|
|
||||||
"title": "⚠️ Tierfutter-Rückruf",
|
|
||||||
"body": body,
|
|
||||||
"data": {"page": "recalls"},
|
|
||||||
"tag": f"recall-{ext_id}",
|
|
||||||
})
|
|
||||||
logger.info(f"Rückruf-Push gesendet: {ext_id} — {produkt}")
|
|
||||||
|
|
||||||
_log_job("recall_check", "ok", f"{len(new_entries)} neue Rückrufe")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}")
|
|
||||||
_log_job("recall_check", "error", str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# JOB: Wiederkehrende Ausgaben anlegen
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _job_recurring_expenses():
|
|
||||||
try:
|
|
||||||
from routes.expenses import process_due_recurring
|
|
||||||
count = process_due_recurring()
|
|
||||||
logger.info(f"Daueraufträge: {count} Einträge angelegt.")
|
|
||||||
_log_job("recurring_expenses", "ok", f"{count} Einträge")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Daueraufträge-Job Fehler: {e}")
|
|
||||||
_log_job("recurring_expenses", "error", str(e))
|
|
||||||
|
|
|
||||||
|
|
@ -6803,716 +6803,3 @@ svg.empty-state-icon {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
STREAK-WIDGET (Welcome-Seite)
|
|
||||||
------------------------------------------------------------ */
|
|
||||||
.wc-streak-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-5);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-radius: var(--radius-lg, 14px);
|
|
||||||
background: linear-gradient(135deg, #ff6b00 0%, #c0392b 100%);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 4px 18px rgba(196, 63, 0, 0.35);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.wc-streak-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: radial-gradient(ellipse at 10% 50%, rgba(255,255,255,0.15) 0%, transparent 60%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.wc-streak-flame-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.wc-streak-flame {
|
|
||||||
font-size: 2.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3));
|
|
||||||
}
|
|
||||||
.wc-streak-number {
|
|
||||||
font-size: 2.6rem;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
.wc-streak-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.wc-streak-label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
.wc-streak-best {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
opacity: 0.75;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.wc-streak-lb-btn {
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border: 1.5px solid rgba(255,255,255,0.45);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #fff;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.wc-streak-lb-btn:active { background: rgba(255,255,255,0.35); }
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
|
||||||
KI RASSEN-ERKENNUNG — Ergebnis-Block
|
|
||||||
------------------------------------------------------------ */
|
|
||||||
.rasse-result-card {
|
|
||||||
background: var(--c-surface);
|
|
||||||
border: 1px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-4);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
.rasse-result-card--top {
|
|
||||||
border-color: var(--c-primary);
|
|
||||||
background: var(--c-primary-subtle, #f0f9ff);
|
|
||||||
}
|
|
||||||
.rasse-result-name {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-text);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
.rasse-result-bar-wrap {
|
|
||||||
background: var(--c-surface-2);
|
|
||||||
border-radius: 999px;
|
|
||||||
height: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: var(--space-2) 0;
|
|
||||||
}
|
|
||||||
.rasse-result-bar {
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--c-primary);
|
|
||||||
transition: width 0.6s ease;
|
|
||||||
}
|
|
||||||
.rasse-result-bar--dim {
|
|
||||||
background: var(--c-text-muted, #9ca3af);
|
|
||||||
}
|
|
||||||
.rasse-result-pct {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-primary);
|
|
||||||
}
|
|
||||||
.rasse-result-pct--dim {
|
|
||||||
color: var(--c-text-muted);
|
|
||||||
}
|
|
||||||
.rasse-result-desc {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rückrufe — Warnbanner (Dark-Mode-sicher) */
|
|
||||||
.recalls-warning-banner {
|
|
||||||
background: var(--c-danger-subtle);
|
|
||||||
border: 1px solid var(--c-danger);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.recalls-warning-icon {
|
|
||||||
color: var(--c-danger);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.recalls-warning-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--c-text);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
Ausgaben-Tracker (expenses.js)
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
/* FAB */
|
|
||||||
.exp-fab {
|
|
||||||
position: fixed;
|
|
||||||
bottom: calc(var(--nav-height, 64px) + var(--space-4));
|
|
||||||
right: var(--space-4);
|
|
||||||
z-index: 100;
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--c-primary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 4px 14px rgba(0,0,0,.25);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
transition: transform .15s, box-shadow .15s;
|
|
||||||
}
|
|
||||||
.exp-fab:active {
|
|
||||||
transform: scale(.93);
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lade-/Fehler-Zustände */
|
|
||||||
.exp-loading { padding: var(--space-4); }
|
|
||||||
.exp-error {
|
|
||||||
padding: var(--space-4);
|
|
||||||
color: var(--c-danger);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.exp-empty-hint {
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
padding: var(--space-3) 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Hero-Card (Übersicht & Statistik oben) ---- */
|
|
||||||
.exp-hero-card {
|
|
||||||
background: linear-gradient(135deg, var(--c-primary) 0%, color-mix(in srgb, var(--c-primary) 75%, #000) 100%);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius-xl, 16px);
|
|
||||||
padding: var(--space-5) var(--space-4);
|
|
||||||
margin: var(--space-3) var(--space-3) var(--space-4);
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 6px 20px rgba(0,0,0,.15);
|
|
||||||
}
|
|
||||||
.exp-hero-card--sm {
|
|
||||||
padding: var(--space-4) var(--space-4);
|
|
||||||
}
|
|
||||||
.exp-hero-label {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
opacity: .85;
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
.exp-hero-betrag {
|
|
||||||
font-size: clamp(1.9rem, 7vw, 2.8rem);
|
|
||||||
font-weight: var(--weight-bold);
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: -.02em;
|
|
||||||
}
|
|
||||||
.exp-hero-meta {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
opacity: .85;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Trend-Badge */
|
|
||||||
.exp-trend {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
.exp-trend--up { background: rgba(239,68,68,.25); }
|
|
||||||
.exp-trend--down { background: rgba(16,185,129,.25); }
|
|
||||||
|
|
||||||
/* ---- Kachel-Grid (Übersicht) ---- */
|
|
||||||
.exp-kachel-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: 0 var(--space-3) var(--space-3);
|
|
||||||
}
|
|
||||||
.exp-kachel {
|
|
||||||
background: var(--c-surface);
|
|
||||||
border: 1px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-3) var(--space-2);
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
.exp-kachel-icon {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
.exp-kachel-betrag {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-bold);
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
.exp-kachel-label {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Sektion-Block (Verlauf etc.) ---- */
|
|
||||||
.exp-section {
|
|
||||||
margin: 0 var(--space-3) var(--space-4);
|
|
||||||
background: var(--c-surface);
|
|
||||||
border: 1px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
.exp-section-title {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Balkendiagramm (Verlauf) ---- */
|
|
||||||
.exp-bar-chart {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--space-1);
|
|
||||||
height: 80px;
|
|
||||||
}
|
|
||||||
.exp-bar-chart--12 {
|
|
||||||
height: 90px;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.exp-bar-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
.exp-bar-item--aktiv .exp-bar-label {
|
|
||||||
color: var(--c-primary);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
}
|
|
||||||
.exp-bar-track {
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
background: var(--c-surface-2, #f3f4f6);
|
|
||||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.exp-bar-track--stack {
|
|
||||||
height: 70px;
|
|
||||||
}
|
|
||||||
.exp-bar-fill {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--c-primary);
|
|
||||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
||||||
transition: height .4s ease;
|
|
||||||
}
|
|
||||||
.exp-bar-fill--aktiv { background: var(--c-primary); }
|
|
||||||
.exp-stack-seg {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2px;
|
|
||||||
transition: height .4s ease;
|
|
||||||
}
|
|
||||||
.exp-bar-label {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-muted, #9ca3af);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.exp-bar-val {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Einträge-Liste ---- */
|
|
||||||
.exp-list {
|
|
||||||
padding: 0 var(--space-3);
|
|
||||||
}
|
|
||||||
.exp-month-group {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
.exp-month-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
background: var(--c-surface-2, #f3f4f6);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
.exp-month-title {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .04em;
|
|
||||||
}
|
|
||||||
.exp-month-summe {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-bold);
|
|
||||||
color: var(--c-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Einzelner Eintrag */
|
|
||||||
.exp-entry {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
background: var(--c-surface);
|
|
||||||
border: 1px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-3);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s;
|
|
||||||
}
|
|
||||||
.exp-entry:active { background: var(--c-surface-2, #f3f4f6); }
|
|
||||||
|
|
||||||
/* Icon-Badge mit Kategorie-Farbe */
|
|
||||||
.exp-entry-icon-badge {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in srgb, var(--kat-color) 15%, transparent);
|
|
||||||
color: var(--kat-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exp-entry-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.exp-entry-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-1);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.exp-entry-datum {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-muted, #9ca3af);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.exp-entry-kat {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--c-text);
|
|
||||||
}
|
|
||||||
.exp-entry-notiz {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.exp-dog-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
background: var(--c-surface-2, #f3f4f6);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rechte Spalte: Betrag + Löschen-Icon */
|
|
||||||
.exp-entry-right {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--space-1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.exp-entry-betrag {
|
|
||||||
font-size: var(--text-base);
|
|
||||||
font-weight: var(--weight-bold);
|
|
||||||
color: var(--c-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.exp-entry-del {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--c-text-muted, #9ca3af);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color .15s;
|
|
||||||
}
|
|
||||||
.exp-entry-del:hover { color: var(--c-danger); }
|
|
||||||
|
|
||||||
/* ---- Statistik: Kategorie-Balken-Reihen ---- */
|
|
||||||
.exp-stat-rows {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.exp-stat-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 120px 1fr 36px 80px;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.exp-stat-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
color: var(--c-text);
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.exp-stat-icon { flex-shrink: 0; }
|
|
||||||
.exp-stat-bar-wrap {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--c-surface-2, #f3f4f6);
|
|
||||||
border-radius: 999px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.exp-stat-bar {
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
transition: width .5s ease;
|
|
||||||
}
|
|
||||||
.exp-stat-pct {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.exp-stat-val {
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-text);
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Donut-Diagramm (CSS conic-gradient) ---- */
|
|
||||||
.exp-donut-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-5);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.exp-donut {
|
|
||||||
position: relative;
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.exp-donut-hole {
|
|
||||||
position: absolute;
|
|
||||||
inset: 28px;
|
|
||||||
background: var(--c-surface);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.exp-donut-legend {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-width: 130px;
|
|
||||||
}
|
|
||||||
.exp-donut-legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.exp-donut-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.exp-donut-legend-label {
|
|
||||||
flex: 1;
|
|
||||||
color: var(--c-text);
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.exp-donut-legend-pct {
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Daueraufträge */
|
|
||||||
.exp-recurring-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: var(--c-surface);
|
|
||||||
border: 1.5px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
transition: opacity .2s;
|
|
||||||
}
|
|
||||||
.exp-recurring-card--inaktiv { opacity: .55; }
|
|
||||||
.exp-recurring-freq {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-primary);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
background: var(--c-primary-subtle);
|
|
||||||
padding: 1px var(--space-2);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
}
|
|
||||||
.exp-recurring-next {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-muted);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.exp-badge-inaktiv {
|
|
||||||
background: var(--c-surface-2);
|
|
||||||
color: var(--c-text-muted);
|
|
||||||
padding: 1px var(--space-2);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.exp-icon-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1.5px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--c-surface);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color .15s, border-color .15s;
|
|
||||||
}
|
|
||||||
.exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); }
|
|
||||||
.exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); }
|
|
||||||
|
|
||||||
/* Ausgaben-Formular — Kategorie-Kacheln */
|
|
||||||
.exp-kat-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.exp-kat-tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-3) var(--space-2);
|
|
||||||
border: 1.5px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color .15s, background .15s;
|
|
||||||
background: var(--c-surface);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.exp-kat-tile:hover { border-color: var(--c-primary); }
|
|
||||||
.exp-kat-tile--sel {
|
|
||||||
border-color: var(--c-primary);
|
|
||||||
background: var(--c-primary-subtle);
|
|
||||||
}
|
|
||||||
.exp-kat-tile-icon { font-size: 1.4rem; line-height: 1; }
|
|
||||||
.exp-kat-tile-label {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.exp-kat-tile--sel .exp-kat-tile-label { color: var(--c-primary); }
|
|
||||||
|
|
||||||
/* Betrag-Feld mit €-Prefix */
|
|
||||||
.exp-betrag-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.exp-betrag-prefix {
|
|
||||||
position: absolute;
|
|
||||||
left: var(--space-3);
|
|
||||||
color: var(--c-text-muted);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.exp-betrag-input { padding-left: calc(var(--space-3) + 14px + var(--space-2)) !important; }
|
|
||||||
|
|
||||||
/* Form-Label Hint */
|
|
||||||
.form-label-hint { color: var(--c-text-muted); font-weight: normal; font-size: var(--text-xs); }
|
|
||||||
|
|
||||||
/* Wiederholungs-Sektion */
|
|
||||||
.exp-repeat-section {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
border-top: 1px solid var(--c-border-light);
|
|
||||||
}
|
|
||||||
.exp-repeat-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: var(--c-text);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.exp-repeat-toggle-box {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: 1.5px solid var(--c-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--c-surface);
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background .15s, border-color .15s;
|
|
||||||
}
|
|
||||||
.exp-repeat-toggle input:checked ~ .exp-repeat-toggle-box {
|
|
||||||
background: var(--c-primary);
|
|
||||||
border-color: var(--c-primary);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -190,84 +190,4 @@
|
||||||
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
|
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,144H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol>
|
<symbol id="envelope-simple" viewBox="0 0 256 256"><path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48Zm-8,144H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/></symbol>
|
||||||
<symbol id="arrow-bend-up-left" viewBox="0 0 256 256"><path d="M232,200a8,8,0,0,1-16,0,88.1,88.1,0,0,0-88-88H88v40a8,8,0,0,1-13.66,5.66l-48-48a8,8,0,0,1,0-11.32l48-48A8,8,0,0,1,88,56V96h40A104.11,104.11,0,0,1,232,200Z"/></symbol>
|
<symbol id="arrow-bend-up-left" viewBox="0 0 256 256"><path d="M232,200a8,8,0,0,1-16,0,88.1,88.1,0,0,0-88-88H88v40a8,8,0,0,1-13.66,5.66l-48-48a8,8,0,0,1,0-11.32l48-48A8,8,0,0,1,88,56V96h40A104.11,104.11,0,0,1,232,200Z"/></symbol></svg>
|
||||||
<symbol id="bowl-food" viewBox="0 0 256 256">
|
|
||||||
<path d="M224,104h-8.37a88,88,0,0,0-175.26,0H32a8,8,0,0,0-8,8,104.35,104.35,0,0,0,56,92.28V208a16,16,0,0,0,16,16h64a16,16,0,0,0,16-16v-3.72A104.35,104.35,0,0,0,232,112,8,8,0,0,0,224,104ZM173.48,56.23q2.75,2.25,5.27,4.75a87.92,87.92,0,0,0-49.15,43H100.1A72.26,72.26,0,0,1,168,56C169.83,56,171.66,56.09,173.48,56.23ZM148.12,104a71.84,71.84,0,0,1,41.27-29.57A71.45,71.45,0,0,1,199.54,104ZM128,40a71.87,71.87,0,0,1,19,2.57A88.36,88.36,0,0,0,83.33,104H56.46A72.08,72.08,0,0,1,128,40Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="calendar" viewBox="0 0 256 256">
|
|
||||||
<path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM112,184a8,8,0,0,1-16,0V132.94l-4.42,2.22a8,8,0,0,1-7.16-14.32l16-8A8,8,0,0,1,112,120Zm56-8a8,8,0,0,1,0,16H136a8,8,0,0,1-6.4-12.8l28.78-38.37A8,8,0,1,0,145.07,132a8,8,0,1,1-13.85-8A24,24,0,0,1,176,136a23.76,23.76,0,0,1-4.84,14.45L152,176ZM48,80V48H72v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="calendar-blank" viewBox="0 0 256 256">
|
|
||||||
<path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm0,48H48V48H72v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="chart-bar" viewBox="0 0 256 256">
|
|
||||||
<path d="M232,208a8,8,0,0,1-8,8H32a8,8,0,0,1,0-16h8V136a8,8,0,0,1,8-8H72a8,8,0,0,1,8,8v64H96V88a8,8,0,0,1,8-8h32a8,8,0,0,1,8,8V200h16V40a8,8,0,0,1,8-8h40a8,8,0,0,1,8,8V200h8A8,8,0,0,1,232,208Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="chart-pie" viewBox="0 0 256 256">
|
|
||||||
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,16a88,88,0,0,1,71.87,37.27L128,118.76Zm0,176a88,88,0,0,1-71.87-37.27L207.89,91.12A88,88,0,0,1,128,216Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="crosshair" viewBox="0 0 256 256">
|
|
||||||
<path d="M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM168,128a40,40,0,1,1-40-40A40,40,0,0,1,168,128Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="globe-hemisphere-west" viewBox="0 0 256 256">
|
|
||||||
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.62,87.62,0,0,1-6.4,32.94l-44.7-27.49a15.92,15.92,0,0,0-6.24-2.23l-22.82-3.08a16.11,16.11,0,0,0-16,7.86h-8.72l-3.8-7.86a15.91,15.91,0,0,0-11-8.67l-8-1.73L96.14,104h16.71a16.06,16.06,0,0,0,7.73-2l12.25-6.76a16.62,16.62,0,0,0,3-2.14l26.91-24.34A15.93,15.93,0,0,0,166,49.1l-.36-.65A88.11,88.11,0,0,1,216,128ZM40,128a87.53,87.53,0,0,1,8.54-37.8l11.34,30.27a16,16,0,0,0,11.62,10l21.43,4.61L96.74,143a16.09,16.09,0,0,0,14.4,9h1.48l-7.23,16.23a16,16,0,0,0,2.86,17.37l.14.14L128,205.94l-1.94,10A88.11,88.11,0,0,1,40,128Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="printer" viewBox="0 0 256 256">
|
|
||||||
<path d="M240,96v80a8,8,0,0,1-8,8H200v32a8,8,0,0,1-8,8H64a8,8,0,0,1-8-8V184H24a8,8,0,0,1-8-8V96c0-13.23,11.36-24,25.33-24H56V40a8,8,0,0,1,8-8H192a8,8,0,0,1,8,8V72h14.67C228.64,72,240,82.77,240,96ZM72,72H184V48H72Zm112,88H72v48H184Zm16-44a12,12,0,1,0-12,12A12,12,0,0,0,200,116Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="receipt" viewBox="0 0 256 256">
|
|
||||||
<path d="M216,40H40A16,16,0,0,0,24,56V208a8,8,0,0,0,11.58,7.15L64,200.94l28.42,14.21a8,8,0,0,0,7.16,0L128,200.94l28.42,14.21a8,8,0,0,0,7.16,0L192,200.94l28.42,14.21A8,8,0,0,0,232,208V56A16,16,0,0,0,216,40ZM176,144H80a8,8,0,0,1,0-16h96a8,8,0,0,1,0,16Zm0-32H80a8,8,0,0,1,0-16h96a8,8,0,0,1,0,16Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="upload-simple" viewBox="0 0 256 256">
|
|
||||||
<path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0ZM88,80h32v64a8,8,0,0,0,16,0V80h32a8,8,0,0,0,5.66-13.66l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,88,80Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="file-pdf" viewBox="0 0 256 256">
|
|
||||||
<path d="M44,120H212a4,4,0,0,0,4-4V88a8,8,0,0,0-2.34-5.66l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v76A4,4,0,0,0,44,120ZM152,44l44,44H152Zm72,108.53a8.18,8.18,0,0,1-8.25,7.47H192v16h15.73a8.17,8.17,0,0,1,8.25,7.47,8,8,0,0,1-8,8.53H192v15.73a8.17,8.17,0,0,1-7.47,8.25,8,8,0,0,1-8.53-8V152a8,8,0,0,1,8-8h32A8,8,0,0,1,224,152.53ZM64,144H48a8,8,0,0,0-8,8v55.73A8.17,8.17,0,0,0,47.47,216,8,8,0,0,0,56,208v-8h7.4c15.24,0,28.14-11.92,28.59-27.15A28,28,0,0,0,64,144Zm-.35,40H56V160h8a12,12,0,0,1,12,13.16A12.25,12.25,0,0,1,63.65,184ZM128,144H112a8,8,0,0,0-8,8v56a8,8,0,0,0,8,8h15.32c19.66,0,36.21-15.48,36.67-35.13A36,36,0,0,0,128,144Zm-.49,56H120V160h8a20,20,0,0,1,20,20.77C147.58,191.59,138.34,200,127.51,200Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="notebook" viewBox="0 0 256 256">
|
|
||||||
<path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM80,208H48V48H80Zm96-56H112a8,8,0,0,1,0-16h64a8,8,0,0,1,0,16Zm0-32H112a8,8,0,0,1,0-16h64a8,8,0,0,1,0,16Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="link" viewBox="0 0 256 256">
|
|
||||||
<path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM115.7,192.49a43.31,43.31,0,0,1-55-66.43l25.37-25.37a43.35,43.35,0,0,1,61.25,0,42.9,42.9,0,0,1,9.95,15.43,8,8,0,1,1-15,5.6A27.33,27.33,0,0,0,97.37,112L72,137.37a27.32,27.32,0,0,0,34.68,41.91,8,8,0,1,1,9,13.21Zm79.61-62.55-25.37,25.37A43,43,0,0,1,139.32,168h0a43.35,43.35,0,0,1-40.53-28.12,8,8,0,1,1,15-5.6A27.35,27.35,0,0,0,139.28,152h0a27.14,27.14,0,0,0,19.32-8L184,118.63a27.32,27.32,0,0,0-34.68-41.91,8,8,0,1,1-9-13.21,43.32,43.32,0,0,1,55,66.43Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="identification-card" viewBox="0 0 256 256">
|
|
||||||
<path d="M112,120a16,16,0,1,1-16-16A16,16,0,0,1,112,120ZM232,56V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM135.75,166a39.76,39.76,0,0,0-17.19-23.34,32,32,0,1,0-45.12,0A39.84,39.84,0,0,0,56.25,166a8,8,0,0,0,15.5,4c2.64-10.25,13.06-18,24.25-18s21.62,7.73,24.25,18a8,8,0,1,0,15.5-4ZM200,144a8,8,0,0,0-8-8H152a8,8,0,0,0,0,16h40A8,8,0,0,0,200,144Zm0-32a8,8,0,0,0-8-8H152a8,8,0,0,0,0,16h40A8,8,0,0,0,200,112Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="wave-sine" viewBox="0 0 256 256">
|
|
||||||
<path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-4.78,91.44c-16.68,35-31.06,50.56-46.65,50.56-19.68,0-31.39-24.56-43.79-50.56C112,113,101,90,91.43,90c-3.74,0-14.37,4-32.21,41.44a8,8,0,0,1-14.44-6.88C61.46,89.59,75.84,74,91.43,74c19.68,0,31.39,24.56,43.79,50.56C144,143,155,166,164.57,166c3.74,0,14.37-4,32.21-41.44a8,8,0,1,1,14.44,6.88Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="list-checks" viewBox="0 0 256 256">
|
|
||||||
<path d="M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM117.66,149.66l-32,32a8,8,0,0,1-11.32,0l-16-16a8,8,0,0,1,11.32-11.32L80,164.69l26.34-26.35a8,8,0,0,1,11.32,11.32Zm0-64-32,32a8,8,0,0,1-11.32,0l-16-16A8,8,0,0,1,69.66,90.34L80,100.69l26.34-26.35a8,8,0,0,1,11.32,11.32ZM192,168H144a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-64H144a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="share-network" viewBox="0 0 256 256">
|
|
||||||
<path d="M212,200a36,36,0,1,1-69.85-12.25l-53-34.05a36,36,0,1,1,0-51.4l53-34a36.09,36.09,0,1,1,8.67,13.45l-53,34.05a36,36,0,0,1,0,24.5l53,34.05A36,36,0,0,1,212,200Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="pause" viewBox="0 0 256 256">
|
|
||||||
<path d="M216,48V208a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V48a16,16,0,0,1,16-16h40A16,16,0,0,1,216,48ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="play" viewBox="0 0 256 256">
|
|
||||||
<path d="M240,128a15.74,15.74,0,0,1-7.6,13.51L88.32,229.65a16,16,0,0,1-16.2.3A15.86,15.86,0,0,1,64,216.13V39.87a15.86,15.86,0,0,1,8.12-13.82,16,16,0,0,1,16.2.3L232.4,114.49A15.74,15.74,0,0,1,240,128Z"/>
|
|
||||||
</symbol>
|
|
||||||
|
|
||||||
<symbol id="currency-eur" viewBox="0 0 256 256">
|
|
||||||
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,80a8,8,0,0,1,0,16H88v16h24a8,8,0,0,1,0,16H88.81a40,40,0,0,0,65.86,21.82,8,8,0,1,1,10.66,11.92A56,56,0,0,1,72.58,152H64a8,8,0,0,1,0-16h8V120H64a8,8,0,0,1,0-16h8.58a56,56,0,0,1,92.75-33.74,8,8,0,1,1-10.66,11.92A40,40,0,0,0,88.81,104Z"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 61 KiB |
|
|
@ -158,9 +158,6 @@
|
||||||
<div class="sidebar-item" data-page="notes">
|
<div class="sidebar-item" data-page="notes">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-item" data-page="expenses">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#receipt"></use></svg> Ausgaben
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="sidebar-section-label">Entdecken</span>
|
<span class="sidebar-section-label">Entdecken</span>
|
||||||
<div class="sidebar-item" data-page="map">
|
<div class="sidebar-item" data-page="map">
|
||||||
|
|
@ -175,18 +172,12 @@
|
||||||
<div class="sidebar-item" data-page="jobs">
|
<div class="sidebar-item" data-page="jobs">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg> Jobs
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg> Jobs
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-item" data-page="adoption">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg> Adoption
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="sidebar-section-label">Soziales</span>
|
<span class="sidebar-section-label">Soziales</span>
|
||||||
<div class="sidebar-item" data-page="friends">
|
<div class="sidebar-item" data-page="friends">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg> Freunde
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg> Freunde
|
||||||
<span class="sidebar-item-badge" id="friends-badge" style="display:none">0</span>
|
<span class="sidebar-item-badge" id="friends-badge" style="display:none">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-item" data-page="playdate">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Playdate
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-item" data-page="chat">
|
<div class="sidebar-item" data-page="chat">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
|
||||||
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
|
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
|
||||||
|
|
@ -198,9 +189,6 @@
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder
|
||||||
<span class="sidebar-item-badge" id="poison-badge" style="display:none">0</span>
|
<span class="sidebar-item-badge" id="poison-badge" style="display:none">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-item" data-page="recalls">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Rückrufe
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-item" data-page="walks">
|
<div class="sidebar-item" data-page="walks">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -471,22 +459,6 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="page" id="page-expenses">
|
|
||||||
<div class="page-body page-container"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="page" id="page-recalls">
|
|
||||||
<div class="page-body page-container"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="page" id="page-adoption">
|
|
||||||
<div class="page-body page-container"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="page" id="page-playdate">
|
|
||||||
<div class="page-body page-container"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||||
|
|
|
||||||
|
|
@ -195,17 +195,6 @@ const API = (() => {
|
||||||
create(data) { return post('/tieraerzte', data); },
|
create(data) { return post('/tieraerzte', data); },
|
||||||
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
|
update(id, d) { return patch(`/tieraerzte/${id}`, d); },
|
||||||
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
osmNearby(lat, lon) { return get(`/tieraerzte/osm-nearby?lat=${lat}&lon=${lon}`); },
|
||||||
myFavorite() { return get('/tieraerzte/my-favorite'); },
|
|
||||||
toggleFavorite(id) { return post(`/tieraerzte/${id}/favorite`); },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// GESUNDHEITSDOKUMENTE
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
const healthDocs = {
|
|
||||||
list(dogId) { return get(`/health-docs?dog_id=${dogId}`); },
|
|
||||||
upload(formData) { return upload('/health-docs/upload', formData); },
|
|
||||||
delete(id) { return del(`/health-docs/${id}`); },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -723,7 +712,7 @@ const API = (() => {
|
||||||
// Öffentliche API
|
// Öffentliche API
|
||||||
return {
|
return {
|
||||||
get, post, put, patch, del, upload,
|
get, post, put, patch, del, upload,
|
||||||
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
|
auth, dogs, diary, health, tieraerzte, poison,
|
||||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||||
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
|
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '607'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '597'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
|
|
@ -71,10 +71,6 @@ const App = (() => {
|
||||||
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
'zucht-profil': { title: 'Hunde-Profil', module: null },
|
||||||
gruender: { title: '100 Gründer', module: null },
|
gruender: { title: '100 Gründer', module: null },
|
||||||
jobs: { title: 'Wir suchen dich', module: null },
|
jobs: { title: 'Wir suchen dich', module: null },
|
||||||
expenses: { title: 'Ausgaben', module: null, requiresAuth: true },
|
|
||||||
recalls: { title: 'Rückrufe', module: null },
|
|
||||||
adoption: { title: 'Adoption', module: null },
|
|
||||||
playdate: { title: 'Playdate', module: null, requiresAuth: true },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -90,7 +86,6 @@ const App = (() => {
|
||||||
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
|
sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null },
|
||||||
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
|
uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' },
|
||||||
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
|
notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null },
|
||||||
playdate: { icon: 'paw-print', text: 'Finde Spielkameraden für deinen Hund in der Nähe und verabrede ein Treffen.', preview: null },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,483 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
BAN YARO — Adoption (Tierheim-Hunde in der Nähe)
|
|
||||||
Seiten-Modul: Hunde aus deutschen Tierheimen finden.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
window.Page_adoption = (() => {
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// MODUL-STATE
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
let _container = null;
|
|
||||||
let _appState = null;
|
|
||||||
let _lat = null;
|
|
||||||
let _lon = null;
|
|
||||||
let _radius = 50;
|
|
||||||
let _rasseFilter = '';
|
|
||||||
let _activeTab = 'hunde';
|
|
||||||
let _data = null; // { animals, shelters, has_petfinder }
|
|
||||||
let _loading = false;
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// INIT
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function init(container, appState) {
|
|
||||||
_container = container;
|
|
||||||
_appState = appState;
|
|
||||||
_render();
|
|
||||||
// Standort automatisch versuchen
|
|
||||||
_tryAutoLocate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// REFRESH
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function refresh() {
|
|
||||||
if (_lat && _lon) {
|
|
||||||
await _loadData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// RENDER — Grundstruktur
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _render() {
|
|
||||||
_container.innerHTML = `
|
|
||||||
<!-- Filter-Leiste -->
|
|
||||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3);align-items:center">
|
|
||||||
<select id="adp-radius" class="form-control" style="width:auto;min-width:110px">
|
|
||||||
<option value="10">10 km</option>
|
|
||||||
<option value="25">25 km</option>
|
|
||||||
<option value="50" selected>50 km</option>
|
|
||||||
<option value="100">100 km</option>
|
|
||||||
</select>
|
|
||||||
<input id="adp-rasse" class="form-control" type="text"
|
|
||||||
placeholder="Rasse filtern…"
|
|
||||||
style="flex:1;min-width:120px;max-width:220px"
|
|
||||||
value="${_esc(_rasseFilter)}">
|
|
||||||
<button class="btn btn-secondary" id="adp-btn-locate"
|
|
||||||
style="white-space:nowrap">
|
|
||||||
${UI.icon('map-pin')} Mein Standort
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PLZ-Fallback (anfangs versteckt) -->
|
|
||||||
<div id="adp-plz-row" style="display:none;margin-bottom:var(--space-3)">
|
|
||||||
<div style="display:flex;gap:var(--space-2);align-items:center">
|
|
||||||
<input id="adp-plz" class="form-control" type="text"
|
|
||||||
inputmode="numeric" maxlength="5"
|
|
||||||
placeholder="PLZ eingeben (z.B. 80331)"
|
|
||||||
style="max-width:180px">
|
|
||||||
<button class="btn btn-primary" id="adp-btn-geocode">Suchen</button>
|
|
||||||
</div>
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-1)">
|
|
||||||
Kein Standort verfügbar — PLZ als Ausgangspunkt eingeben.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div style="display:flex;gap:var(--space-1);margin-bottom:var(--space-4);
|
|
||||||
border-bottom:1px solid var(--c-border)">
|
|
||||||
<button class="adp-tab adp-tab--active" data-tab="hunde"
|
|
||||||
style="padding:var(--space-2) var(--space-3);background:none;border:none;
|
|
||||||
cursor:pointer;font-weight:600;color:var(--c-primary);
|
|
||||||
border-bottom:2px solid var(--c-primary);font-size:var(--text-sm)">
|
|
||||||
${UI.icon('paw-print')} Hunde
|
|
||||||
</button>
|
|
||||||
<button class="adp-tab" data-tab="tierheime"
|
|
||||||
style="padding:var(--space-2) var(--space-3);background:none;border:none;
|
|
||||||
cursor:pointer;color:var(--c-text-secondary);
|
|
||||||
border-bottom:2px solid transparent;font-size:var(--text-sm)">
|
|
||||||
${UI.icon('house-line')} Tierheime
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inhalt -->
|
|
||||||
<div id="adp-content">
|
|
||||||
${UI.skeleton(4)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Events
|
|
||||||
_container.querySelector('#adp-radius')
|
|
||||||
?.addEventListener('change', e => {
|
|
||||||
_radius = parseInt(e.target.value);
|
|
||||||
if (_lat && _lon) _loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
_container.querySelector('#adp-rasse')
|
|
||||||
?.addEventListener('input', e => {
|
|
||||||
_rasseFilter = e.target.value.trim().toLowerCase();
|
|
||||||
_renderContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
_container.querySelector('#adp-btn-locate')
|
|
||||||
?.addEventListener('click', _locateUser);
|
|
||||||
|
|
||||||
_container.querySelector('#adp-btn-geocode')
|
|
||||||
?.addEventListener('click', _geocodePLZ);
|
|
||||||
|
|
||||||
_container.querySelector('#adp-plz')
|
|
||||||
?.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Enter') _geocodePLZ();
|
|
||||||
});
|
|
||||||
|
|
||||||
_container.querySelectorAll('.adp-tab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
_activeTab = btn.dataset.tab;
|
|
||||||
_container.querySelectorAll('.adp-tab').forEach(b => {
|
|
||||||
const isActive = b.dataset.tab === _activeTab;
|
|
||||||
b.style.color = isActive ? 'var(--c-primary)' : 'var(--c-text-secondary)';
|
|
||||||
b.style.fontWeight = isActive ? '600' : 'normal';
|
|
||||||
b.style.borderBottom = isActive ? '2px solid var(--c-primary)' : '2px solid transparent';
|
|
||||||
});
|
|
||||||
_renderContent();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// STANDORT AUTOMATISCH ERMITTELN
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _tryAutoLocate() {
|
|
||||||
try {
|
|
||||||
const pos = await API.getLocation({ timeout: 6000, maximumAge: 300_000 });
|
|
||||||
_lat = pos.lat;
|
|
||||||
_lon = pos.lon;
|
|
||||||
await _loadData();
|
|
||||||
} catch {
|
|
||||||
// Standort nicht verfügbar → PLZ-Eingabe zeigen
|
|
||||||
document.getElementById('adp-plz-row')?.style.setProperty('display', 'flex', 'important');
|
|
||||||
document.getElementById('adp-plz-row').style.display = 'flex';
|
|
||||||
_showNoLocation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _locateUser() {
|
|
||||||
const btn = _container.querySelector('#adp-btn-locate');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const pos = await API.getLocation({ timeout: 10000 });
|
|
||||||
_lat = pos.lat;
|
|
||||||
_lon = pos.lon;
|
|
||||||
document.getElementById('adp-plz-row').style.display = 'none';
|
|
||||||
await _loadData();
|
|
||||||
} catch {
|
|
||||||
UI.toast.error('Standort konnte nicht ermittelt werden. Bitte PLZ eingeben.');
|
|
||||||
document.getElementById('adp-plz-row').style.display = 'flex';
|
|
||||||
} finally {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _geocodePLZ() {
|
|
||||||
const plz = (_container.querySelector('#adp-plz')?.value || '').trim();
|
|
||||||
if (!plz) return;
|
|
||||||
const btn = _container.querySelector('#adp-btn-geocode');
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const geo = await API.get(`/adoption/geocode?plz=${encodeURIComponent(plz)}`);
|
|
||||||
if (geo.lat && geo.lon) {
|
|
||||||
_lat = geo.lat;
|
|
||||||
_lon = geo.lon;
|
|
||||||
await _loadData();
|
|
||||||
} else {
|
|
||||||
UI.toast.error(`PLZ "${plz}" nicht gefunden.`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
UI.toast.error('Geocoding fehlgeschlagen. Bitte erneut versuchen.');
|
|
||||||
} finally {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// DATEN LADEN
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _loadData() {
|
|
||||||
if (_loading || !_lat || !_lon) return;
|
|
||||||
_loading = true;
|
|
||||||
const content = _container.querySelector('#adp-content');
|
|
||||||
if (content) content.innerHTML = UI.skeleton(4);
|
|
||||||
try {
|
|
||||||
_data = await API.get(`/adoption/nearby?lat=${_lat}&lon=${_lon}&radius=${_radius}`);
|
|
||||||
_renderContent();
|
|
||||||
} catch {
|
|
||||||
if (content) content.innerHTML = UI.emptyState({
|
|
||||||
icon: 'warning',
|
|
||||||
title: 'Daten konnten nicht geladen werden',
|
|
||||||
text: 'Bitte versuche es erneut.',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
_loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// INHALT RENDERN (je nach Tab)
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _renderContent() {
|
|
||||||
const content = _container.querySelector('#adp-content');
|
|
||||||
if (!content) return;
|
|
||||||
if (!_data) { _showNoLocation(); return; }
|
|
||||||
|
|
||||||
if (_activeTab === 'hunde') _renderHunde(content);
|
|
||||||
else _renderTierheime(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showNoLocation() {
|
|
||||||
const content = _container.querySelector('#adp-content');
|
|
||||||
if (!content) return;
|
|
||||||
content.innerHTML = `
|
|
||||||
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
|
|
||||||
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
|
|
||||||
<h3 style="margin-bottom:var(--space-2)">Finde Hunde in deiner Nähe</h3>
|
|
||||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
|
|
||||||
Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
|
|
||||||
in deiner Umgebung zu finden.
|
|
||||||
</p>
|
|
||||||
<a href="https://www.tierheimhelden.de/hunde/liste"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="btn btn-primary">
|
|
||||||
${UI.icon('arrow-square-out')} Alle Hunde auf Tierheimhelden.de
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// TAB: HUNDE
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
function _renderHunde(content) {
|
|
||||||
let animals = (_data?.animals || []);
|
|
||||||
|
|
||||||
// Rasse-Filter
|
|
||||||
if (_rasseFilter) {
|
|
||||||
animals = animals.filter(a =>
|
|
||||||
(a.rasse || '').toLowerCase().includes(_rasseFilter) ||
|
|
||||||
(a.name || '').toLowerCase().includes(_rasseFilter)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPetFinder = _data?.has_petfinder;
|
|
||||||
const infoText = hasPetFinder
|
|
||||||
? `${animals.length} Hunde im Umkreis von ${_radius} km (via PetFinder)`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
if (!animals.length) {
|
|
||||||
content.innerHTML = `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
|
||||||
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${_esc(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
|
|
||||||
</p>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
|
|
||||||
<a href="https://www.tierheimhelden.de/hunde/liste"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="btn btn-primary">
|
|
||||||
${UI.icon('arrow-square-out')} Hunde auf Tierheimhelden.de suchen
|
|
||||||
</a>
|
|
||||||
<a href="https://www.tierschutz.com/tierheimsuche/"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="btn btn-secondary">
|
|
||||||
${UI.icon('magnifying-glass')} Tierheimsuche auf tierschutz.com
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:var(--space-4)">
|
|
||||||
Tipp: Schau auch im Tab „Tierheime" nach lokalen Tierheimen direkt.
|
|
||||||
</p>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
content.innerHTML = `
|
|
||||||
${infoText ? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">${infoText}</p>` : ''}
|
|
||||||
<div class="adp-grid"
|
|
||||||
style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:var(--space-3)">
|
|
||||||
${animals.map(a => _animalCard(a)).join('')}
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
|
||||||
Mehr Hunde finden:
|
|
||||||
</p>
|
|
||||||
<a href="https://www.tierheimhelden.de/hunde/liste"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="btn btn-secondary" style="font-size:var(--text-sm)">
|
|
||||||
${UI.icon('arrow-square-out')} Tierheimhelden.de — alle Hunde
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Klick-Events
|
|
||||||
content.querySelectorAll('[data-adp-url]').forEach(card => {
|
|
||||||
card.addEventListener('click', () => {
|
|
||||||
window.open(card.dataset.adpUrl, '_blank', 'noopener,noreferrer');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _animalCard(a) {
|
|
||||||
const foto = a.foto_url
|
|
||||||
? `<img src="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
|
|
||||||
style="width:100%;height:100%;object-fit:cover"
|
|
||||||
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem">🐶</div>'">`
|
|
||||||
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
|
|
||||||
|
|
||||||
const distTxt = a.distanz_km != null ? `${a.distanz_km} km` : '';
|
|
||||||
const alterTxt = a.alter_jahre != null ? `${_formatAlter(a.alter_jahre)}` : '';
|
|
||||||
const rasseTxt = a.rasse || '';
|
|
||||||
const tierheim = a.tierheim || '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div data-adp-url="${_esc(a.adoptions_url)}"
|
|
||||||
style="border-radius:var(--radius-md);overflow:hidden;
|
|
||||||
background:var(--c-surface-2);cursor:pointer;
|
|
||||||
box-shadow:0 1px 4px rgba(0,0,0,0.08);
|
|
||||||
transition:transform .15s,box-shadow .15s"
|
|
||||||
onmouseenter="this.style.transform='translateY(-2px)';this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'"
|
|
||||||
onmouseleave="this.style.transform='';this.style.boxShadow='0 1px 4px rgba(0,0,0,0.08)'">
|
|
||||||
<div style="height:120px;overflow:hidden;background:var(--c-surface-3)">
|
|
||||||
${foto}
|
|
||||||
</div>
|
|
||||||
<div style="padding:var(--space-2) var(--space-2) var(--space-3)">
|
|
||||||
<div style="font-weight:600;font-size:var(--text-sm);
|
|
||||||
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
|
||||||
${_esc(a.name)}
|
|
||||||
</div>
|
|
||||||
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
|
||||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
|
||||||
${_esc(rasseTxt)}
|
|
||||||
</div>` : ''}
|
|
||||||
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)">
|
|
||||||
${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
|
|
||||||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
|
||||||
${_esc(alterTxt)}
|
|
||||||
</span>` : ''}
|
|
||||||
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
|
|
||||||
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
|
|
||||||
${a.geschlecht === 'männlich' ? '♂' : '♀'}
|
|
||||||
</span>` : ''}
|
|
||||||
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
|
|
||||||
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
|
|
||||||
${_esc(distTxt)}
|
|
||||||
</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1);
|
|
||||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${_esc(tierheim)}">
|
|
||||||
${UI.icon('house-line')} ${_esc(tierheim)}
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// TAB: TIERHEIME
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
function _renderTierheime(content) {
|
|
||||||
const shelters = _data?.shelters || [];
|
|
||||||
|
|
||||||
if (!shelters.length) {
|
|
||||||
content.innerHTML = `
|
|
||||||
<div style="text-align:center;padding:var(--space-6) var(--space-4)">
|
|
||||||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
|
||||||
Keine Tierheime im Umkreis von ${_radius} km gefunden.
|
|
||||||
</p>
|
|
||||||
<a href="https://www.tierheimhelden.de"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="btn btn-primary">
|
|
||||||
${UI.icon('arrow-square-out')} Tierheimhelden.de
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
content.innerHTML = `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
|
||||||
${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
|
|
||||||
</p>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
|
||||||
${shelters.map(s => _shelterRow(s)).join('')}
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
|
||||||
Noch mehr Tierheime:
|
|
||||||
</p>
|
|
||||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
|
||||||
<a href="https://www.tierheimhelden.de"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
|
|
||||||
${UI.icon('arrow-square-out')} Tierheimhelden.de
|
|
||||||
</a>
|
|
||||||
<a href="https://www.tierschutz.com/tierheimsuche/"
|
|
||||||
target="_blank" rel="noopener noreferrer"
|
|
||||||
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
|
|
||||||
${UI.icon('magnifying-glass')} tierschutz.com
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _shelterRow(s) {
|
|
||||||
return `
|
|
||||||
<a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
|
|
||||||
style="display:flex;align-items:center;gap:var(--space-3);
|
|
||||||
padding:var(--space-3);border-radius:var(--radius-md);
|
|
||||||
background:var(--c-surface-2);text-decoration:none;color:inherit;
|
|
||||||
border:1px solid var(--c-border);
|
|
||||||
transition:background .15s"
|
|
||||||
onmouseenter="this.style.background='var(--c-surface-3)'"
|
|
||||||
onmouseleave="this.style.background='var(--c-surface-2)'">
|
|
||||||
<div style="width:40px;height:40px;border-radius:50%;
|
|
||||||
background:var(--c-primary-light,#ede9fe);flex-shrink:0;
|
|
||||||
display:flex;align-items:center;justify-content:center;
|
|
||||||
font-size:1.2rem">
|
|
||||||
🏠
|
|
||||||
</div>
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:600;font-size:var(--text-sm);
|
|
||||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
|
||||||
${_esc(s.name)}
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
|
||||||
${_esc(s.plz)} ${_esc(s.stadt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0">
|
|
||||||
<span style="font-size:var(--text-xs);font-weight:600;
|
|
||||||
color:var(--c-primary);background:var(--c-primary-light,#ede9fe);
|
|
||||||
border-radius:999px;padding:2px 8px">
|
|
||||||
${s.distanz_km} km
|
|
||||||
</span>
|
|
||||||
<span style="font-size:10px;color:var(--c-text-muted)">Hunde ansehen ${UI.icon('arrow-right')}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// HILFSFUNKTIONEN
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _formatAlter(jahre) {
|
|
||||||
if (jahre < 0.5) return 'Welpe';
|
|
||||||
if (jahre < 1) return 'Jungtier';
|
|
||||||
if (jahre < 2) return `${Math.round(jahre)} Jahr`;
|
|
||||||
if (jahre < 10) return `${Math.round(jahre)} Jahre`;
|
|
||||||
return 'Senior';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _esc(s) {
|
|
||||||
if (s == null) return '';
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// PUBLIC API
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
return { init, refresh };
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -97,8 +97,19 @@ window.Page_dog_profile = (() => {
|
||||||
<h2 style="font-size:var(--text-2xl);font-weight:700;
|
<h2 style="font-size:var(--text-2xl);font-weight:700;
|
||||||
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
|
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
|
||||||
${dog.rasse
|
${dog.rasse
|
||||||
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
|
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
|
||||||
: `<p style="margin:0 0 var(--space-5)"></p>`}
|
: `<p style="margin:0 0 var(--space-2)"></p>`}
|
||||||
|
|
||||||
|
${(dog.hdm_wins?.length) ? `
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);justify-content:center;margin-bottom:var(--space-5)">
|
||||||
|
${dog.hdm_wins.map(m => {
|
||||||
|
const [y, mo] = m.split('-');
|
||||||
|
const label = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||||
|
.format(new Date(+y, +mo - 1, 1));
|
||||||
|
return `<span class="dp-hdm-badge" title="Hund des Monats ${label}">🏆 ${label}</span>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
` : `<div style="margin-bottom:var(--space-5)"></div>`}
|
||||||
|
|
||||||
<!-- Info-Grid -->
|
<!-- Info-Grid -->
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
|
||||||
|
|
@ -187,14 +198,16 @@ window.Page_dog_profile = (() => {
|
||||||
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
|
${!dog.is_guest ? `<button class="btn btn-primary w-full" id="dp-edit-btn">
|
||||||
Profil bearbeiten
|
Profil bearbeiten
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-share-btn">
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
|
<button class="btn btn-secondary" style="flex:1" id="dp-ausweis-btn">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
|
||||||
|
Ausweis
|
||||||
|
</button>
|
||||||
|
${!dog.is_guest ? `<button class="btn btn-secondary" style="flex:1" id="dp-share-btn">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#share-network"></use></svg>
|
||||||
Teilen
|
Teilen
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-passport-btn">
|
</div>
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#notebook"></use></svg>
|
|
||||||
Hundepass
|
|
||||||
</button>` : ''}
|
|
||||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
|
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-add-dog-btn">
|
||||||
+ Weiteren Hund anlegen
|
+ Weiteren Hund anlegen
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
|
|
@ -207,8 +220,7 @@ window.Page_dog_profile = (() => {
|
||||||
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
|
||||||
<div style="font-weight:600">Sitter-Zugang</div>
|
<div style="font-weight:600">Sitter-Zugang</div>
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||||
Gib einem Freund temporären Schreibzugang für diesen Hund.
|
Gib einem Freund temporären Schreibzugang für diesen Hund
|
||||||
Deine bestehenden Daten und Medien bleiben unsichtbar und privat — der Sitter kann nur neue Einträge anlegen.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade…</div>
|
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade…</div>
|
||||||
|
|
@ -256,12 +268,12 @@ window.Page_dog_profile = (() => {
|
||||||
_showChipEdit(dog);
|
_showChipEdit(dog);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
|
document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => {
|
||||||
_showShareModal(dog);
|
_showAusweisModal(dog.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('dp-passport-btn')?.addEventListener('click', () => {
|
document.getElementById('dp-share-btn')?.addEventListener('click', () => {
|
||||||
_showPassportModal(dog);
|
_showShareModal(dog);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||||
|
|
@ -744,7 +756,13 @@ window.Page_dog_profile = (() => {
|
||||||
// AUSWEIS
|
// AUSWEIS
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _showAusweisModal(dogId) {
|
function _showAusweisModal(dogId) {
|
||||||
window.open(`/ausweis/${dogId}`, '_blank', 'noopener');
|
UI.modal.open({
|
||||||
|
title: 'Heimtierausweis',
|
||||||
|
body: `<iframe src="/ausweis/${dogId}" class="ausweis-frame" title="Heimtierausweis"></iframe>`,
|
||||||
|
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||||
|
<a href="/ausweis/${dogId}" target="_blank" class="btn btn-ghost">${UI.icon('printer')} Drucken</a>`,
|
||||||
|
size: 'fullscreen',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -989,7 +1007,7 @@ window.Page_dog_profile = (() => {
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Foto</label>
|
<label class="form-label">Foto</label>
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
|
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||||
<img id="dp-form-preview"
|
<img id="dp-form-preview"
|
||||||
src="${dog?.foto_url || ''}"
|
src="${dog?.foto_url || ''}"
|
||||||
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
|
style="width:64px;height:64px;border-radius:50%;object-fit:cover;
|
||||||
|
|
@ -1000,16 +1018,6 @@ window.Page_dog_profile = (() => {
|
||||||
<input type="file" name="foto" accept="image/*" style="display:none"
|
<input type="file" name="foto" accept="image/*" style="display:none"
|
||||||
id="dp-form-foto">
|
id="dp-form-foto">
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
|
|
||||||
style="margin:0">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
|
||||||
Rasse erkennen
|
|
||||||
</button>
|
|
||||||
<input type="file" accept="image/jpeg,image/png,image/webp"
|
|
||||||
id="dp-rasse-foto-input" style="display:none">
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
|
||||||
Foto hochladen um die Rasse per KI zu erkennen
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1089,9 +1097,6 @@ window.Page_dog_profile = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rassen-Erkennung per KI
|
|
||||||
_bindRasseErkennung();
|
|
||||||
|
|
||||||
document.getElementById('dp-form-cancel')
|
document.getElementById('dp-form-cancel')
|
||||||
?.addEventListener('click', UI.modal.close);
|
?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
|
@ -1177,152 +1182,6 @@ window.Page_dog_profile = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// RASSEN-ERKENNUNG PER KI (Formular)
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _bindRasseErkennung() {
|
|
||||||
const btn = document.getElementById('dp-rasse-erkennen-btn');
|
|
||||||
const fileInput = document.getElementById('dp-rasse-foto-input');
|
|
||||||
if (!btn || !fileInput) return;
|
|
||||||
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
fileInput.value = '';
|
|
||||||
fileInput.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', async () => {
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
UI.toast.error('Bild zu groß (max. 5 MB).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const origLabel = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert…`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
const token = localStorage.getItem('by_token');
|
|
||||||
const resp = await fetch('/api/ki/rasse-erkennung', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
|
||||||
body: fd,
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
|
|
||||||
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = origLabel;
|
|
||||||
_showRasseErgebnis(data);
|
|
||||||
} catch (e) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = origLabel;
|
|
||||||
UI.toast.error(e.message || 'Fehler bei der Rassen-Erkennung.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showRasseErgebnis(data) {
|
|
||||||
if (!data.ist_hund) {
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Kein Hund erkannt',
|
|
||||||
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
|
|
||||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
|
|
||||||
<p style="color:var(--c-text-secondary)">
|
|
||||||
Auf diesem Foto konnte kein Hund erkannt werden.<br>
|
|
||||||
Bitte lade ein deutlicheres Foto hoch.
|
|
||||||
</p>
|
|
||||||
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
|
|
||||||
</div>`,
|
|
||||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rassen = data.rassen || [];
|
|
||||||
const cardsHtml = rassen.map((r, i) => {
|
|
||||||
const isTop = i === 0;
|
|
||||||
return `
|
|
||||||
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
|
||||||
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
|
|
||||||
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="rasse-result-bar-wrap">
|
|
||||||
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
|
|
||||||
style="width:${r.sicherheit}%"></div>
|
|
||||||
</div>
|
|
||||||
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
|
|
||||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap">
|
|
||||||
${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen"
|
|
||||||
data-rasse="${_esc(r.name)}" style="flex:1">
|
|
||||||
Rasse übernehmen
|
|
||||||
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
|
|
||||||
data-rasse="${_esc(r.name)}" style="flex:1">
|
|
||||||
Diese wählen
|
|
||||||
</button>`}
|
|
||||||
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
|
|
||||||
data-slug="${_esc(r.wiki_slug)}">
|
|
||||||
Im Wiki
|
|
||||||
</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Erkannte Rasse',
|
|
||||||
body: `
|
|
||||||
<div style="padding-bottom:var(--space-2)">
|
|
||||||
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
|
||||||
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
|
|
||||||
color:var(--c-text-secondary)">ℹ️ ${_esc(data.hinweis)}</div>` : ''}
|
|
||||||
${cardsHtml}
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
|
|
||||||
text-align:center">
|
|
||||||
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
footer: `<button class="btn btn-secondary" id="dp-rasse-modal-schliessen">Schließen</button>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('dp-rasse-modal-schliessen')
|
|
||||||
?.addEventListener('click', UI.modal.close);
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-action="uebernehmen"]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const rasse = btn.dataset.rasse;
|
|
||||||
const rasseInput = document.getElementById('dp-rasse-input');
|
|
||||||
const rasseIdInput = document.getElementById('dp-rasse-id');
|
|
||||||
const matchBadge = document.getElementById('dp-rasse-match');
|
|
||||||
if (rasseInput) {
|
|
||||||
rasseInput.value = rasse;
|
|
||||||
rasseInput.dispatchEvent(new Event('input'));
|
|
||||||
}
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success(`Rasse "${rasse}" übernommen.`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
UI.modal.close();
|
|
||||||
App.navigate('wiki');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (window.Page_wiki && typeof Page_wiki._openBreedDetail === 'function') {
|
|
||||||
Page_wiki._openBreedDetail(btn.dataset.slug);
|
|
||||||
}
|
|
||||||
}, 400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// HELPER
|
// HELPER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -1348,444 +1207,6 @@ window.Page_dog_profile = (() => {
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// HUNDEPASS
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _showPassportModal(dog) {
|
|
||||||
UI.modal.open({
|
|
||||||
title: `Hundepass — ${_esc(dog.name)}`,
|
|
||||||
body: `<div id="pp-body" style="min-height:200px">
|
|
||||||
<div style="text-align:center;padding:var(--space-6)">
|
|
||||||
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
|
|
||||||
<use href="/icons/phosphor.svg#spinner-gap"></use>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>`,
|
|
||||||
footer: `
|
|
||||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end">
|
|
||||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
|
||||||
<a class="btn btn-secondary" href="/ausweis/${dog.id}" target="_blank" rel="noopener">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
|
|
||||||
Ausweis öffnen
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-secondary" id="pp-share-btn">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
|
|
||||||
Link teilen
|
|
||||||
</button>
|
|
||||||
<a class="btn btn-primary" id="pp-pdf-btn"
|
|
||||||
href="/api/passport/${dog.id}/pdf" target="_blank" download>
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-pdf"></use></svg>
|
|
||||||
PDF
|
|
||||||
</a>
|
|
||||||
</div>`,
|
|
||||||
size: 'large',
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pp-share-btn')?.addEventListener('click', () => {
|
|
||||||
_createPassportShare(dog);
|
|
||||||
});
|
|
||||||
|
|
||||||
await _loadPassportBody(dog);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadPassportBody(dog) {
|
|
||||||
const wrap = document.getElementById('pp-body');
|
|
||||||
if (!wrap) return;
|
|
||||||
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await API.get(`/passport/${dog.id}`);
|
|
||||||
} catch (e) {
|
|
||||||
wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _fmt = d => {
|
|
||||||
if (!d) return '–';
|
|
||||||
try {
|
|
||||||
const p = d.substring(0, 10).split('-');
|
|
||||||
return `${p[2]}.${p[1]}.${p[0]}`;
|
|
||||||
} catch { return d; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const meta = data.meta || {};
|
|
||||||
const vaccs = data.vaccinations || [];
|
|
||||||
const meds = data.medications || [];
|
|
||||||
|
|
||||||
wrap.innerHTML = `
|
|
||||||
<!-- Meta: Blutgruppe, Allergien, Besonderheiten -->
|
|
||||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
|
||||||
margin-bottom:var(--space-3)">
|
|
||||||
<span style="font-weight:700;font-size:var(--text-sm)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#info"></use></svg>
|
|
||||||
Gesundheits-Info
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-secondary btn-sm" id="pp-meta-edit-btn">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
|
|
||||||
Bearbeiten
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
|
||||||
<div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
|
|
||||||
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
|
|
||||||
${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
|
|
||||||
<div id="pp-meta-allergien" style="font-size:var(--text-sm)">
|
|
||||||
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${meta.besonderheiten ? `
|
|
||||||
<div style="margin-top:var(--space-3)">
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
|
|
||||||
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
|
|
||||||
${_esc(meta.besonderheiten)}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Impfungen -->
|
|
||||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-4)">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
|
||||||
margin-bottom:var(--space-3)">
|
|
||||||
<span style="font-weight:700;font-size:var(--text-sm)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
|
|
||||||
Impfungen
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-primary btn-sm" id="pp-vacc-add-btn">+ Eintragen</button>
|
|
||||||
</div>
|
|
||||||
<div id="pp-vacc-list">
|
|
||||||
${vaccs.length === 0
|
|
||||||
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
|
|
||||||
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
|
|
||||||
<p style="font-size:var(--text-sm);margin:0">Noch keine Impfungen eingetragen.<br>Klicke auf „+ Eintragen" um loszulegen.</p>
|
|
||||||
</div>`
|
|
||||||
: vaccs.map(v => `
|
|
||||||
<div class="pp-vacc-row" data-id="${v.id}"
|
|
||||||
style="display:flex;align-items:flex-start;gap:var(--space-3);
|
|
||||||
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
|
|
||||||
<div style="flex:1">
|
|
||||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
|
||||||
Gegeben: ${_fmt(v.datum)}
|
|
||||||
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
|
|
||||||
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
|
|
||||||
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
|
|
||||||
style="color:var(--c-danger);flex-shrink:0;padding:0">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>`).join('')
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Medikamente -->
|
|
||||||
<div class="card" style="padding:var(--space-4)">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
|
||||||
margin-bottom:var(--space-3)">
|
|
||||||
<span style="font-weight:700;font-size:var(--text-sm)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
|
|
||||||
Medikamente
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-primary btn-sm" id="pp-med-add-btn">+ Eintragen</button>
|
|
||||||
</div>
|
|
||||||
<div id="pp-med-list">
|
|
||||||
${meds.length === 0
|
|
||||||
? `<div style="text-align:center;padding:var(--space-4) var(--space-2);color:var(--c-text-muted)">
|
|
||||||
<svg class="ph-icon" style="width:32px;height:32px;margin-bottom:var(--space-2);opacity:.4" aria-hidden="true"><use href="/icons/phosphor.svg#pill"></use></svg>
|
|
||||||
<p style="font-size:var(--text-sm);margin:0">Noch keine Medikamente eingetragen.<br>Klicke auf „+ Eintragen" um loszulegen.</p>
|
|
||||||
</div>`
|
|
||||||
: meds.map(m => `
|
|
||||||
<div class="pp-med-row" data-id="${m.id}"
|
|
||||||
style="display:flex;align-items:flex-start;gap:var(--space-3);
|
|
||||||
padding:var(--space-3) 0;border-bottom:1px solid var(--c-border)">
|
|
||||||
<div style="flex:1">
|
|
||||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
|
||||||
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
|
|
||||||
${m.von ? `Von ${_fmt(m.von)}` : ''}
|
|
||||||
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
|
|
||||||
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
|
|
||||||
style="color:var(--c-danger);flex-shrink:0;padding:0">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>`).join('')
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Meta bearbeiten
|
|
||||||
document.getElementById('pp-meta-edit-btn')?.addEventListener('click', () => {
|
|
||||||
_editPassportMeta(dog, meta, () => _loadPassportBody(dog));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Impfung hinzufügen
|
|
||||||
document.getElementById('pp-vacc-add-btn')?.addEventListener('click', () => {
|
|
||||||
_addVaccination(dog, () => _loadPassportBody(dog));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Impfung löschen
|
|
||||||
wrap.querySelectorAll('.pp-vacc-del').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
if (!confirm('Impfung wirklich löschen?')) return;
|
|
||||||
try {
|
|
||||||
await API.del(`/passport/${dog.id}/vaccinations/${btn.dataset.id}`);
|
|
||||||
_loadPassportBody(dog);
|
|
||||||
} catch (e) {
|
|
||||||
UI.toast.error(e.message || 'Fehler');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Medikament hinzufügen
|
|
||||||
document.getElementById('pp-med-add-btn')?.addEventListener('click', () => {
|
|
||||||
_addMedication(dog, () => _loadPassportBody(dog));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Medikament löschen
|
|
||||||
wrap.querySelectorAll('.pp-med-del').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
if (!confirm('Medikament wirklich löschen?')) return;
|
|
||||||
try {
|
|
||||||
await API.del(`/passport/${dog.id}/medications/${btn.dataset.id}`);
|
|
||||||
_loadPassportBody(dog);
|
|
||||||
} catch (e) {
|
|
||||||
UI.toast.error(e.message || 'Fehler');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _editPassportMeta(dog, current, onSave) {
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Gesundheits-Info bearbeiten',
|
|
||||||
body: `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Blutgruppe</label>
|
|
||||||
<input id="pp-meta-bg" class="form-control" type="text"
|
|
||||||
value="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Allergien</label>
|
|
||||||
<textarea id="pp-meta-al" class="form-control" rows="2"
|
|
||||||
placeholder="z. B. Hühnchen, Flohspeichel">${_esc(current.allergien || '')}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Besonderheiten</label>
|
|
||||||
<textarea id="pp-meta-be" class="form-control" rows="2"
|
|
||||||
placeholder="z. B. Herzprobleme, Angstpatient">${_esc(current.besonderheiten || '')}</textarea>
|
|
||||||
</div>`,
|
|
||||||
footer: `
|
|
||||||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
|
||||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="pp-meta-save">Speichern</button>
|
|
||||||
</div>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pp-meta-save').addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('pp-meta-save');
|
|
||||||
UI.setLoading(btn, true);
|
|
||||||
try {
|
|
||||||
await API.put(`/passport/${dog.id}/meta`, {
|
|
||||||
blutgruppe: document.getElementById('pp-meta-bg').value.trim() || null,
|
|
||||||
allergien: document.getElementById('pp-meta-al').value.trim() || null,
|
|
||||||
besonderheiten: document.getElementById('pp-meta-be').value.trim() || null,
|
|
||||||
});
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Gesundheits-Info gespeichert.');
|
|
||||||
onSave();
|
|
||||||
} catch (e) {
|
|
||||||
UI.setLoading(btn, false);
|
|
||||||
UI.toast.error(e.message || 'Fehler');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _addVaccination(dog, onSave) {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Impfung eintragen',
|
|
||||||
body: `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Krankheit *</label>
|
|
||||||
<input id="pp-vacc-krankheit" class="form-control" type="text"
|
|
||||||
placeholder="z. B. Staupe, Parvovirose, Tollwut, DHPP" list="pp-vacc-list">
|
|
||||||
<datalist id="pp-vacc-list">
|
|
||||||
<option value="Staupe">
|
|
||||||
<option value="Parvovirose">
|
|
||||||
<option value="Hepatitis (HCC)">
|
|
||||||
<option value="Leptospirose">
|
|
||||||
<option value="Tollwut">
|
|
||||||
<option value="Kennel-Husten (Bordetella)">
|
|
||||||
<option value="Borreliose">
|
|
||||||
<option value="DHPP (Kombi)">
|
|
||||||
</datalist>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Datum *</label>
|
|
||||||
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Nächste fällig</label>
|
|
||||||
<input id="pp-vacc-naechste" class="form-control" type="date">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Tierarzt</label>
|
|
||||||
<input id="pp-vacc-tierarzt" class="form-control" type="text" placeholder="Name der Praxis">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Charge-Nr.</label>
|
|
||||||
<input id="pp-vacc-charge" class="form-control" type="text" placeholder="Chargennummer des Impfstoffs">
|
|
||||||
</div>`,
|
|
||||||
footer: `
|
|
||||||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
|
||||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="pp-vacc-save">Speichern</button>
|
|
||||||
</div>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pp-vacc-save').addEventListener('click', async () => {
|
|
||||||
const krankheit = document.getElementById('pp-vacc-krankheit').value.trim();
|
|
||||||
const datum = document.getElementById('pp-vacc-datum').value;
|
|
||||||
if (!krankheit || !datum) {
|
|
||||||
UI.toast.warning('Bitte Krankheit und Datum angeben.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById('pp-vacc-save');
|
|
||||||
UI.setLoading(btn, true);
|
|
||||||
try {
|
|
||||||
await API.post(`/passport/${dog.id}/vaccinations`, {
|
|
||||||
krankheit,
|
|
||||||
datum,
|
|
||||||
naechste: document.getElementById('pp-vacc-naechste').value || null,
|
|
||||||
tierarzt: document.getElementById('pp-vacc-tierarzt').value.trim() || null,
|
|
||||||
charge_nr: document.getElementById('pp-vacc-charge').value.trim() || null,
|
|
||||||
});
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Impfung eingetragen.');
|
|
||||||
onSave();
|
|
||||||
} catch (e) {
|
|
||||||
UI.setLoading(btn, false);
|
|
||||||
UI.toast.error(e.message || 'Fehler');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _addMedication(dog, onSave) {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Medikament eintragen',
|
|
||||||
body: `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Medikament *</label>
|
|
||||||
<input id="pp-med-name" class="form-control" type="text"
|
|
||||||
placeholder="z. B. Frontline, Milbemax, Onsior">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Dosierung</label>
|
|
||||||
<input id="pp-med-dosierung" class="form-control" type="text"
|
|
||||||
placeholder="z. B. 1× täglich, 5 mg">
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Von</label>
|
|
||||||
<input id="pp-med-von" class="form-control" type="date" value="${today}">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Bis <span style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
|
|
||||||
<input id="pp-med-bis" class="form-control" type="date">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Notiz</label>
|
|
||||||
<input id="pp-med-notiz" class="form-control" type="text"
|
|
||||||
placeholder="z. B. nach dem Fressen geben">
|
|
||||||
</div>`,
|
|
||||||
footer: `
|
|
||||||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
|
||||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="pp-med-save">Speichern</button>
|
|
||||||
</div>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('pp-med-save').addEventListener('click', async () => {
|
|
||||||
const name = document.getElementById('pp-med-name').value.trim();
|
|
||||||
if (!name) {
|
|
||||||
UI.toast.warning('Bitte einen Namen angeben.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const btn = document.getElementById('pp-med-save');
|
|
||||||
UI.setLoading(btn, true);
|
|
||||||
try {
|
|
||||||
await API.post(`/passport/${dog.id}/medications`, {
|
|
||||||
name,
|
|
||||||
dosierung: document.getElementById('pp-med-dosierung').value.trim() || null,
|
|
||||||
von: document.getElementById('pp-med-von').value || null,
|
|
||||||
bis: document.getElementById('pp-med-bis').value || null,
|
|
||||||
notiz: document.getElementById('pp-med-notiz').value.trim() || null,
|
|
||||||
});
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Medikament eingetragen.');
|
|
||||||
onSave();
|
|
||||||
} catch (e) {
|
|
||||||
UI.setLoading(btn, false);
|
|
||||||
UI.toast.error(e.message || 'Fehler');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _createPassportShare(dog) {
|
|
||||||
const btn = document.getElementById('pp-share-btn');
|
|
||||||
if (btn) UI.setLoading(btn, true);
|
|
||||||
try {
|
|
||||||
const res = await API.post(`/passport/${dog.id}/share`, {});
|
|
||||||
const url = `${location.origin}${res.url}`;
|
|
||||||
if (btn) UI.setLoading(btn, false);
|
|
||||||
// Zeige Share-Link im Modal (window.confirm wäre zu kurz)
|
|
||||||
const shareWrap = document.createElement('div');
|
|
||||||
shareWrap.innerHTML = `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
|
||||||
Dieser Link ist 30 Tage gültig. Tierärzte und Sitter können den Pass ohne Login öffnen.
|
|
||||||
</p>
|
|
||||||
<div style="display:flex;gap:var(--space-2);align-items:center">
|
|
||||||
<input id="pp-sharelink-input" class="form-control" type="text" readonly
|
|
||||||
value="${_esc(url)}" style="font-size:var(--text-xs)">
|
|
||||||
<button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2)">
|
|
||||||
Gültig bis: ${res.valid_until.split('-').reverse().join('.')}
|
|
||||||
</p>`;
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Hundepass-Link teilen',
|
|
||||||
body: shareWrap.innerHTML,
|
|
||||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
|
||||||
});
|
|
||||||
document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => {
|
|
||||||
await navigator.clipboard.writeText(url).catch(() => {});
|
|
||||||
UI.toast.success('Link kopiert!');
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (btn) UI.setLoading(btn, false);
|
|
||||||
UI.toast.error(e.message || 'Fehler beim Erstellen des Links.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// PUBLIC
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,828 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
BAN YARO — Ausgaben-Tracker
|
|
||||||
Tabs: Übersicht | Einträge | Statistik
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
window.Page_expenses = (() => {
|
|
||||||
|
|
||||||
let _container = null;
|
|
||||||
let _appState = null;
|
|
||||||
let _tab = 'uebersicht';
|
|
||||||
|
|
||||||
// Cache
|
|
||||||
let _summary = null;
|
|
||||||
let _entries = [];
|
|
||||||
let _statsData = null;
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
|
|
||||||
{ id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' },
|
|
||||||
{ id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' },
|
|
||||||
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const KATEGORIEN = [
|
|
||||||
{ id: 'tierarzt', label: 'Tierarzt', icon: 'syringe', color: '#ef4444' },
|
|
||||||
{ id: 'futter', label: 'Futter', icon: 'bowl-food', color: '#f59e0b' },
|
|
||||||
{ id: 'zubehoer', label: 'Zubehör', icon: 'shopping-bag', color: '#8b5cf6' },
|
|
||||||
{ id: 'versicherung',label: 'Versicherung',icon: 'shield', color: '#3b82f6' },
|
|
||||||
{ id: 'sitter', label: 'Sitter', icon: 'paw-print', color: '#10b981' },
|
|
||||||
{ id: 'sonstiges', label: 'Sonstiges', icon: 'receipt', color: '#6b7280' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function _kat(id) {
|
|
||||||
return KATEGORIEN.find(k => k.id === id) || { id, label: id, icon: 'receipt', color: '#6b7280' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// LIFECYCLE
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function init(container, appState) {
|
|
||||||
_container = container;
|
|
||||||
_appState = appState;
|
|
||||||
_summary = null;
|
|
||||||
_entries = [];
|
|
||||||
_statsData = null;
|
|
||||||
_render();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
_summary = null;
|
|
||||||
_entries = [];
|
|
||||||
_statsData = null;
|
|
||||||
await _renderTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// SHELL
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _render() {
|
|
||||||
_container.innerHTML = `
|
|
||||||
<div class="by-tabs exp-tabs" id="exp-tabs">
|
|
||||||
${TABS.map(t => `
|
|
||||||
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
|
|
||||||
${UI.icon(t.icon)} ${t.label}
|
|
||||||
</button>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
<div id="exp-content"></div>
|
|
||||||
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
|
|
||||||
${UI.icon('plus')}
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
_container.querySelectorAll('#exp-tabs .by-tab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
_tab = btn.dataset.tab;
|
|
||||||
_container.querySelectorAll('#exp-tabs .by-tab').forEach(b =>
|
|
||||||
b.classList.toggle('active', b.dataset.tab === _tab)
|
|
||||||
);
|
|
||||||
_renderTab();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
_container.querySelector('#exp-fab')
|
|
||||||
?.addEventListener('click', () => _showForm(null));
|
|
||||||
|
|
||||||
_renderTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// TAB ROUTER
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _renderTab() {
|
|
||||||
const el = _container.querySelector('#exp-content');
|
|
||||||
if (!el) return;
|
|
||||||
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
|
|
||||||
try {
|
|
||||||
switch (_tab) {
|
|
||||||
case 'uebersicht': await _renderUebersicht(el); break;
|
|
||||||
case 'eintraege': await _renderEintraege(el); break;
|
|
||||||
case 'dauerauftraege': await _renderDauerauftraege(el); break;
|
|
||||||
case 'statistik': await _renderStatistik(el); break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// TAB: ÜBERSICHT
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _renderUebersicht(el) {
|
|
||||||
if (!_summary) {
|
|
||||||
_summary = await API.get('/expenses/summary');
|
|
||||||
}
|
|
||||||
const s = _summary;
|
|
||||||
|
|
||||||
// Vormonatsvergleich berechnen
|
|
||||||
const letzteMonat = await _getLetzteMonateData();
|
|
||||||
const trendHtml = _trendHtml(letzteMonat);
|
|
||||||
|
|
||||||
const kacheln = KATEGORIEN.map(k => {
|
|
||||||
const betrag = s.monat[k.id] || 0;
|
|
||||||
return `
|
|
||||||
<div class="exp-kachel">
|
|
||||||
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
|
|
||||||
${UI.icon(k.icon)}
|
|
||||||
</div>
|
|
||||||
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(betrag)}</div>
|
|
||||||
<div class="exp-kachel-label">${k.label}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : '';
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="exp-hero-card">
|
|
||||||
<div class="exp-hero-label">Dieser Monat</div>
|
|
||||||
<div class="exp-hero-betrag">${_fmt(s.gesamt_monat)}</div>
|
|
||||||
<div class="exp-hero-meta">
|
|
||||||
${UI.icon('calendar')} Dieses Jahr: <strong>${_fmt(s.gesamt_jahr)}</strong>
|
|
||||||
${trendHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="exp-kachel-grid">${kacheln}</div>
|
|
||||||
${verlauf}
|
|
||||||
<div style="height:80px"></div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _getLetzteMonateData() {
|
|
||||||
if (!_entries.length) {
|
|
||||||
_entries = await API.get('/expenses?limit=500');
|
|
||||||
}
|
|
||||||
const monatMap = {};
|
|
||||||
_entries.forEach(e => {
|
|
||||||
const m = e.datum.substring(0, 7);
|
|
||||||
monatMap[m] = (monatMap[m] || 0) + e.betrag;
|
|
||||||
});
|
|
||||||
return Object.entries(monatMap)
|
|
||||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
|
||||||
.slice(0, 6)
|
|
||||||
.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
function _trendHtml(data) {
|
|
||||||
// Vergleich: aktueller Monat vs. Vormonat
|
|
||||||
if (data.length < 2) return '';
|
|
||||||
const aktuell = data[data.length - 1][1];
|
|
||||||
const vormonat = data[data.length - 2][1];
|
|
||||||
if (!vormonat) return '';
|
|
||||||
const diff = aktuell - vormonat;
|
|
||||||
const pct = Math.round(Math.abs(diff / vormonat) * 100);
|
|
||||||
if (pct === 0) return '';
|
|
||||||
const pfeil = diff > 0
|
|
||||||
? `<span class="exp-trend exp-trend--up">${UI.icon('arrow-up')} +${pct}% ggü. Vormonat</span>`
|
|
||||||
: `<span class="exp-trend exp-trend--down">${UI.icon('arrow-down')} −${pct}% ggü. Vormonat</span>`;
|
|
||||||
return pfeil;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _vergleichHtml(data) {
|
|
||||||
if (!data.length) return '';
|
|
||||||
const max = Math.max(...data.map(d => d[1]), 1);
|
|
||||||
const balken = data.map(([monat, summe]) => {
|
|
||||||
const pct = Math.round((summe / max) * 100);
|
|
||||||
const [y, m] = monat.split('-');
|
|
||||||
const label = new Date(parseInt(y), parseInt(m) - 1, 1)
|
|
||||||
.toLocaleString('de-DE', { month: 'short' });
|
|
||||||
return `
|
|
||||||
<div class="exp-bar-item">
|
|
||||||
<div class="exp-bar-track">
|
|
||||||
<div class="exp-bar-fill" style="height:${pct}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="exp-bar-label">${label}</div>
|
|
||||||
<div class="exp-bar-val">${_fmtShort(summe)}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="exp-section">
|
|
||||||
<div class="exp-section-title">${UI.icon('chart-bar')} Verlauf (6 Monate)</div>
|
|
||||||
<div class="exp-bar-chart">${balken}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// TAB: EINTRÄGE
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _renderEintraege(el) {
|
|
||||||
if (!_entries.length) {
|
|
||||||
_entries = await API.get('/expenses?limit=500');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_entries.length) {
|
|
||||||
el.innerHTML = UI.emptyState({
|
|
||||||
icon: UI.icon('receipt'),
|
|
||||||
title: 'Noch keine Ausgaben',
|
|
||||||
text: 'Tippe auf + um deine erste Ausgabe einzutragen.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nach Monat gruppieren
|
|
||||||
const groups = {};
|
|
||||||
_entries.forEach(e => {
|
|
||||||
const m = e.datum.substring(0, 7);
|
|
||||||
if (!groups[m]) groups[m] = [];
|
|
||||||
groups[m].push(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
const html = Object.entries(groups)
|
|
||||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
|
||||||
.map(([monat, items]) => {
|
|
||||||
const [y, m] = monat.split('-');
|
|
||||||
const titel = new Date(parseInt(y), parseInt(m) - 1, 1)
|
|
||||||
.toLocaleString('de-DE', { month: 'long', year: 'numeric' });
|
|
||||||
const summe = items.reduce((s, e) => s + e.betrag, 0);
|
|
||||||
|
|
||||||
const rows = items.map(e => {
|
|
||||||
const k = _kat(e.kategorie);
|
|
||||||
const datum = new Date(e.datum + 'T00:00:00')
|
|
||||||
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
|
||||||
const dogBadge = e.dog_name
|
|
||||||
? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
|
|
||||||
: '';
|
|
||||||
const notiz = e.notiz
|
|
||||||
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
|
|
||||||
: '';
|
|
||||||
return `
|
|
||||||
<div class="exp-entry" data-id="${e.id}">
|
|
||||||
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
|
|
||||||
${UI.icon(k.icon)}
|
|
||||||
</div>
|
|
||||||
<div class="exp-entry-body">
|
|
||||||
<div class="exp-entry-head">
|
|
||||||
<span class="exp-entry-datum">${datum}</span>
|
|
||||||
<span class="exp-entry-kat">${k.label}</span>
|
|
||||||
${dogBadge}
|
|
||||||
</div>
|
|
||||||
${notiz}
|
|
||||||
</div>
|
|
||||||
<div class="exp-entry-right">
|
|
||||||
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
|
|
||||||
<button class="exp-entry-del" data-del="${e.id}" title="Löschen"
|
|
||||||
aria-label="Eintrag löschen">
|
|
||||||
${UI.icon('trash')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="exp-month-group">
|
|
||||||
<div class="exp-month-header">
|
|
||||||
<span class="exp-month-title">${titel}</span>
|
|
||||||
<span class="exp-month-summe">${_fmt(summe)}</span>
|
|
||||||
</div>
|
|
||||||
${rows}
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
|
|
||||||
|
|
||||||
// Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
|
|
||||||
el.querySelectorAll('.exp-entry').forEach(row => {
|
|
||||||
row.addEventListener('click', (ev) => {
|
|
||||||
if (ev.target.closest('.exp-entry-del')) return;
|
|
||||||
const id = parseInt(row.dataset.id);
|
|
||||||
const entry = _entries.find(e => e.id === id);
|
|
||||||
if (entry) _showForm(entry);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Löschen-Buttons
|
|
||||||
el.querySelectorAll('.exp-entry-del').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async (ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
const id = parseInt(btn.dataset.del);
|
|
||||||
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
|
|
||||||
try {
|
|
||||||
await API.del(`/expenses/${id}`);
|
|
||||||
UI.toast.success('Ausgabe gelöscht.');
|
|
||||||
_invalidateCache();
|
|
||||||
await _renderTab();
|
|
||||||
} catch (e) {
|
|
||||||
UI.toast.error(e.message || 'Fehler beim Löschen.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// TAB: DAUERAUFTRÄGE
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
const HAEUFIGKEIT_LABEL = {
|
|
||||||
monatlich: 'Monatlich',
|
|
||||||
quartalsweise: 'Quartalsweise',
|
|
||||||
jaehrlich: 'Jährlich',
|
|
||||||
};
|
|
||||||
|
|
||||||
async function _renderDauerauftraege(el) {
|
|
||||||
let recurring = [];
|
|
||||||
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ }
|
|
||||||
|
|
||||||
const cards = recurring.map(r => {
|
|
||||||
const k = _kat(r.kategorie);
|
|
||||||
const naechste = r.naechste_faelligkeit
|
|
||||||
? new Date(r.naechste_faelligkeit + 'T00:00:00')
|
|
||||||
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
: '—';
|
|
||||||
return `
|
|
||||||
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
|
|
||||||
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
|
|
||||||
<div class="exp-entry-body">
|
|
||||||
<div class="exp-entry-head">
|
|
||||||
<span class="exp-entry-kat">${k.label}</span>
|
|
||||||
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
|
|
||||||
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
|
|
||||||
<div class="exp-recurring-next">
|
|
||||||
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
|
|
||||||
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="exp-entry-right">
|
|
||||||
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
|
|
||||||
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
|
|
||||||
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
|
|
||||||
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
|
|
||||||
${UI.icon(r.aktiv ? 'pause' : 'play')}
|
|
||||||
</button>
|
|
||||||
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
|
|
||||||
title="Löschen">${UI.icon('trash')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
|
||||||
<button class="btn btn-primary btn-sm" id="exp-recurring-add">
|
|
||||||
${UI.icon('plus')} Dauerauftrag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
${recurring.length
|
|
||||||
? `<div class="exp-list">${cards}</div>`
|
|
||||||
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
|
|
||||||
title: 'Keine Daueraufträge',
|
|
||||||
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
|
|
||||||
<div style="height:80px"></div>`;
|
|
||||||
|
|
||||||
el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => {
|
|
||||||
_tab = 'dauerauftraege'; _renderTab();
|
|
||||||
}));
|
|
||||||
|
|
||||||
el.querySelectorAll('.exp-recurring-toggle').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const rid = parseInt(btn.dataset.rid);
|
|
||||||
const aktiv = btn.dataset.aktiv === '1';
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv });
|
|
||||||
_renderTab();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
el.querySelectorAll('.exp-recurring-del').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
if (!window.confirm('Dauerauftrag löschen?')) return;
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.del(`/expenses/recurring/${btn.dataset.rid}`);
|
|
||||||
_renderTab();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showRecurringForm(r, onSave) {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const katOptions = [
|
|
||||||
{ id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' },
|
|
||||||
{ id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' },
|
|
||||||
{ id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' },
|
|
||||||
].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
|
|
||||||
|
|
||||||
const dogOptions = (_appState.dogs || []).map(d =>
|
|
||||||
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
<form id="exp-recurring-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Kategorie</label>
|
|
||||||
<select class="form-control" name="kategorie">${katOptions}</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Betrag (€)</label>
|
|
||||||
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
|
|
||||||
value="${r?.betrag || ''}" placeholder="0,00" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Häufigkeit</label>
|
|
||||||
<select class="form-control" name="haeufigkeit">
|
|
||||||
<option value="monatlich" ${r?.haeufigkeit === 'monatlich' ? 'selected' : ''}>Monatlich</option>
|
|
||||||
<option value="quartalsweise" ${r?.haeufigkeit === 'quartalsweise' ? 'selected' : ''}>Quartalsweise (alle 3 Monate)</option>
|
|
||||||
<option value="jaehrlich" ${r?.haeufigkeit === 'jaehrlich' ? 'selected' : ''}>Jährlich</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Startdatum</label>
|
|
||||||
<input class="form-control" type="date" name="startdatum"
|
|
||||||
value="${r?.startdatum || today}" required>
|
|
||||||
</div>
|
|
||||||
${dogOptions ? `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
|
|
||||||
<select class="form-control" name="dog_id">
|
|
||||||
<option value="">Kein Hund</option>${dogOptions}
|
|
||||||
</select>
|
|
||||||
</div>` : ''}
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
|
|
||||||
<input class="form-control" type="text" name="notiz"
|
|
||||||
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
|
|
||||||
</div>
|
|
||||||
</form>`;
|
|
||||||
|
|
||||||
const footer = `
|
|
||||||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
|
|
||||||
|
|
||||||
UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer });
|
|
||||||
|
|
||||||
document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]');
|
|
||||||
const fd = UI.formData(e.target);
|
|
||||||
const payload = {
|
|
||||||
kategorie: fd.kategorie,
|
|
||||||
betrag: parseFloat(fd.betrag),
|
|
||||||
haeufigkeit: fd.haeufigkeit,
|
|
||||||
startdatum: fd.startdatum,
|
|
||||||
notiz: fd.notiz || null,
|
|
||||||
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
|
|
||||||
};
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
if (r) {
|
|
||||||
await API.patch(`/expenses/recurring/${r.id}`, payload);
|
|
||||||
} else {
|
|
||||||
await API.post('/expenses/recurring', payload);
|
|
||||||
}
|
|
||||||
UI.modal.close();
|
|
||||||
onSave?.();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// TAB: STATISTIK
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _renderStatistik(el) {
|
|
||||||
if (!_summary) {
|
|
||||||
_summary = await API.get('/expenses/summary');
|
|
||||||
}
|
|
||||||
if (!_entries.length) {
|
|
||||||
_entries = await API.get('/expenses?limit=500');
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = _summary;
|
|
||||||
const gesamtJahr = s.gesamt_jahr || 1;
|
|
||||||
|
|
||||||
// Jahres-Aufteilung nach Kategorien (als Balken-Reihen)
|
|
||||||
const katBalken = KATEGORIEN
|
|
||||||
.filter(k => (s.jahr[k.id] || 0) > 0)
|
|
||||||
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
|
|
||||||
.map(k => {
|
|
||||||
const val = s.jahr[k.id] || 0;
|
|
||||||
const pct = Math.round((val / gesamtJahr) * 100);
|
|
||||||
return `
|
|
||||||
<div class="exp-stat-row">
|
|
||||||
<div class="exp-stat-label">
|
|
||||||
<span class="exp-stat-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
|
|
||||||
${k.label}
|
|
||||||
</div>
|
|
||||||
<div class="exp-stat-bar-wrap">
|
|
||||||
<div class="exp-stat-bar" style="width:${pct}%;background:${k.color}"></div>
|
|
||||||
</div>
|
|
||||||
<div class="exp-stat-pct">${pct}%</div>
|
|
||||||
<div class="exp-stat-val">${_fmt(val)}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Monats-Balken mit gestapelten Top-2-Kategorien
|
|
||||||
const heute = new Date();
|
|
||||||
const jahrStr = heute.getFullYear().toString();
|
|
||||||
|
|
||||||
// Pro Monat: Summe je Kategorie berechnen
|
|
||||||
const monatKatMap = {}; // { monat: { katId: summe } }
|
|
||||||
_entries
|
|
||||||
.filter(e => e.datum.startsWith(jahrStr))
|
|
||||||
.forEach(e => {
|
|
||||||
const m = parseInt(e.datum.split('-')[1]);
|
|
||||||
if (!monatKatMap[m]) monatKatMap[m] = {};
|
|
||||||
monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag;
|
|
||||||
});
|
|
||||||
|
|
||||||
const monatTotalMap = {};
|
|
||||||
Object.entries(monatKatMap).forEach(([m, katObj]) => {
|
|
||||||
monatTotalMap[m] = Object.values(katObj).reduce((a, b) => a + b, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxMonat = Math.max(...Object.values(monatTotalMap), 1);
|
|
||||||
const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
|
||||||
|
|
||||||
const monatsBalken = MONATE.map((label, i) => {
|
|
||||||
const mi = i + 1;
|
|
||||||
const total = monatTotalMap[mi] || 0;
|
|
||||||
const pct = Math.round((total / maxMonat) * 100);
|
|
||||||
const isAktiv = mi === (heute.getMonth() + 1);
|
|
||||||
|
|
||||||
// Top-2-Kategorien für gestapelten Balken
|
|
||||||
let stackHtml = '';
|
|
||||||
if (total > 0 && monatKatMap[mi]) {
|
|
||||||
const sorted = Object.entries(monatKatMap[mi])
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 2);
|
|
||||||
// Gesamthöhe = pct%, verteile anteilig auf Top-2
|
|
||||||
let rest = pct;
|
|
||||||
const segments = sorted.map(([katId, val], idx) => {
|
|
||||||
const k = _kat(katId);
|
|
||||||
const segPct = idx < sorted.length - 1
|
|
||||||
? Math.round((val / total) * pct)
|
|
||||||
: rest;
|
|
||||||
rest -= segPct;
|
|
||||||
return `<div class="exp-stack-seg" style="height:${segPct}%;background:${k.color}" title="${k.label}: ${_fmt(val)}"></div>`;
|
|
||||||
});
|
|
||||||
stackHtml = segments.join('');
|
|
||||||
} else {
|
|
||||||
stackHtml = `<div class="exp-stack-seg" style="height:${pct}%;background:var(--c-border)"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="exp-bar-item${isAktiv ? ' exp-bar-item--aktiv' : ''}">
|
|
||||||
<div class="exp-bar-track exp-bar-track--stack">
|
|
||||||
${stackHtml}
|
|
||||||
</div>
|
|
||||||
<div class="exp-bar-label">${label}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
// Donut-Übersicht (CSS-gradient)
|
|
||||||
const donutHtml = _donutHtml(s, gesamtJahr);
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="exp-hero-card exp-hero-card--sm">
|
|
||||||
<div class="exp-hero-label">Gesamt dieses Jahr</div>
|
|
||||||
<div class="exp-hero-betrag">${_fmt(s.gesamt_jahr)}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="exp-section">
|
|
||||||
<div class="exp-section-title">${UI.icon('chart-bar')} Monatsübersicht ${jahrStr}</div>
|
|
||||||
<div class="exp-bar-chart exp-bar-chart--12">${monatsBalken}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${donutHtml}
|
|
||||||
|
|
||||||
<div class="exp-section">
|
|
||||||
<div class="exp-section-title">${UI.icon('chart-pie')} Aufteilung nach Kategorie</div>
|
|
||||||
<div class="exp-stat-rows">
|
|
||||||
${katBalken || `<div class="exp-empty-hint">Noch keine Ausgaben dieses Jahr.</div>`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="height:80px"></div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Donut via CSS conic-gradient
|
|
||||||
function _donutHtml(s, gesamt) {
|
|
||||||
const aktiveKat = KATEGORIEN.filter(k => (s.jahr[k.id] || 0) > 0);
|
|
||||||
if (!aktiveKat.length) return '';
|
|
||||||
|
|
||||||
// Stops für conic-gradient berechnen
|
|
||||||
let offset = 0;
|
|
||||||
const stops = [];
|
|
||||||
aktiveKat.forEach(k => {
|
|
||||||
const pct = (s.jahr[k.id] || 0) / gesamt * 100;
|
|
||||||
stops.push(`${k.color} ${offset.toFixed(1)}% ${(offset + pct).toFixed(1)}%`);
|
|
||||||
offset += pct;
|
|
||||||
});
|
|
||||||
const gradient = `conic-gradient(${stops.join(', ')})`;
|
|
||||||
|
|
||||||
const legendeItems = aktiveKat
|
|
||||||
.sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0))
|
|
||||||
.map(k => {
|
|
||||||
const pct = Math.round((s.jahr[k.id] || 0) / gesamt * 100);
|
|
||||||
return `
|
|
||||||
<div class="exp-donut-legend-item">
|
|
||||||
<span class="exp-donut-dot" style="background:${k.color}"></span>
|
|
||||||
<span class="exp-donut-legend-label">${k.label}</span>
|
|
||||||
<span class="exp-donut-legend-pct">${pct}%</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="exp-section">
|
|
||||||
<div class="exp-section-title">${UI.icon('chart-pie')} Kategorien-Verteilung</div>
|
|
||||||
<div class="exp-donut-wrap">
|
|
||||||
<div class="exp-donut" style="background:${gradient}">
|
|
||||||
<div class="exp-donut-hole"></div>
|
|
||||||
</div>
|
|
||||||
<div class="exp-donut-legend">${legendeItems}</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// FORMULAR — Neu / Bearbeiten
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _showForm(entry) {
|
|
||||||
const isEdit = !!entry;
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
|
||||||
const formId = 'exp-form';
|
|
||||||
const selKat = entry?.kategorie || 'sonstiges';
|
|
||||||
|
|
||||||
const dogOptions = (_appState.dogs || []).map(d =>
|
|
||||||
`<option value="${d.id}"${entry?.dog_id === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
// Kategorie-Kacheln statt Dropdown
|
|
||||||
const katKacheln = KATEGORIEN.map(k => `
|
|
||||||
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}">
|
|
||||||
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} style="display:none">
|
|
||||||
<span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
|
|
||||||
<span class="exp-kat-tile-label">${k.label}</span>
|
|
||||||
</label>`).join('');
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
<form id="${formId}" autocomplete="off">
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Kategorie</label>
|
|
||||||
<div class="exp-kat-grid">${katKacheln}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
|
||||||
<div class="form-group" style="margin-bottom:0">
|
|
||||||
<label class="form-label">Betrag</label>
|
|
||||||
<div class="exp-betrag-wrap">
|
|
||||||
<span class="exp-betrag-prefix">€</span>
|
|
||||||
<input type="number" name="betrag" class="form-control exp-betrag-input"
|
|
||||||
value="${entry?.betrag || ''}" min="0.01" step="0.01"
|
|
||||||
placeholder="0,00" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom:0">
|
|
||||||
<label class="form-label">Datum</label>
|
|
||||||
<input type="date" name="datum" class="form-control"
|
|
||||||
value="${entry?.datum || today}" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${dogOptions ? `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Hund <span class="form-label-hint">(optional)</span></label>
|
|
||||||
<select name="dog_id" class="form-control">
|
|
||||||
<option value="">— kein Hund —</option>${dogOptions}
|
|
||||||
</select>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
|
|
||||||
<input type="text" name="notiz" class="form-control"
|
|
||||||
value="${_esc(entry?.notiz || '')}"
|
|
||||||
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${!isEdit ? `
|
|
||||||
<div class="exp-repeat-section">
|
|
||||||
<label class="exp-repeat-toggle">
|
|
||||||
<input type="checkbox" id="exp-wiederholen" name="wiederholen">
|
|
||||||
<span class="exp-repeat-toggle-box"></span>
|
|
||||||
<span>${UI.icon('arrows-clockwise')} Automatisch wiederholen</span>
|
|
||||||
</label>
|
|
||||||
<div id="exp-repeat-opts" style="display:none;margin-top:var(--space-3)">
|
|
||||||
<select name="haeufigkeit" class="form-control">
|
|
||||||
<option value="monatlich">Monatlich</option>
|
|
||||||
<option value="quartalsweise">Quartalsweise (alle 3 Monate)</option>
|
|
||||||
<option value="jaehrlich" selected>Jährlich</option>
|
|
||||||
</select>
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-2) 0 0">
|
|
||||||
Der Betrag wird automatisch zum Fälligkeitstermin eingetragen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
</form>`;
|
|
||||||
|
|
||||||
const footer = isEdit ? `
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="exp-delete-btn"
|
|
||||||
style="color:var(--c-danger);margin-right:auto">
|
|
||||||
${UI.icon('trash')}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
|
|
||||||
` : `
|
|
||||||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
|
||||||
<button type="submit" form="${formId}" class="btn btn-primary flex-1">Speichern</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer });
|
|
||||||
|
|
||||||
// Kategorie-Kacheln interaktiv
|
|
||||||
modal.querySelectorAll('.exp-kat-tile').forEach(tile => {
|
|
||||||
tile.addEventListener('click', () => {
|
|
||||||
modal.querySelectorAll('.exp-kat-tile').forEach(t => t.classList.remove('exp-kat-tile--sel'));
|
|
||||||
tile.classList.add('exp-kat-tile--sel');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wiederholen-Toggle
|
|
||||||
modal.querySelector('#exp-wiederholen')?.addEventListener('change', e => {
|
|
||||||
modal.querySelector('#exp-repeat-opts').style.display = e.target.checked ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isEdit) {
|
|
||||||
modal.querySelector('#exp-delete-btn')?.addEventListener('click', async () => {
|
|
||||||
if (!window.confirm('Diesen Eintrag wirklich löschen?')) return;
|
|
||||||
try {
|
|
||||||
await API.del(`/expenses/${entry.id}`);
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Ausgabe gelöscht.');
|
|
||||||
_invalidateCache();
|
|
||||||
await _renderTab();
|
|
||||||
} catch (e) {
|
|
||||||
UI.toast.error(e.message || 'Fehler beim Löschen.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const fd = UI.formData(ev.target);
|
|
||||||
const payload = {
|
|
||||||
kategorie: fd.kategorie,
|
|
||||||
betrag: parseFloat(fd.betrag),
|
|
||||||
datum: fd.datum,
|
|
||||||
notiz: fd.notiz || null,
|
|
||||||
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEdit) {
|
|
||||||
await API.patch(`/expenses/${entry.id}`, payload);
|
|
||||||
UI.toast.success('Ausgabe aktualisiert.');
|
|
||||||
} else {
|
|
||||||
await API.post('/expenses', payload);
|
|
||||||
// Auch als Dauerauftrag anlegen wenn gewünscht
|
|
||||||
if (fd.wiederholen) {
|
|
||||||
await API.post('/expenses/recurring', {
|
|
||||||
...payload,
|
|
||||||
haeufigkeit: fd.haeufigkeit || 'jaehrlich',
|
|
||||||
startdatum: fd.datum,
|
|
||||||
});
|
|
||||||
UI.toast.success('Ausgabe + Dauerauftrag gespeichert.');
|
|
||||||
} else {
|
|
||||||
UI.toast.success('Ausgabe gespeichert.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UI.modal.close();
|
|
||||||
_invalidateCache();
|
|
||||||
await _renderTab();
|
|
||||||
} catch (e) {
|
|
||||||
UI.toast.error(e.message || 'Fehler beim Speichern.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Hilfsfunktionen
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _invalidateCache() {
|
|
||||||
_summary = null;
|
|
||||||
_entries = [];
|
|
||||||
_statsData = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _fmt(val) {
|
|
||||||
return (val || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function _fmtShort(val) {
|
|
||||||
if (!val) return '0 €';
|
|
||||||
if (val >= 1000) return (val / 1000).toFixed(1).replace('.', ',') + ' k€';
|
|
||||||
return Math.round(val) + ' €';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _esc(s) {
|
|
||||||
if (!s) return '';
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init, refresh };
|
|
||||||
})();
|
|
||||||
|
|
@ -11,8 +11,6 @@ window.Page_health = (() => {
|
||||||
let _data = {};
|
let _data = {};
|
||||||
let _praxen = [];
|
let _praxen = [];
|
||||||
let _activeTab = 'impfung';
|
let _activeTab = 'impfung';
|
||||||
let _favoritVet = null;
|
|
||||||
let _healthDocs = [];
|
|
||||||
|
|
||||||
const BASE_TABS = [
|
const BASE_TABS = [
|
||||||
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
|
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
|
||||||
|
|
@ -22,6 +20,7 @@ window.Page_health = (() => {
|
||||||
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
|
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
|
||||||
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
|
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
|
||||||
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||||||
|
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
|
||||||
];
|
];
|
||||||
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
|
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
|
||||||
|
|
||||||
|
|
@ -151,9 +150,6 @@ window.Page_health = (() => {
|
||||||
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
|
||||||
${UI.icon('star')} KI-Zusammenfassung
|
${UI.icon('star')} KI-Zusammenfassung
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="health-ki-tierarzt-btn">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
${transponderHtml}
|
${transponderHtml}
|
||||||
<div id="health-ki-berichte"></div>
|
<div id="health-ki-berichte"></div>
|
||||||
|
|
@ -166,8 +162,6 @@ window.Page_health = (() => {
|
||||||
_renderTabBar();
|
_renderTabBar();
|
||||||
_container.querySelector('#health-ki-btn')
|
_container.querySelector('#health-ki-btn')
|
||||||
.addEventListener('click', _showKiSummary);
|
.addEventListener('click', _showKiSummary);
|
||||||
_container.querySelector('#health-ki-tierarzt-btn')
|
|
||||||
.addEventListener('click', _showKiTierarzt);
|
|
||||||
_container.querySelector('#health-transponder-edit')
|
_container.querySelector('#health-transponder-edit')
|
||||||
.addEventListener('click', () => _editTransponder(dog));
|
.addEventListener('click', () => _editTransponder(dog));
|
||||||
|
|
||||||
|
|
@ -176,7 +170,6 @@ window.Page_health = (() => {
|
||||||
_renderTab();
|
_renderTab();
|
||||||
_loadKiBerichte(dog.id);
|
_loadKiBerichte(dog.id);
|
||||||
_loadTerminvorschlaege(dog.id);
|
_loadTerminvorschlaege(dog.id);
|
||||||
_loadMeinTierarzt();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -349,16 +342,6 @@ window.Page_health = (() => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_data['gewicht_chart'] = [];
|
_data['gewicht_chart'] = [];
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
_favoritVet = await API.tieraerzte.myFavorite();
|
|
||||||
} catch (err) {
|
|
||||||
_favoritVet = null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
_healthDocs = await API.healthDocs.list(dogId);
|
|
||||||
} catch (err) {
|
|
||||||
_healthDocs = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -379,6 +362,7 @@ window.Page_health = (() => {
|
||||||
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
||||||
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
||||||
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
||||||
|
case 'symptomcheck': _renderSymptomCheck(content); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_bindTabEvents(content);
|
_bindTabEvents(content);
|
||||||
|
|
@ -917,8 +901,7 @@ window.Page_health = (() => {
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `<div class="health-list">${items}</div>
|
return `<div class="health-list">${items}</div>
|
||||||
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
|
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>`;
|
||||||
${_renderBefundeSection()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -974,32 +957,6 @@ window.Page_health = (() => {
|
||||||
// Praxis hinzufügen
|
// Praxis hinzufügen
|
||||||
content.querySelector('[data-action="add-praxis"]')
|
content.querySelector('[data-action="add-praxis"]')
|
||||||
?.addEventListener('click', () => _showPraxForm(null));
|
?.addEventListener('click', () => _showPraxForm(null));
|
||||||
// Favorit-Toggle für Praxen
|
|
||||||
content.querySelectorAll('[data-action="toggle-fav"]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const vetId = parseInt(btn.dataset.praxisId);
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
const res = await API.tieraerzte.toggleFavorite(vetId);
|
|
||||||
if (res.is_favorite) {
|
|
||||||
_favoritVet = _praxen.find(p => p.id === vetId) || null;
|
|
||||||
UI.toast.success('Als Favorit-Tierarzt gespeichert.');
|
|
||||||
} else {
|
|
||||||
_favoritVet = null;
|
|
||||||
UI.toast.success('Favorit entfernt.');
|
|
||||||
}
|
|
||||||
// is_favorite in _praxen aktualisieren
|
|
||||||
_praxen = _praxen.map(p => ({ ...p, is_favorite: p.id === vetId ? res.is_favorite : false }));
|
|
||||||
const elFav = _container.querySelector('#health-mein-tierarzt');
|
|
||||||
if (elFav) _renderMeinTierarztKachel(elFav);
|
|
||||||
_renderTab();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Befunde & Dokumente
|
|
||||||
if (_activeTab === 'dokument') {
|
|
||||||
_bindBefundeEvents(content);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -1640,9 +1597,7 @@ window.Page_health = (() => {
|
||||||
action: addBtn
|
action: addBtn
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderCard = p => {
|
const renderCard = p => `
|
||||||
const isFav = _favoritVet?.id === p.id || p.is_favorite;
|
|
||||||
return `
|
|
||||||
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
|
||||||
data-praxis-id="${p.id}" data-action="open-praxis">
|
data-praxis-id="${p.id}" data-action="open-praxis">
|
||||||
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
|
<div style="font-size:1.5rem"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${p.ist_notfallpraxis ? 'warning' : 'first-aid'}"></use></svg></div>
|
||||||
|
|
@ -1671,40 +1626,17 @@ window.Page_health = (() => {
|
||||||
onclick="event.stopPropagation()">
|
onclick="event.stopPropagation()">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
|
||||||
</a>` : ''}
|
</a>` : ''}
|
||||||
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
|
|
||||||
data-action="toggle-fav" data-praxis-id="${p.id}"
|
|
||||||
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
|
|
||||||
style="flex-shrink:0"
|
|
||||||
onclick="event.stopPropagation()">
|
|
||||||
<svg class="ph-icon" aria-hidden="true">
|
|
||||||
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
|
|
||||||
</svg>
|
|
||||||
${isFav ? 'Mein Tierarzt' : 'Als Favorit'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`};
|
`;
|
||||||
|
|
||||||
|
|
||||||
const favCard = _favoritVet ? `
|
|
||||||
<div style="margin-bottom:var(--space-4)">
|
|
||||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary);
|
|
||||||
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
|
|
||||||
${UI.icon('heart')} Mein Tierarzt
|
|
||||||
</div>
|
|
||||||
${renderCard(_favoritVet)}
|
|
||||||
</div>` : '';
|
|
||||||
|
|
||||||
const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id);
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||||
${addBtn}
|
${addBtn}
|
||||||
</div>
|
</div>
|
||||||
${favCard}
|
|
||||||
<div class="health-list">
|
<div class="health-list">
|
||||||
${ohneGesetzt.map(renderCard).join('')}
|
${aktive.map(renderCard).join('')}
|
||||||
${inaktive.length ? `
|
${inaktive.length ? `
|
||||||
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
|
<div style="margin-top:var(--space-4);padding-top:var(--space-3);
|
||||||
border-top:1px solid var(--c-border)">
|
border-top:1px solid var(--c-border)">
|
||||||
|
|
@ -2224,306 +2156,6 @@ window.Page_health = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// MEIN TIERARZT — Kachel
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _loadMeinTierarzt() {
|
|
||||||
const el = _container.querySelector('#health-mein-tierarzt');
|
|
||||||
if (!el) return;
|
|
||||||
_renderMeinTierarztKachel(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _renderMeinTierarztKachel(el) {
|
|
||||||
if (!el) return;
|
|
||||||
const vet = _favoritVet;
|
|
||||||
const adresse = vet
|
|
||||||
? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="margin:var(--space-3) var(--space-4) 0">
|
|
||||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
|
|
||||||
text-transform:uppercase;letter-spacing:.05em;margin-bottom:var(--space-2)">
|
|
||||||
Mein Tierarzt
|
|
||||||
</div>
|
|
||||||
<div class="health-card" style="align-items:flex-start">
|
|
||||||
<div style="font-size:1.6rem;flex-shrink:0">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
|
|
||||||
</div>
|
|
||||||
<div class="health-card-body" style="flex:1;min-width:0">
|
|
||||||
${vet ? `
|
|
||||||
<div class="health-card-title">${_esc(vet.name)}</div>
|
|
||||||
${adresse ? `<div class="health-card-meta">${_esc(adresse)}</div>` : ''}
|
|
||||||
${vet.telefon ? `
|
|
||||||
<div style="margin-top:var(--space-2)">
|
|
||||||
<a href="tel:${_esc(vet.telefon)}" class="btn btn-secondary btn-sm"
|
|
||||||
onclick="event.stopPropagation()">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${_esc(vet.telefon)}
|
|
||||||
</a>
|
|
||||||
</div>` : ''}
|
|
||||||
${vet.notfall_telefon ? `
|
|
||||||
<div style="margin-top:var(--space-1)">
|
|
||||||
<a href="tel:${_esc(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
|
|
||||||
onclick="event.stopPropagation()">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${_esc(vet.notfall_telefon)}
|
|
||||||
</a>
|
|
||||||
</div>` : ''}
|
|
||||||
` : `
|
|
||||||
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">
|
|
||||||
Noch kein Tierarzt als Favorit gespeichert.
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-secondary btn-sm" style="margin-top:var(--space-2)"
|
|
||||||
id="health-suche-tierarzt-btn">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Tierarzt suchen
|
|
||||||
</button>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
${vet ? `
|
|
||||||
<button class="btn btn-ghost btn-sm" id="health-remove-fav-btn"
|
|
||||||
title="Als Favorit entfernen" style="flex-shrink:0;color:var(--c-danger)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart-fill"></use></svg>
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => {
|
|
||||||
App.navigate('map', { filter: 'tierarzt' });
|
|
||||||
});
|
|
||||||
|
|
||||||
el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const btn = e.currentTarget;
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.tieraerzte.toggleFavorite(_favoritVet.id);
|
|
||||||
_favoritVet = null;
|
|
||||||
const elAgain = _container.querySelector('#health-mein-tierarzt');
|
|
||||||
if (elAgain) _renderMeinTierarztKachel(elAgain);
|
|
||||||
UI.toast.success('Tierarzt-Favorit entfernt.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs)
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Diese Sektion erscheint im "dokument"-Tab als zweite Liste.
|
|
||||||
// Wir ergänzen _renderDokumente um einen Abschnitt unten.
|
|
||||||
|
|
||||||
function _renderBefundeSection() {
|
|
||||||
const dog = _appState.activeDog;
|
|
||||||
const docs = _healthDocs;
|
|
||||||
const DOC_ICONS = {
|
|
||||||
blutbild: 'drop',
|
|
||||||
roentgen: 'file-text',
|
|
||||||
rezept: 'note',
|
|
||||||
impfausweis:'certificate',
|
|
||||||
sonstiges: 'file-text',
|
|
||||||
};
|
|
||||||
const DOC_LABELS = {
|
|
||||||
blutbild: 'Blutbild',
|
|
||||||
roentgen: 'Röntgen',
|
|
||||||
rezept: 'Rezept',
|
|
||||||
impfausweis:'Impfausweis',
|
|
||||||
sonstiges: 'Sonstiges',
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadBtn = `
|
|
||||||
<button class="btn btn-primary btn-sm" id="health-docs-upload-btn">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen
|
|
||||||
</button>`;
|
|
||||||
|
|
||||||
const items = docs.length
|
|
||||||
? docs.map(doc => {
|
|
||||||
const icon = DOC_ICONS[doc.typ] || 'file-text';
|
|
||||||
const label = DOC_LABELS[doc.typ] || doc.typ;
|
|
||||||
const isImg = !['pdf'].includes(doc.file_type);
|
|
||||||
const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : '';
|
|
||||||
return `
|
|
||||||
<div class="health-card" style="align-items:flex-start">
|
|
||||||
<div style="font-size:1.4rem;flex-shrink:0;color:var(--c-primary)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_esc(icon)}"></use></svg>
|
|
||||||
</div>
|
|
||||||
<div class="health-card-body" style="flex:1;min-width:0">
|
|
||||||
<div class="health-card-title">${_esc(doc.titel)}</div>
|
|
||||||
<div class="health-card-meta">
|
|
||||||
${_esc(label)}${datum ? ' · ' + datum : ''}
|
|
||||||
${doc.vet_name ? ' · <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ' + _esc(doc.vet_name) : ''}
|
|
||||||
</div>
|
|
||||||
${doc.beschreibung ? `<div class="health-card-note">${_esc(doc.beschreibung)}</div>` : ''}
|
|
||||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
|
|
||||||
<a href="${_esc(doc.file_path)}" target="_blank" rel="noopener"
|
|
||||||
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
|
|
||||||
${isImg
|
|
||||||
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
|
|
||||||
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-ghost btn-xs" style="color:var(--c-danger)"
|
|
||||||
data-action="delete-hdoc" data-doc-id="${doc.id}"
|
|
||||||
onclick="event.stopPropagation()">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('')
|
|
||||||
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted);padding:var(--space-3) 0">
|
|
||||||
Noch keine Befunde hochgeladen.
|
|
||||||
</p>`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
|
|
||||||
border-top:1px solid var(--c-border)">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
|
||||||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-muted);
|
|
||||||
text-transform:uppercase;letter-spacing:.05em">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> Befunde & Dokumente
|
|
||||||
</div>
|
|
||||||
${uploadBtn}
|
|
||||||
</div>
|
|
||||||
<div class="health-list" id="health-docs-list">${items}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _bindBefundeEvents(content) {
|
|
||||||
content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => {
|
|
||||||
_showBefundUploadModal();
|
|
||||||
});
|
|
||||||
content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const docId = parseInt(btn.dataset.docId);
|
|
||||||
const ok = window.confirm('Befund wirklich löschen?');
|
|
||||||
if (!ok) return;
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.healthDocs.delete(docId);
|
|
||||||
_healthDocs = _healthDocs.filter(d => d.id !== docId);
|
|
||||||
_renderTab();
|
|
||||||
UI.toast.success('Befund gelöscht.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showBefundUploadModal() {
|
|
||||||
const aktivePraxen = _praxen.filter(p => p.aktiv);
|
|
||||||
const dog = _appState.activeDog;
|
|
||||||
|
|
||||||
UI.modal.open({
|
|
||||||
title: `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#upload"></use></svg> Befund hochladen`,
|
|
||||||
body: `
|
|
||||||
<form id="befund-form" autocomplete="off">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Art des Dokuments *</label>
|
|
||||||
<select class="form-control" name="typ" required>
|
|
||||||
<option value="">– bitte wählen –</option>
|
|
||||||
<option value="blutbild">Blutbild</option>
|
|
||||||
<option value="roentgen">Röntgen</option>
|
|
||||||
<option value="rezept">Rezept</option>
|
|
||||||
<option value="impfausweis">Impfausweis</option>
|
|
||||||
<option value="sonstiges">Sonstiges</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Titel *</label>
|
|
||||||
<input class="form-control" type="text" name="titel" required
|
|
||||||
placeholder="z.B. Blutbild März 2026">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Untersuchungsdatum</label>
|
|
||||||
<input class="form-control" type="date" name="datum"
|
|
||||||
value="${new Date().toISOString().slice(0,10)}">
|
|
||||||
</div>
|
|
||||||
${aktivePraxen.length ? `
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Tierarzt / Praxis</label>
|
|
||||||
<select class="form-control" name="vet_id">
|
|
||||||
<option value="">– optional –</option>
|
|
||||||
${aktivePraxen.map(p =>
|
|
||||||
`<option value="${p.id}">${_esc(p.name)}${p.ort ? ' · ' + _esc(p.ort) : ''}</option>`
|
|
||||||
).join('')}
|
|
||||||
</select>
|
|
||||||
</div>` : ''}
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Beschreibung</label>
|
|
||||||
<textarea class="form-control" name="beschreibung" rows="2"
|
|
||||||
placeholder="Zusätzliche Infos (optional)"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Datei * (PDF, JPG, PNG, WebP — max. 10 MB)</label>
|
|
||||||
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;
|
|
||||||
align-items:center;gap:var(--space-2)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Datei auswählen
|
|
||||||
<input type="file" name="file" id="befund-file-input"
|
|
||||||
accept=".pdf,image/*"
|
|
||||||
required
|
|
||||||
style="position:absolute;opacity:0;width:1px;height:1px">
|
|
||||||
</label>
|
|
||||||
<div id="befund-file-preview" style="margin-top:var(--space-2);font-size:var(--text-sm);
|
|
||||||
color:var(--c-text-secondary)"></div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
footer: `
|
|
||||||
<button type="button" class="btn btn-secondary flex-1" id="befund-cancel">Abbrechen</button>
|
|
||||||
<button type="submit" form="befund-form" class="btn btn-primary flex-1">Hochladen</button>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close);
|
|
||||||
|
|
||||||
document.getElementById('befund-file-input')?.addEventListener('change', function () {
|
|
||||||
const preview = document.getElementById('befund-file-preview');
|
|
||||||
if (this.files?.length) {
|
|
||||||
const f = this.files[0];
|
|
||||||
preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`;
|
|
||||||
} else {
|
|
||||||
preview.textContent = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('befund-form')?.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const btn = document.querySelector('[form="befund-form"][type="submit"]');
|
|
||||||
const form = e.target;
|
|
||||||
const fd = UI.formData(form);
|
|
||||||
const fileInput = form.querySelector('[name="file"]');
|
|
||||||
const file = fileInput?.files?.[0];
|
|
||||||
|
|
||||||
if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; }
|
|
||||||
if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; }
|
|
||||||
if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; }
|
|
||||||
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
UI.toast.error('Datei ist zu groß. Maximum: 10 MB.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('dog_id', String(dog.id));
|
|
||||||
formData.append('typ', fd.typ);
|
|
||||||
formData.append('titel', fd.titel);
|
|
||||||
formData.append('beschreibung', fd.beschreibung || '');
|
|
||||||
formData.append('datum', fd.datum || '');
|
|
||||||
if (fd.vet_id) formData.append('vet_id', fd.vet_id);
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const doc = await API.healthDocs.upload(formData);
|
|
||||||
_healthDocs.unshift(doc);
|
|
||||||
UI.modal.close();
|
|
||||||
_renderTab();
|
|
||||||
UI.toast.success('Befund hochgeladen.');
|
|
||||||
} catch (err) {
|
|
||||||
UI.toast.error(err.message || 'Fehler beim Hochladen.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _showKiSummary() {
|
async function _showKiSummary() {
|
||||||
const btn = _container.querySelector('#health-ki-btn');
|
const btn = _container.querySelector('#health-ki-btn');
|
||||||
|
|
@ -2691,129 +2323,6 @@ window.Page_health = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// KI-TIERARZTFRAGEN
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _showKiTierarzt() {
|
|
||||||
const dog = _appState.activeDog;
|
|
||||||
const dogName = dog?.name || '';
|
|
||||||
const rasse = dog?.rasse || '';
|
|
||||||
const placeholder = dogName
|
|
||||||
? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...`
|
|
||||||
: 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...';
|
|
||||||
|
|
||||||
UI.modal.open({
|
|
||||||
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg> KI-Tierarzt',
|
|
||||||
body: `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
|
|
||||||
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung —
|
|
||||||
kein Ersatz für einen echten Tierarzt.
|
|
||||||
</p>
|
|
||||||
<div class="form-group">
|
|
||||||
<textarea id="ki-tierarzt-symptom" class="form-control" rows="4"
|
|
||||||
placeholder="${_esc(placeholder)}"></textarea>
|
|
||||||
</div>
|
|
||||||
<div id="ki-tierarzt-result" style="display:none"></div>
|
|
||||||
<div style="margin-top:var(--space-3);padding:var(--space-3);
|
|
||||||
background:#fff3cd;border-radius:var(--radius-md);
|
|
||||||
font-size:var(--text-xs);color:#856404;
|
|
||||||
border:1px solid #ffc107">
|
|
||||||
<strong>⚠️ Hinweis:</strong> Dies ist keine medizinische Diagnose.
|
|
||||||
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
|
|
||||||
</div>`,
|
|
||||||
footer: `
|
|
||||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
|
||||||
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('ki-tierarzt-submit-btn')
|
|
||||||
.addEventListener('click', async function () {
|
|
||||||
const btn = this;
|
|
||||||
const symptom = document.getElementById('ki-tierarzt-symptom').value.trim();
|
|
||||||
const resultEl = document.getElementById('ki-tierarzt-result');
|
|
||||||
|
|
||||||
if (!symptom) {
|
|
||||||
UI.toast.warning('Bitte Symptome eingeben.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
resultEl.style.display = 'none';
|
|
||||||
resultEl.innerHTML = '';
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await API.post('/ki/tierarzt', {
|
|
||||||
symptom,
|
|
||||||
dog_id: dog?.id || null,
|
|
||||||
dog_name: dogName || null,
|
|
||||||
rasse: rasse || null,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err.status === 429) {
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="padding:var(--space-3);background:var(--c-surface);
|
|
||||||
border-radius:var(--radius-md);border:1px solid var(--c-warning);
|
|
||||||
font-size:var(--text-sm);color:var(--c-text-secondary)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
|
|
||||||
5 Anfragen pro Tag erreicht. Morgen wieder verfügbar.
|
|
||||||
</div>`;
|
|
||||||
} else if (err.status === 503) {
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="padding:var(--space-3);background:var(--c-surface);
|
|
||||||
border-radius:var(--radius-md);border:1px solid var(--c-danger);
|
|
||||||
font-size:var(--text-sm)">
|
|
||||||
KI momentan nicht verfügbar. Bitte später versuchen.
|
|
||||||
</div>`;
|
|
||||||
} else {
|
|
||||||
UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resultEl.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const antwortHtml = _esc(result.antwort)
|
|
||||||
.replace(/\n\n/g, '</p><p style="margin:var(--space-2) 0">')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
const restHtml = result.limit - result.anfragen_heute > 0
|
|
||||||
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
|
|
||||||
Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar.
|
|
||||||
</p>`
|
|
||||||
: `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0">
|
|
||||||
Tageslimit erreicht. Morgen wieder verfügbar.
|
|
||||||
</p>`;
|
|
||||||
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="margin-top:var(--space-4);padding:var(--space-4);
|
|
||||||
background:var(--c-surface);border-radius:var(--radius-md);
|
|
||||||
border:1px solid var(--c-border)">
|
|
||||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
|
||||||
margin-bottom:var(--space-2);color:var(--c-text-secondary)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#stethoscope"></use></svg>
|
|
||||||
Einschätzung
|
|
||||||
</div>
|
|
||||||
<p style="font-size:var(--text-sm);line-height:1.7;margin:0">${antwortHtml}</p>
|
|
||||||
${restHtml}
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:var(--space-3);padding:var(--space-3);
|
|
||||||
background:#fee2e2;border-radius:var(--radius-md);
|
|
||||||
font-size:var(--text-xs);color:#991b1b;
|
|
||||||
border:1px solid #fca5a5">
|
|
||||||
<strong>⚠️ Dies ist keine medizinische Diagnose.</strong>
|
|
||||||
Bei ernsthaften Symptomen sofort zum Tierarzt.
|
|
||||||
</div>`;
|
|
||||||
resultEl.style.display = '';
|
|
||||||
|
|
||||||
// Submit-Button ausblenden wenn Limit erschöpft
|
|
||||||
if (result.anfragen_heute >= result.limit) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Limit erreicht';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init, refresh, openNew, onDogChange };
|
return { init, refresh, openNew, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,708 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
BAN YARO — Playdate-Matching
|
|
||||||
Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
window.Page_playdate = (() => {
|
|
||||||
|
|
||||||
let _container = null;
|
|
||||||
let _appState = null;
|
|
||||||
let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests'
|
|
||||||
let _userPos = null;
|
|
||||||
let _radius = 10;
|
|
||||||
let _dogs = [];
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
function _esc(s) {
|
|
||||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _fmtDate(iso) {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso.replace(' ', 'T'));
|
|
||||||
return d.toLocaleDateString('de-DE');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _dogAvatar(foto_url, name, size = 48) {
|
|
||||||
const initials = _esc((name || '?').charAt(0).toUpperCase());
|
|
||||||
if (foto_url) {
|
|
||||||
return `<img src="${_esc(foto_url)}" alt="${initials}"
|
|
||||||
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
|
|
||||||
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
|
|
||||||
}
|
|
||||||
return `<div style="width:${size}px;height:${size}px;border-radius:50%;
|
|
||||||
background:var(--c-primary-subtle);display:flex;align-items:center;
|
|
||||||
justify-content:center;font-size:${Math.round(size * 0.45)}px;
|
|
||||||
font-weight:700;color:var(--c-primary);">${initials}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _statusBadge(status) {
|
|
||||||
const map = {
|
|
||||||
pending: ['warning', 'Ausstehend'],
|
|
||||||
accepted: ['success', 'Angenommen'],
|
|
||||||
declined: ['danger', 'Abgelehnt'],
|
|
||||||
};
|
|
||||||
const [type, label] = map[status] || ['default', status];
|
|
||||||
const colors = {
|
|
||||||
warning: 'var(--c-warning, #f59e0b)',
|
|
||||||
success: 'var(--c-success, #10b981)',
|
|
||||||
danger: 'var(--c-danger, #ef4444)',
|
|
||||||
default: 'var(--c-text-muted)',
|
|
||||||
};
|
|
||||||
return `<span style="font-size:var(--text-xs);font-weight:600;
|
|
||||||
color:${colors[type]};padding:2px 8px;border-radius:999px;
|
|
||||||
background:${colors[type]}18">${label}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// INIT
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
async function init(container, appState) {
|
|
||||||
_container = container;
|
|
||||||
_appState = appState;
|
|
||||||
_dogs = appState.dogs?.filter(d => !d.is_guest) || [];
|
|
||||||
_render();
|
|
||||||
_switchTab(_activeTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
|
|
||||||
_switchTab(_activeTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDogChange() {
|
|
||||||
_dogs = _appState?.dogs?.filter(d => !d.is_guest) || [];
|
|
||||||
if (_activeTab === 'listings') _loadListings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// RENDER — Grundstruktur mit Tabs
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
function _render() {
|
|
||||||
_container.innerHTML = `
|
|
||||||
<div class="playdate-layout">
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
|
|
||||||
<button class="by-tab active" data-tab="nearby">In der Nähe</button>
|
|
||||||
<button class="by-tab" data-tab="listings">Meine Inserate</button>
|
|
||||||
<button class="by-tab" data-tab="requests">
|
|
||||||
Anfragen
|
|
||||||
<span id="playdate-req-badge" style="display:none;margin-left:4px;
|
|
||||||
background:var(--c-primary);color:#fff;border-radius:999px;
|
|
||||||
padding:1px 6px;font-size:var(--text-xs);font-weight:700">0</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab-Inhalt -->
|
|
||||||
<div id="playdate-content"></div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('playdate-tabs').addEventListener('click', e => {
|
|
||||||
const btn = e.target.closest('.by-tab');
|
|
||||||
if (!btn) return;
|
|
||||||
_switchTab(btn.dataset.tab);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _switchTab(tab) {
|
|
||||||
_activeTab = tab;
|
|
||||||
document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => {
|
|
||||||
b.classList.toggle('active', b.dataset.tab === tab);
|
|
||||||
});
|
|
||||||
const content = document.getElementById('playdate-content');
|
|
||||||
if (!content) return;
|
|
||||||
|
|
||||||
if (tab === 'nearby') _renderNearby(content);
|
|
||||||
if (tab === 'listings') _renderListings(content);
|
|
||||||
if (tab === 'requests') _renderRequests(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// TAB: IN DER NÄHE
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
async function _renderNearby(el) {
|
|
||||||
el.innerHTML = `
|
|
||||||
<div>
|
|
||||||
<!-- Toolbar: Radius-Auswahl + Standort-Button -->
|
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap">
|
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
|
||||||
${UI.icon('map-pin')}
|
|
||||||
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)" id="nearby-location-label">
|
|
||||||
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<select id="nearby-radius" class="form-select" style="width:auto;font-size:var(--text-sm)">
|
|
||||||
<option value="5" ${_radius===5 ? 'selected' : ''}>5 km</option>
|
|
||||||
<option value="10" ${_radius===10 ? 'selected' : ''}>10 km</option>
|
|
||||||
<option value="25" ${_radius===25 ? 'selected' : ''}>25 km</option>
|
|
||||||
<option value="50" ${_radius===50 ? 'selected' : ''}>50 km</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-ghost btn-sm" id="nearby-locate-btn">
|
|
||||||
${UI.icon('crosshair')} Standort aktualisieren
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Info-Hinweis -->
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
|
|
||||||
margin-bottom:var(--space-4);padding:var(--space-2) var(--space-3);
|
|
||||||
background:var(--c-surface-2);border-radius:var(--radius-md)">
|
|
||||||
${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben.
|
|
||||||
Dein genauer Standort bleibt privat — es wird nur der Ortsname und die ungefähre Entfernung angezeigt.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ergebnisse -->
|
|
||||||
<div id="nearby-results">
|
|
||||||
<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">
|
|
||||||
Standort wird ermittelt…
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.getElementById('nearby-radius').addEventListener('change', e => {
|
|
||||||
_radius = parseInt(e.target.value, 10);
|
|
||||||
_loadNearby();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('nearby-locate-btn').addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('nearby-locate-btn');
|
|
||||||
UI.setLoading(btn, true);
|
|
||||||
try {
|
|
||||||
_userPos = await API.getLocation();
|
|
||||||
const label = document.getElementById('nearby-location-label');
|
|
||||||
if (label) label.textContent = 'Standort aktualisiert';
|
|
||||||
await _loadNearby();
|
|
||||||
} catch {
|
|
||||||
UI.toast.error('Standort konnte nicht ermittelt werden.');
|
|
||||||
} finally {
|
|
||||||
UI.setLoading(btn, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!_userPos) {
|
|
||||||
try {
|
|
||||||
_userPos = await API.getLocation();
|
|
||||||
const label = document.getElementById('nearby-location-label');
|
|
||||||
if (label) label.textContent = 'Standort bekannt';
|
|
||||||
} catch {
|
|
||||||
document.getElementById('nearby-results').innerHTML = `
|
|
||||||
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
|
|
||||||
${UI.icon('map-pin')}
|
|
||||||
<p style="margin:var(--space-3) 0 var(--space-4)">
|
|
||||||
Standort konnte nicht automatisch ermittelt werden.<br>
|
|
||||||
Klicke auf "Standort aktualisieren".
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _loadNearby();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadNearby() {
|
|
||||||
if (!_userPos) return;
|
|
||||||
const resultsEl = document.getElementById('nearby-results');
|
|
||||||
if (!resultsEl) return;
|
|
||||||
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Suche…</p>`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`);
|
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
resultsEl.innerHTML = UI.emptyState({
|
|
||||||
icon: UI.icon('paw-print'),
|
|
||||||
title: 'Niemand in der Nähe',
|
|
||||||
text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsEl.innerHTML = `
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3)">
|
|
||||||
${data.map(d => _nearbyCard(d)).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const toDogId = parseInt(btn.dataset.dogId, 10);
|
|
||||||
const dogName = btn.dataset.dogName;
|
|
||||||
_showRequestModal(toDogId, dogName);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
resultsEl.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _nearbyCard(d) {
|
|
||||||
return `
|
|
||||||
<div class="card" style="padding:var(--space-4)">
|
|
||||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
|
|
||||||
${_dogAvatar(d.foto_url, d.dog_name, 56)}
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
|
|
||||||
color:var(--c-text)">${_esc(d.dog_name)}</div>
|
|
||||||
${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''}
|
|
||||||
${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap">
|
|
||||||
<span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">
|
|
||||||
${UI.icon('map-pin')}
|
|
||||||
${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
|
|
||||||
</span>
|
|
||||||
${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${d.beschreibung ? `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
|
||||||
margin:0 0 var(--space-3);line-height:1.5">
|
|
||||||
${_esc(d.beschreibung)}
|
|
||||||
</p>` : ''}
|
|
||||||
|
|
||||||
<button class="btn btn-primary btn-sm playdate-anfrage-btn"
|
|
||||||
data-dog-id="${d.dog_id}"
|
|
||||||
data-dog-name="${_esc(d.dog_name)}">
|
|
||||||
${UI.icon('paw-print')} Spielkamerad anfragen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showRequestModal(toDogId, dogName) {
|
|
||||||
const formId = 'playdate-req-form';
|
|
||||||
UI.modal.open({
|
|
||||||
title: `Anfrage an ${dogName}`,
|
|
||||||
body: `
|
|
||||||
<form id="${formId}">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Nachricht (optional)</label>
|
|
||||||
<textarea id="req-nachricht" class="form-control" rows="3" maxlength="500"
|
|
||||||
placeholder="Hallo! Unsere Hunde könnten super zusammenpassen…"></textarea>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
footer: `
|
|
||||||
<button class="btn btn-secondary" id="req-cancel-btn">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="req-send-btn" form="${formId}">
|
|
||||||
${UI.icon('paper-plane-tilt')} Anfrage senden
|
|
||||||
</button>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close());
|
|
||||||
document.getElementById('req-send-btn').addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('req-send-btn');
|
|
||||||
const nachricht = document.getElementById('req-nachricht').value.trim();
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
const result = await API.post('/playdate/request', {
|
|
||||||
to_dog_id: toDogId,
|
|
||||||
nachricht: nachricht || null,
|
|
||||||
});
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.');
|
|
||||||
// Zum Chat navigieren
|
|
||||||
if (result.conversation_id) {
|
|
||||||
setTimeout(() => {
|
|
||||||
App.navigate('chat', true, { conversation_id: result.conversation_id });
|
|
||||||
}, 800);
|
|
||||||
}
|
|
||||||
}, { errorMsg: null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// TAB: MEINE INSERATE
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
async function _renderListings(el) {
|
|
||||||
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
|
|
||||||
await _loadListings(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _loadListings(el) {
|
|
||||||
const target = el || document.getElementById('playdate-content');
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
if (_dogs.length === 0) {
|
|
||||||
target.innerHTML = UI.emptyState({
|
|
||||||
icon: UI.icon('paw-print'),
|
|
||||||
title: 'Noch kein Hund',
|
|
||||||
text: 'Lege zuerst einen Hund in deinem Profil an.',
|
|
||||||
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Hund anlegen</button>`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listings für alle eigenen Hunde laden
|
|
||||||
const listings = {};
|
|
||||||
await Promise.all(_dogs.map(async dog => {
|
|
||||||
try {
|
|
||||||
const data = await API.get(`/playdate/my-listing/${dog.id}`);
|
|
||||||
listings[dog.id] = data;
|
|
||||||
} catch {
|
|
||||||
listings[dog.id] = null;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
target.innerHTML = `
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
|
||||||
${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Event-Delegation für alle Buttons
|
|
||||||
target.addEventListener('click', async e => {
|
|
||||||
const btn = e.target.closest('button[data-action]');
|
|
||||||
if (!btn) return;
|
|
||||||
const action = btn.dataset.action;
|
|
||||||
const dogId = parseInt(btn.dataset.dogId, 10);
|
|
||||||
const dog = _dogs.find(d => d.id === dogId);
|
|
||||||
|
|
||||||
if (action === 'edit') {
|
|
||||||
_showListingModal(dog, listings[dogId], async () => {
|
|
||||||
await _loadListings();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (action === 'deactivate') {
|
|
||||||
if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return;
|
|
||||||
try {
|
|
||||||
await API.del(`/playdate/listing/${dogId}`);
|
|
||||||
UI.toast.success('Inserat deaktiviert.');
|
|
||||||
await _loadListings();
|
|
||||||
} catch (err) {
|
|
||||||
UI.toast.error(err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _listingCard(dog, listing) {
|
|
||||||
const isAktiv = listing && listing.aktiv;
|
|
||||||
return `
|
|
||||||
<div class="card" style="padding:var(--space-4)">
|
|
||||||
<div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
|
|
||||||
${_dogAvatar(dog.foto_url, dog.name, 44)}
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
|
|
||||||
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
<span style="font-size:var(--text-xs);font-weight:600;
|
|
||||||
padding:2px 10px;border-radius:999px;
|
|
||||||
background:${isAktiv ? 'var(--c-success-subtle,#d1fae5)' : 'var(--c-surface-2)'};
|
|
||||||
color:${isAktiv ? 'var(--c-success,#10b981)' : 'var(--c-text-muted)'}">
|
|
||||||
${isAktiv ? 'Aktiv' : 'Inaktiv'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${isAktiv ? `
|
|
||||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
|
|
||||||
${UI.icon('map-pin')}
|
|
||||||
${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
|
|
||||||
Radius: ${listing.radius_km} km
|
|
||||||
</div>
|
|
||||||
${listing.beschreibung ? `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
|
||||||
margin:0 0 var(--space-3);line-height:1.5">${_esc(listing.beschreibung)}</p>` : ''}
|
|
||||||
` : `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0 0 var(--space-3)">
|
|
||||||
Noch kein Inserat — trage dich ein, damit andere dich finden können.
|
|
||||||
</p>
|
|
||||||
`}
|
|
||||||
|
|
||||||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
|
||||||
<button class="btn btn-primary btn-sm"
|
|
||||||
data-action="edit" data-dog-id="${dog.id}">
|
|
||||||
${UI.icon('pencil')} ${isAktiv ? 'Bearbeiten' : 'Inserat anlegen'}
|
|
||||||
</button>
|
|
||||||
${isAktiv ? `
|
|
||||||
<button class="btn btn-ghost btn-sm"
|
|
||||||
data-action="deactivate" data-dog-id="${dog.id}">
|
|
||||||
${UI.icon('x')} Deaktivieren
|
|
||||||
</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showListingModal(dog, existing, onSaved) {
|
|
||||||
const formId = 'listing-form';
|
|
||||||
UI.modal.open({
|
|
||||||
title: `Inserat für ${dog.name}`,
|
|
||||||
body: `
|
|
||||||
<form id="${formId}">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Ort / Standort</label>
|
|
||||||
<div style="display:flex;gap:var(--space-2)">
|
|
||||||
<input type="text" id="listing-ort" class="form-control"
|
|
||||||
placeholder="z.B. München"
|
|
||||||
value="${_esc(existing?.ort_name || '')}">
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
|
|
||||||
title="GPS-Standort ermitteln">
|
|
||||||
${UI.icon('crosshair')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="listing-lat" value="${existing?.lat || ''}">
|
|
||||||
<input type="hidden" id="listing-lon" value="${existing?.lon || ''}">
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
|
||||||
Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln.
|
|
||||||
Nur der Ortsname wird für andere sichtbar — nicht dein genauer Standort.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Suchradius</label>
|
|
||||||
<select id="listing-radius" class="form-control">
|
|
||||||
<option value="5" ${(existing?.radius_km||10)===5 ? 'selected' : ''}>5 km</option>
|
|
||||||
<option value="10" ${(existing?.radius_km||10)===10 ? 'selected' : ''}>10 km</option>
|
|
||||||
<option value="25" ${(existing?.radius_km||10)===25 ? 'selected' : ''}>25 km</option>
|
|
||||||
<option value="50" ${(existing?.radius_km||10)===50 ? 'selected' : ''}>50 km</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Beschreibung (optional)</label>
|
|
||||||
<textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400"
|
|
||||||
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${_esc(existing?.beschreibung || '')}</textarea>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
footer: `
|
|
||||||
<button class="btn btn-secondary" id="listing-cancel-btn">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="listing-save-btn">
|
|
||||||
${UI.icon('floppy-disk')} Speichern
|
|
||||||
</button>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// GPS-Button
|
|
||||||
document.getElementById('listing-gps-btn').addEventListener('click', async () => {
|
|
||||||
const gpsBtn = document.getElementById('listing-gps-btn');
|
|
||||||
UI.setLoading(gpsBtn, true);
|
|
||||||
try {
|
|
||||||
const pos = await API.getLocation();
|
|
||||||
document.getElementById('listing-lat').value = pos.lat;
|
|
||||||
document.getElementById('listing-lon').value = pos.lon;
|
|
||||||
|
|
||||||
// Reverse-Geocoding für Ortsname
|
|
||||||
try {
|
|
||||||
const rev = await fetch(
|
|
||||||
`https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`,
|
|
||||||
{ cache: 'no-store' }
|
|
||||||
);
|
|
||||||
const geoData = await rev.json();
|
|
||||||
const a = geoData.address || {};
|
|
||||||
const ort = a.city || a.town || a.village || a.municipality || '';
|
|
||||||
if (ort) document.getElementById('listing-ort').value = ort;
|
|
||||||
} catch {}
|
|
||||||
UI.toast.success('Standort ermittelt.');
|
|
||||||
} catch {
|
|
||||||
UI.toast.error('Standort konnte nicht ermittelt werden.');
|
|
||||||
} finally {
|
|
||||||
UI.setLoading(gpsBtn, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close());
|
|
||||||
|
|
||||||
document.getElementById('listing-save-btn').addEventListener('click', async () => {
|
|
||||||
const btn = document.getElementById('listing-save-btn');
|
|
||||||
const lat = parseFloat(document.getElementById('listing-lat').value);
|
|
||||||
const lon = parseFloat(document.getElementById('listing-lon').value);
|
|
||||||
const ort = document.getElementById('listing-ort').value.trim();
|
|
||||||
const rad = parseInt(document.getElementById('listing-radius').value, 10);
|
|
||||||
const desc = document.getElementById('listing-beschreibung').value.trim();
|
|
||||||
|
|
||||||
if (!lat || !lon) {
|
|
||||||
UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
await API.put('/playdate/listing', {
|
|
||||||
dog_id: dog.id,
|
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
ort_name: ort || null,
|
|
||||||
radius_km: rad,
|
|
||||||
beschreibung: desc || null,
|
|
||||||
});
|
|
||||||
UI.modal.close();
|
|
||||||
UI.toast.success('Inserat gespeichert!');
|
|
||||||
onSaved?.();
|
|
||||||
}, { errorMsg: null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// TAB: ANFRAGEN
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
async function _renderRequests(el) {
|
|
||||||
el.innerHTML = `<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-6)">${UI.icon('spinner')} Lädt…</p>`;
|
|
||||||
try {
|
|
||||||
const data = await API.get('/playdate/requests');
|
|
||||||
const incoming = data.incoming || [];
|
|
||||||
const outgoing = data.outgoing || [];
|
|
||||||
|
|
||||||
// Badge aktualisieren
|
|
||||||
const pendingCount = incoming.filter(r => r.status === 'pending').length;
|
|
||||||
const badge = document.getElementById('playdate-req-badge');
|
|
||||||
if (badge) {
|
|
||||||
badge.textContent = pendingCount;
|
|
||||||
badge.style.display = pendingCount > 0 ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.length === 0 && outgoing.length === 0) {
|
|
||||||
el.innerHTML = UI.emptyState({
|
|
||||||
icon: UI.icon('paw-print'),
|
|
||||||
title: 'Noch keine Anfragen',
|
|
||||||
text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
|
||||||
|
|
||||||
${incoming.length > 0 ? `
|
|
||||||
<div>
|
|
||||||
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
|
||||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
|
|
||||||
margin:0 0 var(--space-3)">Eingehende Anfragen</h3>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
|
||||||
${incoming.map(r => _incomingCard(r)).join('')}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
${outgoing.length > 0 ? `
|
|
||||||
<div>
|
|
||||||
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
|
||||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
|
|
||||||
margin:0 0 var(--space-3)">Ausgehende Anfragen</h3>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
|
|
||||||
${outgoing.map(r => _outgoingCard(r)).join('')}
|
|
||||||
</div>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Button-Events (Accept/Decline)
|
|
||||||
el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const reqId = parseInt(btn.dataset.reqId, 10);
|
|
||||||
const status = btn.dataset.status;
|
|
||||||
await UI.asyncButton(btn, async () => {
|
|
||||||
const result = await API.patch(`/playdate/requests/${reqId}`, { status });
|
|
||||||
if (status === 'accepted' && result.conversation_id) {
|
|
||||||
UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.');
|
|
||||||
setTimeout(() => {
|
|
||||||
App.navigate('chat', true, { conversation_id: result.conversation_id });
|
|
||||||
}, 800);
|
|
||||||
} else {
|
|
||||||
UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.');
|
|
||||||
}
|
|
||||||
await _renderRequests(el);
|
|
||||||
}, { errorMsg: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Chat-Buttons
|
|
||||||
el.querySelectorAll('.req-chat-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
App.navigate('chat', true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
el.innerHTML = `<p style="text-align:center;color:var(--c-danger);padding:var(--space-6)">${err.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _incomingCard(r) {
|
|
||||||
const isPending = r.status === 'pending';
|
|
||||||
return `
|
|
||||||
<div class="card" style="padding:var(--space-4)">
|
|
||||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
|
|
||||||
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
|
||||||
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
|
|
||||||
${r.alter ? _esc(r.alter) + ' · ' : ''}
|
|
||||||
von ${_esc(r.from_user_name)}
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
${_statusBadge(r.status)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${r.nachricht ? `
|
|
||||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
|
||||||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
|
||||||
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
|
|
||||||
line-height:1.5">
|
|
||||||
"${_esc(r.nachricht)}"
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
${isPending ? `
|
|
||||||
<div style="display:flex;gap:var(--space-2)">
|
|
||||||
<button class="btn btn-primary btn-sm req-accept-btn"
|
|
||||||
data-req-id="${r.id}" data-status="accepted">
|
|
||||||
${UI.icon('check')} Annehmen
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-sm req-decline-btn"
|
|
||||||
data-req-id="${r.id}" data-status="declined">
|
|
||||||
${UI.icon('x')} Ablehnen
|
|
||||||
</button>
|
|
||||||
</div>` : `
|
|
||||||
${r.status === 'accepted' ? `
|
|
||||||
<button class="btn btn-ghost btn-sm req-chat-btn">
|
|
||||||
${UI.icon('chat-circle-dots')} Zum Chat
|
|
||||||
</button>` : ''}
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _outgoingCard(r) {
|
|
||||||
return `
|
|
||||||
<div class="card" style="padding:var(--space-4)">
|
|
||||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
|
|
||||||
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
|
||||||
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
|
|
||||||
von ${_esc(r.to_user_name)}
|
|
||||||
</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
${_statusBadge(r.status)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${r.nachricht ? `
|
|
||||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
|
|
||||||
"${_esc(r.nachricht)}"
|
|
||||||
</p>` : ''}
|
|
||||||
|
|
||||||
${r.status === 'accepted' ? `
|
|
||||||
<button class="btn btn-ghost btn-sm req-chat-btn">
|
|
||||||
${UI.icon('chat-circle-dots')} Chat öffnen
|
|
||||||
</button>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
return { init, refresh, onDogChange };
|
|
||||||
})();
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
/* ============================================================
|
|
||||||
BAN YARO — Tierfutter-Rückrufe
|
|
||||||
Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
window.Page_recalls = (() => {
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// MODUL-STATE
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
let _container = null;
|
|
||||||
let _appState = null;
|
|
||||||
let _recalls = [];
|
|
||||||
let _query = '';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// INIT
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function init(container, appState) {
|
|
||||||
_container = container;
|
|
||||||
_appState = appState;
|
|
||||||
_query = '';
|
|
||||||
await _render();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// REFRESH
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function refresh() {
|
|
||||||
_recalls = [];
|
|
||||||
_query = '';
|
|
||||||
await _render();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// RENDER
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _render() {
|
|
||||||
_container.innerHTML = `
|
|
||||||
<!-- Warnbanner -->
|
|
||||||
<div class="recalls-warning-banner">
|
|
||||||
<svg class="ph-icon recalls-warning-icon" aria-hidden="true">
|
|
||||||
<use href="/icons/phosphor.svg#warning"></use>
|
|
||||||
</svg>
|
|
||||||
<p class="recalls-warning-text">
|
|
||||||
<strong>Hinweis:</strong> Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer
|
|
||||||
bevor du ein gemeldetes Produkt entsorgst oder zurückgibst.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Suchfeld -->
|
|
||||||
<div style="position:relative;margin-bottom:var(--space-4)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"
|
|
||||||
style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
|
|
||||||
color:var(--c-text-muted);pointer-events:none">
|
|
||||||
<use href="/icons/phosphor.svg#magnifying-glass"></use>
|
|
||||||
</svg>
|
|
||||||
<input type="search" id="recalls-search" placeholder="Produkt, Gefahr oder Herkunft suchen…"
|
|
||||||
value="${UI.escape(_query)}"
|
|
||||||
style="width:100%;padding:var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3)*2 + 1.2rem);
|
|
||||||
border:1px solid var(--c-border);border-radius:var(--radius-md);
|
|
||||||
font-size:var(--text-sm);background:var(--c-surface);color:var(--c-text);
|
|
||||||
box-sizing:border-box">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ergebnis-Liste -->
|
|
||||||
<div id="recalls-list">${UI.skeleton(4)}</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Suchfeld-Handler
|
|
||||||
_container.querySelector('#recalls-search').addEventListener('input', (e) => {
|
|
||||||
_query = e.target.value.trim();
|
|
||||||
_renderList();
|
|
||||||
});
|
|
||||||
|
|
||||||
await _loadRecalls();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// DATEN LADEN
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _loadRecalls() {
|
|
||||||
try {
|
|
||||||
const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls';
|
|
||||||
_recalls = await API.get(url);
|
|
||||||
} catch {
|
|
||||||
_container.querySelector('#recalls-list').innerHTML = UI.emptyState({
|
|
||||||
icon: 'warning-circle',
|
|
||||||
title: 'Rückrufe konnten nicht geladen werden',
|
|
||||||
text: 'Bitte versuche es später erneut.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_renderList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// LISTE RENDERN
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _renderList() {
|
|
||||||
const listEl = _container.querySelector('#recalls-list');
|
|
||||||
if (!listEl) return;
|
|
||||||
|
|
||||||
const filtered = _query
|
|
||||||
? _recalls.filter(r => {
|
|
||||||
const q = _query.toLowerCase();
|
|
||||||
return (r.titel || '').toLowerCase().includes(q)
|
|
||||||
|| (r.produkt || '').toLowerCase().includes(q)
|
|
||||||
|| (r.gefahr || '').toLowerCase().includes(q)
|
|
||||||
|| (r.herkunft || '').toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
: _recalls;
|
|
||||||
|
|
||||||
if (!filtered.length) {
|
|
||||||
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
||||||
listEl.innerHTML = UI.emptyState({
|
|
||||||
icon: UI.icon('check-circle'),
|
|
||||||
title: 'Aktuell keine Rückrufe',
|
|
||||||
text: `Letzte Prüfung: ${today}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.innerHTML = filtered.map(r => _cardHtml(r)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// EINZELNE KARTE
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _cardHtml(r) {
|
|
||||||
const datum = r.datum
|
|
||||||
? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const meta = [
|
|
||||||
r.herkunft ? `<span>${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}</span>` : '',
|
|
||||||
datum ? `<span>${UI.icon('calendar-blank')} ${datum}</span>` : '',
|
|
||||||
r.quelle ? `<span style="text-transform:uppercase;font-size:var(--text-xs);color:var(--c-text-muted)">${UI.escape(r.quelle)}</span>` : '',
|
|
||||||
].filter(Boolean).join('<span style="color:var(--c-border)"> · </span>');
|
|
||||||
|
|
||||||
const linkHtml = r.url
|
|
||||||
? `<a href="${UI.escape(r.url)}" target="_blank" rel="noopener"
|
|
||||||
style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-sm);
|
|
||||||
color:var(--c-primary);text-decoration:none;margin-top:var(--space-1)">
|
|
||||||
${UI.icon('arrow-square-out')} Details auf RASFF
|
|
||||||
</a>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="background:var(--c-surface);border:1px solid var(--c-border);
|
|
||||||
border-left:4px solid #dc2626;border-radius:var(--radius-md);
|
|
||||||
padding:var(--space-3) var(--space-4);margin-bottom:var(--space-3)">
|
|
||||||
<!-- Titel -->
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:var(--space-2);margin-bottom:var(--space-1)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true" style="color:#dc2626;flex-shrink:0;margin-top:2px">
|
|
||||||
<use href="/icons/phosphor.svg#warning-octagon"></use>
|
|
||||||
</svg>
|
|
||||||
<strong style="font-size:var(--text-base);color:var(--c-text);line-height:1.4">
|
|
||||||
${UI.escape(r.produkt || r.titel)}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gefahr -->
|
|
||||||
${r.gefahr ? `
|
|
||||||
<p style="margin:0 0 var(--space-2) 0;font-size:var(--text-sm);color:var(--c-text-muted);
|
|
||||||
padding-left:calc(var(--space-2) + 1.2rem)">
|
|
||||||
${UI.escape(r.gefahr)}
|
|
||||||
</p>` : ''}
|
|
||||||
|
|
||||||
<!-- Meta-Zeile -->
|
|
||||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2);
|
|
||||||
font-size:var(--text-sm);color:var(--c-text-muted);
|
|
||||||
padding-left:calc(var(--space-2) + 1.2rem)">
|
|
||||||
${meta}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Link -->
|
|
||||||
${linkHtml ? `<div style="padding-left:calc(var(--space-2) + 1.2rem)">${linkHtml}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// PUBLIC API
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
return { init, refresh };
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -1747,16 +1747,6 @@ window.Page_uebungen = (() => {
|
||||||
_closeModal();
|
_closeModal();
|
||||||
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
|
if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; }
|
||||||
|
|
||||||
// Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord
|
|
||||||
API.post(`/streak/${body.dog_id}/ping`).then(streak => {
|
|
||||||
if (!streak) return;
|
|
||||||
if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) {
|
|
||||||
setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500);
|
|
||||||
} else if (streak.current_streak > 1) {
|
|
||||||
setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500);
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
if (resp.ist_top) {
|
if (resp.ist_top) {
|
||||||
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
|
UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -463,8 +463,6 @@ window.Page_welcome = (() => {
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${dog?.id ? `<div id="wc-streak-widget"></div>` : ''}
|
|
||||||
|
|
||||||
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
|
<h2 class="wc-section-title" style="margin-top:var(--space-6)">Mehr entdecken</h2>
|
||||||
<div class="wc-grid">
|
<div class="wc-grid">
|
||||||
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
|
${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => `
|
||||||
|
|
@ -499,85 +497,9 @@ window.Page_welcome = (() => {
|
||||||
_updateChipsFromDash(dash);
|
_updateChipsFromDash(dash);
|
||||||
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
|
_tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen
|
||||||
}).catch(() => { /* Skeleton bleibt sichtbar */ });
|
}).catch(() => { /* Skeleton bleibt sichtbar */ });
|
||||||
|
|
||||||
// Streak-Widget asynchron laden
|
|
||||||
_loadStreakWidget(dog.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// STREAK-WIDGET
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _loadStreakWidget(dogId) {
|
|
||||||
const slot = _container.querySelector('#wc-streak-widget');
|
|
||||||
if (!slot) return;
|
|
||||||
|
|
||||||
let streak;
|
|
||||||
try {
|
|
||||||
streak = await API.get(`/streak/${dogId}`);
|
|
||||||
} catch { return; }
|
|
||||||
|
|
||||||
if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return;
|
|
||||||
|
|
||||||
slot.innerHTML = _streakWidgetHTML(streak);
|
|
||||||
|
|
||||||
slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => {
|
|
||||||
const modalEl = UI.modal.open({
|
|
||||||
title: '🔥 Trainings-Bestenliste',
|
|
||||||
body: '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Wird geladen…</p>',
|
|
||||||
});
|
|
||||||
let board;
|
|
||||||
try { board = await API.get('/streak/leaderboard'); } catch { board = []; }
|
|
||||||
const bodyEl = modalEl?.querySelector('.modal-body');
|
|
||||||
if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _streakWidgetHTML(s) {
|
|
||||||
const cur = s.current_streak || 0;
|
|
||||||
const best = s.longest_streak || 0;
|
|
||||||
return `
|
|
||||||
<div class="wc-streak-card">
|
|
||||||
<div class="wc-streak-flame-wrap">
|
|
||||||
<span class="wc-streak-flame">🔥</span>
|
|
||||||
<span class="wc-streak-number">${cur}</span>
|
|
||||||
</div>
|
|
||||||
<div class="wc-streak-info">
|
|
||||||
<div class="wc-streak-label">Tage in Folge trainiert</div>
|
|
||||||
<div class="wc-streak-best">Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}</div>
|
|
||||||
</div>
|
|
||||||
<button class="wc-streak-lb-btn" id="wc-streak-leaderboard-btn" title="Bestenliste">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _leaderboardHTML(rows) {
|
|
||||||
if (!rows || !rows.length) {
|
|
||||||
return '<p style="text-align:center;color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Einträge.</p>';
|
|
||||||
}
|
|
||||||
const medals = ['🥇', '🥈', '🥉'];
|
|
||||||
return `
|
|
||||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
|
||||||
${rows.map((r, i) => `
|
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-subtle)">
|
|
||||||
<span style="font-size:1.4rem;width:28px;text-align:center;flex-shrink:0">${medals[i] || (i + 1) + '.'}</span>
|
|
||||||
${r.foto_url
|
|
||||||
? `<img src="${UI.escape(r.foto_url)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;flex-shrink:0" alt="">`
|
|
||||||
: `<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary-subtle);flex-shrink:0"></div>`}
|
|
||||||
<div style="flex:1;min-width:0">
|
|
||||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${UI.escape(r.dog_name)}</div>
|
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
|
||||||
<span style="font-size:1.1rem">🔥</span>
|
|
||||||
<span style="font-weight:var(--weight-bold);font-size:var(--text-base);color:var(--c-primary)">${r.current_streak}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _updateHeroFromDash(dash, dog) {
|
function _updateHeroFromDash(dash, dog) {
|
||||||
const heroBox = _container.querySelector('#wc-hero-box');
|
const heroBox = _container.querySelector('#wc-hero-box');
|
||||||
if (!heroBox) return;
|
if (!heroBox) return;
|
||||||
|
|
|
||||||
|
|
@ -255,15 +255,6 @@ window.Page_wiki = (() => {
|
||||||
<option value="">Alle Gruppen</option>
|
<option value="">Alle Gruppen</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 0 var(--space-3)">
|
|
||||||
<button class="btn btn-secondary w-full" id="wiki-rasse-erkennen-btn"
|
|
||||||
style="font-size:var(--text-sm)">
|
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
|
||||||
Welche Rasse ist das? — Foto analysieren
|
|
||||||
</button>
|
|
||||||
<input type="file" accept="image/jpeg,image/png,image/webp"
|
|
||||||
id="wiki-rasse-foto-input" style="display:none">
|
|
||||||
</div>
|
|
||||||
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
|
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
|
||||||
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
|
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
|
||||||
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
|
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
|
||||||
|
|
@ -273,9 +264,6 @@ window.Page_wiki = (() => {
|
||||||
// Load initial batch (also populates gruppen)
|
// Load initial batch (also populates gruppen)
|
||||||
await _loadBreeds(el, true);
|
await _loadBreeds(el, true);
|
||||||
|
|
||||||
// Rassen-Erkennung per KI
|
|
||||||
_bindWikiRasseErkennung(el);
|
|
||||||
|
|
||||||
// Search handler with debounce
|
// Search handler with debounce
|
||||||
let _searchTimer;
|
let _searchTimer;
|
||||||
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
|
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
|
||||||
|
|
@ -1277,130 +1265,6 @@ window.Page_wiki = (() => {
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// RASSEN-ERKENNUNG PER KI (Wiki-Tab)
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
function _bindWikiRasseErkennung(el) {
|
|
||||||
const btn = el.querySelector('#wiki-rasse-erkennen-btn');
|
|
||||||
const fileInput = el.querySelector('#wiki-rasse-foto-input');
|
|
||||||
if (!btn || !fileInput) return;
|
|
||||||
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
if (!_appState.user) {
|
|
||||||
UI.toast('Bitte melde dich an, um diese Funktion zu nutzen.', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fileInput.value = '';
|
|
||||||
fileInput.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', async () => {
|
|
||||||
const file = fileInput.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
UI.toast('Bild zu groß (max. 5 MB).', 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const origHtml = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#spinner"></use></svg> KI analysiert das Bild…`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
const token = localStorage.getItem('by_token');
|
|
||||||
const resp = await fetch('/api/ki/rasse-erkennung', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
|
||||||
body: fd,
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (!resp.ok) throw new Error(data.detail || 'Fehler bei der Erkennung.');
|
|
||||||
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = origHtml;
|
|
||||||
_showWikiRasseErgebnis(data);
|
|
||||||
} catch (e) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = origHtml;
|
|
||||||
UI.toast(e.message || 'Fehler bei der Rassen-Erkennung.', 'danger');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _showWikiRasseErgebnis(data) {
|
|
||||||
if (!data.ist_hund) {
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Kein Hund erkannt',
|
|
||||||
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
|
|
||||||
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
|
|
||||||
<p style="color:var(--c-text-secondary)">
|
|
||||||
Auf diesem Foto konnte kein Hund erkannt werden.<br>
|
|
||||||
Bitte lade ein deutlicheres Foto hoch.
|
|
||||||
</p>
|
|
||||||
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
|
|
||||||
</div>`,
|
|
||||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rassen = data.rassen || [];
|
|
||||||
const cardsHtml = rassen.map((r, i) => {
|
|
||||||
const isTop = i === 0;
|
|
||||||
return `
|
|
||||||
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
|
||||||
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
|
|
||||||
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="rasse-result-bar-wrap">
|
|
||||||
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
|
|
||||||
style="width:${r.sicherheit}%"></div>
|
|
||||||
</div>
|
|
||||||
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
|
|
||||||
${r.wiki_slug ? `
|
|
||||||
<div style="margin-top:var(--space-3)">
|
|
||||||
<button class="btn btn-${isTop ? 'primary' : 'secondary'} btn-sm w-full"
|
|
||||||
data-action="wiki" data-slug="${_esc(r.wiki_slug)}">
|
|
||||||
Im Wiki nachschlagen
|
|
||||||
</button>
|
|
||||||
</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
UI.modal.open({
|
|
||||||
title: 'Erkannte Rasse',
|
|
||||||
body: `
|
|
||||||
<div style="padding-bottom:var(--space-2)">
|
|
||||||
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
|
||||||
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
|
|
||||||
color:var(--c-text-secondary)">ℹ️ ${_esc(data.hinweis)}</div>` : ''}
|
|
||||||
${cardsHtml}
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
|
|
||||||
text-align:center">
|
|
||||||
Noch ${data.verbleibende_anfragen} Erkennung${data.verbleibende_anfragen !== 1 ? 'en' : ''} heute verfügbar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
footer: `<button class="btn btn-secondary" id="wiki-rasse-modal-schliessen">Schließen</button>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('wiki-rasse-modal-schliessen')
|
|
||||||
?.addEventListener('click', UI.modal.close);
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-action="wiki"]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
UI.modal.close();
|
|
||||||
setTimeout(() => _openBreedDetail(btn.dataset.slug), 300);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// PUBLIC
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v607';
|
const CACHE_VERSION = 'by-v597';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@ services:
|
||||||
- "3012:8000"
|
- "3012:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- /volume1/docker/banyaro/data/media:/prod-media:ro
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/banyaro.db
|
- DB_PATH=/data/banyaro.db
|
||||||
- MEDIA_DIR=/prod-media
|
- MEDIA_DIR=/data/media
|
||||||
- STAGING=true
|
- STAGING=true
|
||||||
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
||||||
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
|
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue