Feature: Aktive Erinnerungen, Versicherung, Verhaltensprotokoll, Hundefreundliche Orte (SW by-v874)
This commit is contained in:
parent
83034c0db0
commit
b818f85f36
11 changed files with 589 additions and 14 deletions
|
|
@ -2210,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 = "873" # muss mit APP_VER in app.js übereinstimmen
|
||||
APP_VER = "874" # muss mit APP_VER in app.js übereinstimmen
|
||||
|
||||
@app.get("/.well-known/assetlinks.json")
|
||||
async def assetlinks():
|
||||
|
|
|
|||
|
|
@ -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=873">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=873">
|
||||
<link rel="stylesheet" href="/css/components.css?v=873">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=874">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=874">
|
||||
<link rel="stylesheet" href="/css/components.css?v=874">
|
||||
</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=873"></script>
|
||||
<script src="/js/ui.js?v=873"></script>
|
||||
<script src="/js/app.js?v=873"></script>
|
||||
<script src="/js/worlds.js?v=873"></script>
|
||||
<script src="/js/api.js?v=874"></script>
|
||||
<script src="/js/ui.js?v=874"></script>
|
||||
<script src="/js/app.js?v=874"></script>
|
||||
<script src="/js/worlds.js?v=874"></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 = '873'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '874'; // ← 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
|
||||
|
|
|
|||
|
|
@ -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-v873';
|
||||
const CACHE_VERSION = 'by-v874';
|
||||
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