Compare commits
4 commits
bda61a0e40
...
0ab88ef6b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ab88ef6b6 | |||
| b818f85f36 | |||
| 83034c0db0 | |||
| b12467286c |
14 changed files with 642 additions and 40 deletions
|
|
@ -580,6 +580,8 @@ def _migrate(conn_factory):
|
|||
("users", "password_reset_expires", "TEXT"),
|
||||
# Fell-Typ für personalisierte Wetter-Hinweise
|
||||
("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt
|
||||
# Widerristhöhe in cm (höchster Punkt Schulterblatt → Boden)
|
||||
("dogs", "widerrist_cm", "REAL"),
|
||||
# Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz
|
||||
("tieraerzte", "avg_rating", "REAL DEFAULT 0"),
|
||||
("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"),
|
||||
|
|
@ -2208,6 +2210,45 @@ def _migrate(conn_factory):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Versicherungs-Verwaltung
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dog_insurance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
anbieter TEXT NOT NULL,
|
||||
police_nr TEXT,
|
||||
jahresbeitrag REAL,
|
||||
kontakt TEXT,
|
||||
ablaufdatum TEXT,
|
||||
notizen TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
logger.info("Migration: dog_insurance bereit.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration dog_insurance: {e}")
|
||||
|
||||
# Verhaltens-Protokoll
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS behavior_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
datum TEXT NOT NULL,
|
||||
uhrzeit TEXT,
|
||||
kategorie TEXT NOT NULL,
|
||||
intensitaet INTEGER NOT NULL DEFAULT 3,
|
||||
trigger TEXT,
|
||||
notiz TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_behavior_dog ON behavior_log(dog_id, datum DESC)")
|
||||
logger.info("Migration: behavior_log bereit.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Migration behavior_log: {e}")
|
||||
|
||||
# route_dogs: bestehende Routen allen Hunden des Users zuweisen
|
||||
try:
|
||||
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]
|
||||
|
|
|
|||
|
|
@ -376,7 +376,7 @@ if STAGING and os.path.isdir(PROD_MEDIA_DIR):
|
|||
else:
|
||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||
|
||||
APP_VER = "872" # muss mit APP_VER in app.js übereinstimmen
|
||||
APP_VER = "875" # muss mit APP_VER in app.js übereinstimmen
|
||||
|
||||
@app.get("/.well-known/assetlinks.json")
|
||||
async def assetlinks():
|
||||
|
|
@ -1407,6 +1407,7 @@ async def ausweis_page(dog_id: int, request: Request):
|
|||
<div class="meta-item"><div class="label">Geschlecht</div><div class="value">{geschlecht}</div></div>
|
||||
<div class="meta-item"><div class="label">Gewicht</div><div class="value">{f'{dog["gewicht_kg"]} kg' if dog.get("gewicht_kg") else "–"}</div></div>
|
||||
<div class="meta-item"><div class="label">Transponder</div><div class="value">{esc(dog.get("chip_nr")) or "–"}</div></div>
|
||||
{f'<div class="meta-item"><div class="label">Widerrist</div><div class="value">{dog["widerrist_cm"]} cm</div></div>' if dog.get("widerrist_cm") else ''}
|
||||
<div class="meta-item"><div class="label">Besitzer</div><div class="value">{esc(owner["name"]) if owner else "–"}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1414,7 +1415,7 @@ async def ausweis_page(dog_id: int, request: Request):
|
|||
|
||||
<div class="body">
|
||||
<div class="no-print" style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px">
|
||||
<button onclick="window.history.length>1?window.history.back():window.close()"
|
||||
<button onclick="window.history.length>1?window.history.back():(window.location.href='/')"
|
||||
style="background:#f0e6d3;color:#7a4a1e;border:none;border-radius:100px;
|
||||
padding:10px 20px;font-size:0.9rem;cursor:pointer;font-weight:600">
|
||||
← Zurück zur App
|
||||
|
|
|
|||
|
|
@ -15,26 +15,28 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
|||
|
||||
|
||||
class DogCreate(BaseModel):
|
||||
name: str
|
||||
rasse: Optional[str] = None
|
||||
geburtstag: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
gewicht_kg: Optional[float] = None
|
||||
chip_nr: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
is_public: bool = False
|
||||
name: str
|
||||
rasse: Optional[str] = None
|
||||
geburtstag: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
gewicht_kg: Optional[float] = None
|
||||
widerrist_cm: Optional[float] = None
|
||||
chip_nr: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
is_public: bool = False
|
||||
|
||||
|
||||
class DogUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
rasse: Optional[str] = None
|
||||
rasse_id: Optional[int] = None
|
||||
geburtstag: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
gewicht_kg: Optional[float] = None
|
||||
chip_nr: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
name: Optional[str] = None
|
||||
rasse: Optional[str] = None
|
||||
rasse_id: Optional[int] = None
|
||||
geburtstag: Optional[str] = None
|
||||
geschlecht: Optional[str] = None
|
||||
gewicht_kg: Optional[float] = None
|
||||
widerrist_cm: Optional[float] = None
|
||||
chip_nr: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -141,11 +143,11 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)):
|
|||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
|
||||
gewicht_kg, chip_nr, bio, is_public)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
gewicht_kg, widerrist_cm, chip_nr, bio, is_public)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||||
(user["id"], data.name, data.rasse, data.geburtstag,
|
||||
data.geschlecht, data.gewicht_kg, data.chip_nr,
|
||||
data.bio, int(data.is_public))
|
||||
data.geschlecht, data.gewicht_kg, data.widerrist_cm,
|
||||
data.chip_nr, data.bio, int(data.is_public))
|
||||
)
|
||||
dog = conn.execute(
|
||||
"SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||
|
|
|
|||
|
|
@ -569,3 +569,190 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
|
|||
})
|
||||
|
||||
return vorschlaege
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# VERSICHERUNGS-VERWALTUNG
|
||||
# ==================================================================
|
||||
|
||||
class InsuranceCreate(BaseModel):
|
||||
anbieter: str
|
||||
police_nr: Optional[str] = None
|
||||
jahresbeitrag: Optional[float] = None
|
||||
kontakt: Optional[str] = None
|
||||
ablaufdatum: Optional[str] = None
|
||||
notizen: Optional[str] = None
|
||||
|
||||
class InsuranceUpdate(BaseModel):
|
||||
anbieter: Optional[str] = None
|
||||
police_nr: Optional[str] = None
|
||||
jahresbeitrag: Optional[float] = None
|
||||
kontakt: Optional[str] = None
|
||||
ablaufdatum: Optional[str] = None
|
||||
notizen: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{dog_id}/insurance")
|
||||
async def get_insurance(dog_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone() or (_ for _ in ()).throw(HTTPException(404))
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM dog_insurance WHERE dog_id=? ORDER BY created_at DESC", (dog_id,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{dog_id}/insurance", status_code=201)
|
||||
async def create_insurance(dog_id: int, data: InsuranceCreate, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO dog_insurance (dog_id, anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(dog_id, data.anbieter, data.police_nr, data.jahresbeitrag, data.kontakt, data.ablaufdatum, data.notizen)
|
||||
)
|
||||
row = conn.execute("SELECT * FROM dog_insurance WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.patch("/{dog_id}/insurance/{ins_id}")
|
||||
async def update_insurance(dog_id: int, ins_id: int, data: InsuranceUpdate, user=Depends(get_current_user)):
|
||||
fields = {k: v for k, v in data.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(400, "Keine Änderungen.")
|
||||
with db() as conn:
|
||||
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404)
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
conn.execute(f"UPDATE dog_insurance SET {set_clause} WHERE id=? AND dog_id=?",
|
||||
list(fields.values()) + [ins_id, dog_id])
|
||||
row = conn.execute("SELECT * FROM dog_insurance WHERE id=?", (ins_id,)).fetchone()
|
||||
return dict(row) if row else {}
|
||||
|
||||
|
||||
@router.delete("/{dog_id}/insurance/{ins_id}", status_code=204)
|
||||
async def delete_insurance(dog_id: int, ins_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404)
|
||||
conn.execute("DELETE FROM dog_insurance WHERE id=? AND dog_id=?", (ins_id, dog_id))
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# VERHALTENS-PROTOKOLL
|
||||
# ==================================================================
|
||||
|
||||
BEHAVIOR_KATEGORIEN = {
|
||||
"angst": {"label": "Angst / Panik", "icon": "smiley-nervous"},
|
||||
"aggression": {"label": "Aggression", "icon": "warning"},
|
||||
"ueberreaktion":{"label": "Überreaktion", "icon": "lightning"},
|
||||
"ressource": {"label": "Ressourcenverteidigung", "icon": "lock"},
|
||||
"separation": {"label": "Trennungsangst", "icon": "house"},
|
||||
"leine": {"label": "Leinenprobleme", "icon": "path"},
|
||||
"sozial": {"label": "Sozialkompetenz", "icon": "users"},
|
||||
"sonstiges": {"label": "Sonstiges", "icon": "note"},
|
||||
}
|
||||
|
||||
BEHAVIOR_TRIGGER = [
|
||||
"fremde_hunde", "fremde_menschen", "kinder", "laerm_feuerwerk",
|
||||
"laerm_gewitter", "auto_fahrrad", "tierarzt", "allein_zuhause",
|
||||
"andere_tiere", "besucher_zuhause", "sonstiges"
|
||||
]
|
||||
|
||||
TRIGGER_LABELS = {
|
||||
"fremde_hunde": "Fremde Hunde", "fremde_menschen": "Fremde Menschen",
|
||||
"kinder": "Kinder", "laerm_feuerwerk": "Feuerwerk/Knaller",
|
||||
"laerm_gewitter": "Gewitter", "auto_fahrrad": "Autos/Fahrräder",
|
||||
"tierarzt": "Tierarztbesuch", "allein_zuhause": "Allein zuhause",
|
||||
"andere_tiere": "Andere Tiere", "besucher_zuhause": "Besucher",
|
||||
"sonstiges": "Sonstiges"
|
||||
}
|
||||
|
||||
|
||||
class BehaviorCreate(BaseModel):
|
||||
datum: str
|
||||
uhrzeit: Optional[str] = None
|
||||
kategorie: str
|
||||
intensitaet: int = 3
|
||||
trigger: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{dog_id}/behavior")
|
||||
async def get_behavior(dog_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM behavior_log WHERE dog_id=? ORDER BY datum DESC, uhrzeit DESC LIMIT 100",
|
||||
(dog_id,)
|
||||
).fetchall()
|
||||
return {
|
||||
"entries": [dict(r) for r in rows],
|
||||
"kategorien": BEHAVIOR_KATEGORIEN,
|
||||
"trigger_labels": TRIGGER_LABELS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{dog_id}/behavior", status_code=201)
|
||||
async def create_behavior(dog_id: int, data: BehaviorCreate, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404)
|
||||
if data.kategorie not in BEHAVIOR_KATEGORIEN:
|
||||
raise HTTPException(400, "Unbekannte Kategorie.")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO behavior_log (dog_id, datum, uhrzeit, kategorie, intensitaet, trigger, notiz)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(dog_id, data.datum, data.uhrzeit, data.kategorie,
|
||||
max(1, min(5, data.intensitaet)), data.trigger, data.notiz)
|
||||
)
|
||||
row = conn.execute("SELECT * FROM behavior_log WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/{dog_id}/behavior/{entry_id}", status_code=204)
|
||||
async def delete_behavior(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404)
|
||||
conn.execute("DELETE FROM behavior_log WHERE id=? AND dog_id=?", (entry_id, dog_id))
|
||||
|
||||
|
||||
@router.get("/{dog_id}/reminders")
|
||||
async def get_upcoming_reminders(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Bevorstehende Erinnerungen der nächsten 30 Tage + überfällige."""
|
||||
from datetime import timedelta
|
||||
today = date.today()
|
||||
in30 = today + timedelta(days=30)
|
||||
with db() as conn:
|
||||
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404)
|
||||
rows = conn.execute(
|
||||
"""SELECT id, typ, bezeichnung, naechstes
|
||||
FROM health
|
||||
WHERE dog_id=? AND naechstes IS NOT NULL
|
||||
AND (erinnerung IS NULL OR erinnerung = 1)
|
||||
AND typ IN ('impfung','entwurmung','medikament')
|
||||
ORDER BY naechstes ASC""",
|
||||
(dog_id,)
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
try:
|
||||
d = date.fromisoformat(r["naechstes"])
|
||||
except Exception:
|
||||
continue
|
||||
delta = (d - today).days
|
||||
if delta < -30 or delta > 30:
|
||||
continue
|
||||
result.append({**dict(r), "delta_tage": delta, "ueberfaellig": delta < 0})
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -46,8 +46,9 @@ OSM_QUERIES = {
|
|||
'drinking_water': '[out:json][timeout:20];node["amenity"="drinking_water"]({bbox});out;',
|
||||
'tierarzt': '[out:json][timeout:25];(node["amenity"="veterinary"]({bbox});way["amenity"="veterinary"]({bbox}););out center;',
|
||||
'shop': '[out:json][timeout:25];(node["shop"="pet"]({bbox});way["shop"="pet"]({bbox}););out center;',
|
||||
'restaurant': '[out:json][timeout:35];(node["amenity"="restaurant"]({bbox});way["amenity"="restaurant"]({bbox});node["amenity"="cafe"]({bbox});way["amenity"="cafe"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;',
|
||||
'restaurant': '[out:json][timeout:35];(node["amenity"~"restaurant|cafe"]["dog"~"yes|allowed"]({bbox});way["amenity"~"restaurant|cafe"]["dog"~"yes|allowed"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;',
|
||||
'bank': '[out:json][timeout:20];node["amenity"="bench"]({bbox});out;',
|
||||
'hotel': '[out:json][timeout:25];(node["tourism"~"hotel|guest_house|hostel"]["dog"~"yes|allowed"]({bbox});way["tourism"~"hotel|guest_house|hostel"]["dog"~"yes|allowed"]({bbox}););out center;',
|
||||
}
|
||||
|
||||
# Ab dieser Anzahl Meldungen wird ein Marker ausgeblendet
|
||||
|
|
|
|||
|
|
@ -214,16 +214,20 @@ async def _job_health_reminders():
|
|||
|
||||
logger.info(f"Health-Reminder Job läuft für {today}")
|
||||
|
||||
in3 = today + timedelta(days=3)
|
||||
|
||||
with db() as conn:
|
||||
# Alle fälligen Einträge der nächsten 7 Tage + gestrige (überfällig)
|
||||
# erinnerung=0 → User hat diese Erinnerung deaktiviert
|
||||
rows = conn.execute("""
|
||||
SELECT h.id, h.typ, h.bezeichnung, h.naechstes,
|
||||
d.user_id, d.name AS hund_name
|
||||
FROM health h
|
||||
JOIN dogs d ON d.id = h.dog_id
|
||||
WHERE h.naechstes IN (?, ?, ?)
|
||||
WHERE h.naechstes IN (?, ?, ?, ?)
|
||||
AND h.typ IN ('impfung', 'entwurmung', 'medikament')
|
||||
""", (str(today), str(in7), str(yesterday))).fetchall()
|
||||
AND (h.erinnerung IS NULL OR h.erinnerung = 1)
|
||||
""", (str(today), str(in7), str(in3), str(yesterday))).fetchall()
|
||||
|
||||
sent_total = 0
|
||||
for r in rows:
|
||||
|
|
@ -233,6 +237,9 @@ async def _job_health_reminders():
|
|||
if delta == 7:
|
||||
title = f"⏰ Erinnerung: {r['bezeichnung']}"
|
||||
body = f"In 7 Tagen fällig für {r['hund_name']}."
|
||||
elif delta == 3:
|
||||
title = f"⏰ Erinnerung: {r['bezeichnung']}"
|
||||
body = f"In 3 Tagen fällig für {r['hund_name']} — bald vorbereiten."
|
||||
elif delta == 0:
|
||||
title = f"📅 Heute fällig: {r['bezeichnung']}"
|
||||
body = f"Bitte heute erledigen — {r['hund_name']} wartet."
|
||||
|
|
|
|||
|
|
@ -101,9 +101,9 @@
|
|||
</script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=872">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=872">
|
||||
<link rel="stylesheet" href="/css/components.css?v=872">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=875">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=875">
|
||||
<link rel="stylesheet" href="/css/components.css?v=875">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -583,10 +583,10 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=872"></script>
|
||||
<script src="/js/ui.js?v=872"></script>
|
||||
<script src="/js/app.js?v=872"></script>
|
||||
<script src="/js/worlds.js?v=872"></script>
|
||||
<script src="/js/api.js?v=875"></script>
|
||||
<script src="/js/ui.js?v=875"></script>
|
||||
<script src="/js/app.js?v=875"></script>
|
||||
<script src="/js/worlds.js?v=875"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,14 @@ const API = (() => {
|
|||
gewichtVerlauf(dogId) {
|
||||
return get(`/dogs/${dogId}/health/gewicht`);
|
||||
},
|
||||
reminders(dogId) { return get(`/dogs/${dogId}/reminders`); },
|
||||
insuranceList(dogId) { return get(`/dogs/${dogId}/insurance`); },
|
||||
insuranceCreate(dogId, d) { return post(`/dogs/${dogId}/insurance`, d); },
|
||||
insuranceUpdate(dogId, id, d) { return patch(`/dogs/${dogId}/insurance/${id}`, d); },
|
||||
insuranceDelete(dogId, id) { return del(`/dogs/${dogId}/insurance/${id}`); },
|
||||
behaviorList(dogId) { return get(`/dogs/${dogId}/behavior`); },
|
||||
behaviorCreate(dogId, d) { return post(`/dogs/${dogId}/behavior`, d); },
|
||||
behaviorDelete(dogId, id) { return del(`/dogs/${dogId}/behavior/${id}`); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '872'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '875'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||
|
|
|
|||
|
|
@ -1678,9 +1678,11 @@ window.Page_diary = (() => {
|
|||
});
|
||||
|
||||
await UI.asyncButton(submitBtn, async () => {
|
||||
// Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort
|
||||
// Auto-Wetter: nur bei neuem Eintrag ohne GPS-Standort und OHNE anhängige Dateien.
|
||||
// Wenn Dateien hochzuladen sind, dürfen wir keinen langen await vor dem Upload machen —
|
||||
// iOS macht File-Handles nach längerer Pause ungültig (WebKit-Bug).
|
||||
let _clientWeather = null;
|
||||
if (!isEdit && _locLat == null) {
|
||||
if (!isEdit && _locLat == null && _newFiles.length === 0) {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
const wd = await API.weather.get(pos.lat, pos.lon);
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@ window.Page_dog_profile = (() => {
|
|||
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${dog.widerrist_cm ? `
|
||||
<div class="card" style="padding:var(--space-3)">
|
||||
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#ruler"></use></svg> Widerrist</div>
|
||||
<div style="font-weight:500;font-size:var(--text-sm)">${dog.widerrist_cm} cm</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="card" style="padding:var(--space-3)">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
margin-bottom:2px">
|
||||
|
|
@ -1133,6 +1139,18 @@ window.Page_dog_profile = (() => {
|
|||
value="${dog?.gewicht_kg || ''}"
|
||||
min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Widerristhöhe (cm)
|
||||
${UI.help('Der Widerrist ist der höchste Punkt zwischen den Schulterblättern. Hund gerade hinstellen, senkrecht von diesem Punkt zum Boden messen. Ab 40 cm gilt der Hund in NRW als „großer Hund" (Anleinpflicht + Versicherungspflicht). In anderen Bundesländern gelten teils andere Regeln — im Zweifel bei der Gemeinde nachfragen.')}
|
||||
</label>
|
||||
<input class="form-control" type="number" name="widerrist_cm"
|
||||
value="${dog?.widerrist_cm || ''}"
|
||||
min="10" max="120" step="1" placeholder="z. B. 58">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
Chip-Nummer
|
||||
|
|
@ -1141,6 +1159,7 @@ window.Page_dog_profile = (() => {
|
|||
<input class="form-control" type="text" name="chip_nr"
|
||||
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -1327,8 +1346,9 @@ window.Page_dog_profile = (() => {
|
|||
rasse_id: fd.rasse_id ? parseInt(fd.rasse_id) : null,
|
||||
geburtstag: fd.geburtstag || null,
|
||||
geschlecht: fd.geschlecht || null,
|
||||
gewicht_kg: fd.gewicht_kg ? parseFloat(fd.gewicht_kg) : null,
|
||||
chip_nr: fd.chip_nr || null,
|
||||
gewicht_kg: fd.gewicht_kg ? parseFloat(fd.gewicht_kg) : null,
|
||||
widerrist_cm: fd.widerrist_cm ? parseFloat(fd.widerrist_cm) : null,
|
||||
chip_nr: fd.chip_nr || null,
|
||||
bio: fd.bio || null,
|
||||
is_public: 'is_public' in fd,
|
||||
fell_typ: fd.fell_typ || null,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ window.Page_health = (() => {
|
|||
{ 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: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
|
||||
{ key: 'versicherung', label: 'Versicherung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield-check"></use></svg>' },
|
||||
{ key: 'verhalten', label: 'Verhalten', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#brain"></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>' };
|
||||
|
||||
|
|
@ -111,12 +113,14 @@ window.Page_health = (() => {
|
|||
<div id="health-ki-berichte"></div>
|
||||
<div id="health-terminvorschlaege"></div>
|
||||
<div id="health-reminders"></div>
|
||||
<div id="health-reminders-banner" style="display:none;padding:0 0 var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)"></div>
|
||||
<div class="by-tabs" id="by-tabs"></div>
|
||||
<div id="by-tab-content"></div>
|
||||
`;
|
||||
|
||||
_renderTabBar();
|
||||
UI.bindDogChip(_container, _appState);
|
||||
_loadRemindersBanner();
|
||||
_container.querySelector('#health-ki-btn')
|
||||
.addEventListener('click', _showKiSummary);
|
||||
_container.querySelector('#health-ki-tierarzt-btn')
|
||||
|
|
@ -332,6 +336,8 @@ window.Page_health = (() => {
|
|||
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
|
||||
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
|
||||
case 'praxen': content.innerHTML = _renderPraxen(); break;
|
||||
case 'versicherung': _renderVersicherung(content); break;
|
||||
case 'verhalten': _renderVerhalten(content); break;
|
||||
}
|
||||
|
||||
_bindTabEvents(content);
|
||||
|
|
@ -3050,6 +3056,331 @@ window.Page_health = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// BEVORSTEHENDE ERINNERUNGEN (Banner oben in der Health-Seite)
|
||||
// ==============================================================
|
||||
async function _loadRemindersBanner() {
|
||||
const dog = _appState?.activeDog;
|
||||
if (!dog) return;
|
||||
const wrap = _container?.querySelector('#health-reminders-banner');
|
||||
if (!wrap) return;
|
||||
let items;
|
||||
try { items = await API.health.reminders(dog.id); }
|
||||
catch { return; }
|
||||
if (!items.length) { wrap.style.display = 'none'; return; }
|
||||
|
||||
const TYPE_LABEL = { impfung: 'Impfung', entwurmung: 'Entwurmung', medikament: 'Medikament' };
|
||||
const fmt = d => { try { const p = d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
|
||||
|
||||
wrap.style.display = '';
|
||||
wrap.innerHTML = items.slice(0, 3).map(r => {
|
||||
const overdue = r.ueberfaellig;
|
||||
const color = overdue ? 'var(--c-danger,#ef4444)' : r.delta_tage <= 3 ? '#f59e0b' : 'var(--c-primary)';
|
||||
const bg = overdue ? 'rgba(239,68,68,0.08)' : r.delta_tage <= 3 ? 'rgba(245,158,11,0.08)' : 'var(--c-primary-subtle)';
|
||||
const label = overdue ? `Überfällig seit ${Math.abs(r.delta_tage)} Tag${Math.abs(r.delta_tage)!==1?'en':''}` :
|
||||
r.delta_tage === 0 ? 'Heute fällig' :
|
||||
`in ${r.delta_tage} Tag${r.delta_tage!==1?'en':''}`;
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);
|
||||
background:${bg};border-radius:var(--radius-md);border-left:3px solid ${color}">
|
||||
<svg class="ph-icon" style="width:16px;height:16px;color:${color};flex-shrink:0" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#bell-ringing"></use>
|
||||
</svg>
|
||||
<div style="flex:1;min-width:0">
|
||||
<span style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${_esc(r.bezeichnung)}</span>
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:var(--space-1)">${TYPE_LABEL[r.typ] || r.typ}</span>
|
||||
</div>
|
||||
<span style="font-size:var(--text-xs);font-weight:600;color:${color};white-space:nowrap">${label}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// TAB: VERSICHERUNG
|
||||
// ==============================================================
|
||||
async function _renderVersicherung(content) {
|
||||
const dog = _appState?.activeDog;
|
||||
if (!dog) return;
|
||||
content.innerHTML = `<div style="padding:var(--space-4) 0">
|
||||
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
|
||||
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner-gap"></use></svg>
|
||||
</div></div>`;
|
||||
|
||||
let policies;
|
||||
try { policies = await API.health.insuranceList(dog.id); }
|
||||
catch { content.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Fehler beim Laden.</p>`; return; }
|
||||
|
||||
const _fmtDate = d => { if (!d) return '–'; try { const p=d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
|
||||
const _fmtEur = v => v ? `${v.toFixed(2).replace('.',',')} €/Jahr` : '–';
|
||||
|
||||
const cardsHtml = policies.length ? policies.map(p => `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)" data-ins-id="${p.id}">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-2)">
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:var(--text-base)">${_esc(p.anbieter)}</div>
|
||||
${p.police_nr ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">Police: ${_esc(p.police_nr)}</div>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-1)">
|
||||
<button class="btn btn-ghost btn-sm ins-edit-btn" data-id="${p.id}" style="padding:4px 8px">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm ins-del-btn" data-id="${p.id}" style="padding:4px 8px;color:var(--c-danger)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3);font-size:var(--text-sm)">
|
||||
<div><span style="color:var(--c-text-secondary)">Jahresbeitrag</span><br><strong>${_fmtEur(p.jahresbeitrag)}</strong></div>
|
||||
<div><span style="color:var(--c-text-secondary)">Läuft ab</span><br><strong>${_fmtDate(p.ablaufdatum)}</strong></div>
|
||||
${p.kontakt ? `<div style="grid-column:1/-1"><span style="color:var(--c-text-secondary)">Kontakt</span><br>${_esc(p.kontakt)}</div>` : ''}
|
||||
${p.notizen ? `<div style="grid-column:1/-1"><span style="color:var(--c-text-secondary)">Notizen</span><br>${_esc(p.notizen)}</div>` : ''}
|
||||
</div>
|
||||
</div>`).join('') : `
|
||||
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
|
||||
<svg class="ph-icon" style="width:2.5rem;height:2.5rem;margin-bottom:var(--space-3);display:block;margin-inline:auto" aria-hidden="true"><use href="/icons/phosphor.svg#shield-check"></use></svg>
|
||||
<div style="font-size:var(--text-sm)">Noch keine Versicherung eingetragen.</div>
|
||||
</div>`;
|
||||
|
||||
content.innerHTML = `<div style="padding:var(--space-4) 0">
|
||||
${cardsHtml}
|
||||
<button class="btn btn-primary" id="ins-add-btn" style="width:100%;margin-top:var(--space-2)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
|
||||
Versicherung eintragen
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
content.querySelector('#ins-add-btn')?.addEventListener('click', () => _openInsuranceForm(dog, null, () => _renderVersicherung(content)));
|
||||
content.querySelectorAll('.ins-edit-btn').forEach(btn => {
|
||||
const pol = policies.find(p => p.id === parseInt(btn.dataset.id));
|
||||
btn.addEventListener('click', () => _openInsuranceForm(dog, pol, () => _renderVersicherung(content)));
|
||||
});
|
||||
content.querySelectorAll('.ins-del-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!window.confirm('Versicherung löschen?')) return;
|
||||
await API.health.insuranceDelete(dog.id, parseInt(btn.dataset.id));
|
||||
_renderVersicherung(content);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _openInsuranceForm(dog, existing, onSave) {
|
||||
const id = `ins-form-${Date.now()}`;
|
||||
const body = `<form id="${id}">
|
||||
<div class="by-form-group"><label class="by-label">Anbieter *</label>
|
||||
<input type="text" name="anbieter" class="form-control by-input" value="${_esc(existing?.anbieter||'')}" required placeholder="z. B. HUK-Coburg">
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Police-Nr.</label>
|
||||
<input type="text" name="police_nr" class="form-control by-input" value="${_esc(existing?.police_nr||'')}" placeholder="optional">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="by-form-group"><label class="by-label">Jahresbeitrag (€)</label>
|
||||
<input type="number" name="jahresbeitrag" class="form-control by-input" value="${existing?.jahresbeitrag||''}" min="0" step="0.01" placeholder="z. B. 149.00">
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Ablaufdatum</label>
|
||||
<input type="date" name="ablaufdatum" class="form-control by-input" value="${existing?.ablaufdatum||''}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Kontakt / Telefon</label>
|
||||
<input type="text" name="kontakt" class="form-control by-input" value="${_esc(existing?.kontakt||'')}" placeholder="optional">
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Notizen</label>
|
||||
<textarea name="notizen" class="form-control by-input" rows="2">${_esc(existing?.notizen||'')}</textarea>
|
||||
</div>
|
||||
</form>`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="ins-save-btn" form="${id}">Speichern</button>`;
|
||||
UI.modal.open({ title: existing ? 'Versicherung bearbeiten' : 'Versicherung eintragen', body, footer });
|
||||
setTimeout(() => {
|
||||
document.getElementById('ins-save-btn')?.addEventListener('click', async ev => {
|
||||
ev.preventDefault();
|
||||
const form = document.getElementById(id);
|
||||
if (!form) return;
|
||||
const fd = new FormData(form);
|
||||
const data = {
|
||||
anbieter: (fd.get('anbieter')||'').trim(),
|
||||
police_nr: fd.get('police_nr')||null,
|
||||
jahresbeitrag: fd.get('jahresbeitrag') ? parseFloat(fd.get('jahresbeitrag')) : null,
|
||||
ablaufdatum: fd.get('ablaufdatum')||null,
|
||||
kontakt: fd.get('kontakt')||null,
|
||||
notizen: fd.get('notizen')||null,
|
||||
};
|
||||
if (!data.anbieter) { UI.toast.warning('Bitte Anbieter angeben.'); return; }
|
||||
await UI.asyncButton(document.getElementById('ins-save-btn'), async () => {
|
||||
try {
|
||||
if (existing) await API.health.insuranceUpdate(dog.id, existing.id, data);
|
||||
else await API.health.insuranceCreate(dog.id, data);
|
||||
UI.modal.close();
|
||||
UI.toast.success('Gespeichert.');
|
||||
onSave();
|
||||
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
|
||||
});
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// TAB: VERHALTEN
|
||||
// ==============================================================
|
||||
const _KAT_LABELS = {
|
||||
angst: 'Angst / Panik', aggression: 'Aggression', ueberreaktion: 'Überreaktion',
|
||||
ressource: 'Ressourcenverteidigung', separation: 'Trennungsangst',
|
||||
leine: 'Leinenprobleme', sozial: 'Sozialkompetenz', sonstiges: 'Sonstiges',
|
||||
};
|
||||
const _KAT_COLORS = {
|
||||
angst: '#3b82f6', aggression: '#ef4444', ueberreaktion: '#f59e0b',
|
||||
ressource: '#8b5cf6', separation: '#ec4899', leine: '#06b6d4',
|
||||
sozial: '#22c55e', sonstiges: '#6b7280',
|
||||
};
|
||||
const _TRIGGER_LABELS = {
|
||||
fremde_hunde: 'Fremde Hunde', fremde_menschen: 'Fremde Menschen', kinder: 'Kinder',
|
||||
laerm_feuerwerk: 'Feuerwerk', laerm_gewitter: 'Gewitter', auto_fahrrad: 'Autos/Fahrräder',
|
||||
tierarzt: 'Tierarztbesuch', allein_zuhause: 'Allein zuhause',
|
||||
andere_tiere: 'Andere Tiere', besucher_zuhause: 'Besucher', sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
async function _renderVerhalten(content) {
|
||||
const dog = _appState?.activeDog;
|
||||
if (!dog) return;
|
||||
content.innerHTML = `<div style="padding:var(--space-4) 0">
|
||||
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
|
||||
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner-gap"></use></svg>
|
||||
</div></div>`;
|
||||
|
||||
let resp;
|
||||
try { resp = await API.health.behaviorList(dog.id); }
|
||||
catch { content.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Fehler beim Laden.</p>`; return; }
|
||||
|
||||
const entries = resp.entries || [];
|
||||
const fmtDate = d => { try { const p=d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
|
||||
|
||||
const listHtml = entries.length ? entries.map(e => {
|
||||
const color = _KAT_COLORS[e.kategorie] || '#6b7280';
|
||||
const katLabel = _KAT_LABELS[e.kategorie] || e.kategorie;
|
||||
const trigLabel = _TRIGGER_LABELS[e.trigger] || e.trigger || '';
|
||||
const dots = Array.from({length: 5}, (_,i) =>
|
||||
`<div style="width:8px;height:8px;border-radius:50%;background:${i < e.intensitaet ? color : 'var(--c-border)'}"></div>`
|
||||
).join('');
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-3);margin-bottom:var(--space-2);display:flex;align-items:flex-start;gap:var(--space-3)">
|
||||
<div style="width:3px;border-radius:2px;background:${color};align-self:stretch;flex-shrink:0"></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
||||
<span style="font-weight:700;font-size:var(--text-sm);color:${color}">${_esc(katLabel)}</span>
|
||||
${trigLabel ? `<span style="font-size:var(--text-xs);background:var(--c-surface-2);padding:1px 6px;border-radius:100px;color:var(--c-text-secondary)">${_esc(trigLabel)}</span>` : ''}
|
||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted);margin-left:auto">${fmtDate(e.datum)}${e.uhrzeit ? ' ' + e.uhrzeit : ''}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:3px;margin-top:4px">${dots}</div>
|
||||
${e.notiz ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:4px">${_esc(e.notiz)}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm beh-del-btn" data-id="${e.id}" style="padding:4px 6px;color:var(--c-danger);flex-shrink:0">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('') : `
|
||||
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
|
||||
<svg class="ph-icon" style="width:2.5rem;height:2.5rem;margin-bottom:var(--space-3);display:block;margin-inline:auto" aria-hidden="true"><use href="/icons/phosphor.svg#brain"></use></svg>
|
||||
<div style="font-size:var(--text-sm)">Noch keine Einträge. Protokolliere auffälliges Verhalten um Muster zu erkennen.</div>
|
||||
</div>`;
|
||||
|
||||
content.innerHTML = `<div style="padding:var(--space-4) 0">
|
||||
${listHtml}
|
||||
<button class="btn btn-primary" id="beh-add-btn" style="width:100%;margin-top:var(--space-2)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
|
||||
Verhalten erfassen
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
content.querySelector('#beh-add-btn')?.addEventListener('click', () => _openBehaviorForm(dog, () => _renderVerhalten(content)));
|
||||
content.querySelectorAll('.beh-del-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!window.confirm('Eintrag löschen?')) return;
|
||||
await API.health.behaviorDelete(dog.id, parseInt(btn.dataset.id));
|
||||
_renderVerhalten(content);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _openBehaviorForm(dog, onSave) {
|
||||
const id = `beh-form-${Date.now()}`;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const nowTime = (() => { const d=new Date(); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; })();
|
||||
const body = `<form id="${id}">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="by-form-group"><label class="by-label">Datum</label>
|
||||
<input type="date" name="datum" class="form-control by-input" value="${today}" required>
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Uhrzeit</label>
|
||||
<input type="time" name="uhrzeit" class="form-control by-input" value="${nowTime}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Kategorie *</label>
|
||||
<select name="kategorie" class="form-control by-select" required>
|
||||
<option value="">– wählen –</option>
|
||||
${Object.entries(_KAT_LABELS).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Intensität (1 = gering, 5 = stark)</label>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
${[1,2,3,4,5].map(n => `<button type="button" class="beh-int-btn" data-val="${n}"
|
||||
style="flex:1;padding:10px;border-radius:8px;border:1.5px solid var(--c-border);
|
||||
background:${n<=3?'var(--c-primary)':'var(--c-bg-card)'};
|
||||
color:${n<=3?'#fff':'var(--c-text-secondary)'};font-weight:700;cursor:pointer">${n}</button>`).join('')}
|
||||
</div>
|
||||
<input type="hidden" name="intensitaet" value="3">
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Auslöser</label>
|
||||
<select name="trigger" class="form-control by-select">
|
||||
<option value="">– unbekannt –</option>
|
||||
${Object.entries(_TRIGGER_LABELS).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="by-form-group"><label class="by-label">Notiz</label>
|
||||
<textarea name="notiz" class="form-control by-input" rows="2" placeholder="Was ist passiert?"></textarea>
|
||||
</div>
|
||||
</form>`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="beh-save-btn" form="${id}">Speichern</button>`;
|
||||
UI.modal.open({ title: 'Verhalten erfassen', body, footer });
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.beh-int-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const val = parseInt(btn.dataset.val);
|
||||
document.querySelectorAll('.beh-int-btn').forEach((b,i) => {
|
||||
b.style.background = i < val ? 'var(--c-primary)' : 'var(--c-bg-card)';
|
||||
b.style.color = i < val ? '#fff' : 'var(--c-text-secondary)';
|
||||
});
|
||||
const hi = document.querySelector('[name="intensitaet"]');
|
||||
if (hi) hi.value = val;
|
||||
});
|
||||
});
|
||||
document.getElementById('beh-save-btn')?.addEventListener('click', async ev => {
|
||||
ev.preventDefault();
|
||||
const form = document.getElementById(id);
|
||||
if (!form) return;
|
||||
const fd = new FormData(form);
|
||||
const data = {
|
||||
datum: fd.get('datum'),
|
||||
uhrzeit: fd.get('uhrzeit')||null,
|
||||
kategorie: fd.get('kategorie'),
|
||||
intensitaet: parseInt(fd.get('intensitaet')||'3'),
|
||||
trigger: fd.get('trigger')||null,
|
||||
notiz: (fd.get('notiz')||'').trim()||null,
|
||||
};
|
||||
if (!data.kategorie) { UI.toast.warning('Bitte Kategorie wählen.'); return; }
|
||||
await UI.asyncButton(document.getElementById('beh-save-btn'), async () => {
|
||||
try {
|
||||
await API.health.behaviorCreate(dog.id, data);
|
||||
UI.modal.close();
|
||||
UI.toast.success('Eintrag gespeichert.');
|
||||
onSave();
|
||||
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
|
||||
});
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
return { init, refresh, openNew, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ window.Page_map = (() => {
|
|||
|
||||
// z: zIndexOffset — höher = weiter oben bei Überlappung
|
||||
const TYPEN = {
|
||||
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316', z: 10 },
|
||||
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Hundefreundl. Café/Restaurant', color: '#F97316', z: 10 },
|
||||
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
|
||||
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
|
||||
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
|
||||
|
|
@ -92,6 +92,7 @@ window.Page_map = (() => {
|
|||
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
|
||||
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
|
||||
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
|
||||
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 },
|
||||
};
|
||||
|
||||
// Frontend-Layer → Backend-Typ Mapping
|
||||
|
|
@ -109,6 +110,7 @@ window.Page_map = (() => {
|
|||
parkplatz: 'parkplatz',
|
||||
treffpunkt: 'treffpunkt',
|
||||
community: 'sonstiges',
|
||||
hotel: 'hotel',
|
||||
};
|
||||
|
||||
// Gefahren-Radius-Kreis: prominente rote Fläche
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v872';
|
||||
const CACHE_VERSION = 'by-v875';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue