Feature: Läufigkeit & Trächtigkeit — Zyklen, Progesterontests, Deckdaten, Meilensteine (SW by-v894)

This commit is contained in:
rene 2026-05-13 17:09:02 +02:00
parent 5a639d47a9
commit f3308a6a94
8 changed files with 997 additions and 8 deletions

View file

@ -2265,6 +2265,51 @@ def _migrate(conn_factory):
except Exception as e:
logger.warning(f"Migration behavior_log: {e}")
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS laeufi_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE,
beginn TEXT NOT NULL,
ende TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS progesteron_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
laeufi_id INTEGER NOT NULL REFERENCES laeufi_log(id) ON DELETE CASCADE,
datum TEXT NOT NULL,
wert REAL,
einheit TEXT NOT NULL DEFAULT 'ng/ml',
labor TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS deckdaten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE,
laeufi_id INTEGER REFERENCES laeufi_log(id) ON DELETE SET NULL,
deckdatum TEXT NOT NULL,
ruede_id INTEGER REFERENCES zucht_hunde(id) ON DELETE SET NULL,
ruede_name TEXT,
deckart TEXT NOT NULL DEFAULT 'natuerlich',
traechtig INTEGER NOT NULL DEFAULT 0,
ultraschall_datum TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_laeufi_hund ON laeufi_log(hund_id, beginn DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prog_laeufi ON progesteron_tests(laeufi_id, datum)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_deck_hund ON deckdaten(hund_id, deckdatum DESC)")
logger.info("Migration: laeufi_log, progesteron_tests, deckdaten bereit.")
except Exception as e:
logger.warning(f"Migration laeufi: {e}")
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS litter_waitlist (

View file

@ -233,6 +233,7 @@ from routes.moderation import router as moderation_router
from routes.notes import router as notes_router
from routes.breeder import router as breeder_router
from routes.litters import router as litters_router
from routes.laeufi import router as laeufi_router
from routes.breeder_photos import router as breeder_photos_router
from routes.zucht_hunde import router as zucht_hunde_router
from routes.breeder_export import router as breeder_export_router
@ -279,6 +280,7 @@ app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
app.include_router(breeder_router, prefix="/api", tags=["Züchter"])
app.include_router(litters_router, prefix="/api", tags=["Würfe"])
app.include_router(laeufi_router, prefix="/api", tags=["Läufigkeit"])
app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter-Fotos"])
app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
@ -404,7 +406,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "893" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "894" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():

307
backend/routes/laeufi.py Normal file
View file

@ -0,0 +1,307 @@
"""BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from datetime import date, timedelta
from database import db
from auth import get_current_user
router = APIRouter()
def _require_breeder(user=Depends(get_current_user)):
if user["rolle"] not in ("breeder", "admin"):
raise HTTPException(403, "Nur für verifizierte Züchter.")
return user
def _check_hund_owner(hund_id: int, user: dict, conn):
row = conn.execute(
"""SELECT zh.id, bp.user_id AS owner_user_id FROM zucht_hunde zh
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE zh.id=?""", (hund_id,)
).fetchone()
if not row:
raise HTTPException(404, "Hund nicht gefunden.")
if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return row
def _check_laeufi_owner(laeufi_id: int, user: dict, conn):
row = conn.execute(
"""SELECT l.id, bp.user_id AS owner_user_id FROM laeufi_log l
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE l.id=?""", (laeufi_id,)
).fetchone()
if not row:
raise HTTPException(404, "Läufigkeit nicht gefunden.")
if user["rolle"] != "admin" and row["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return row
# ------------------------------------------------------------------
# Meilensteine berechnen
# ------------------------------------------------------------------
_MEILENSTEINE = [
(21, "Frühester Ultraschall möglich"),
(25, "Ultraschall empfohlen (Welpen erkennbar)"),
(35, "Bauch wird sichtbar"),
(45, "Röntgen möglich (Skelettanzahl)"),
(56, "Wurfbox aufstellen"),
(58, "Tägliche Temperaturmessung beginnen"),
(63, "Erwarteter Geburtstermin"),
(65, "Tierarzt konsultieren wenn keine Geburt"),
]
def _calc_meilensteine(deckdatum_str: str) -> list:
try:
deck = date.fromisoformat(deckdatum_str)
except Exception:
return []
return [
{
"tag": tag,
"datum": (deck + timedelta(days=tag)).isoformat(),
"label": label,
"vorbei": (deck + timedelta(days=tag)) < date.today(),
}
for tag, label in _MEILENSTEINE
]
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LaeufiCreate(BaseModel):
beginn: str
ende: Optional[str] = None
notiz: Optional[str] = None
class LaeufiUpdate(BaseModel):
beginn: Optional[str] = None
ende: Optional[str] = None
notiz: Optional[str] = None
class ProgestCreate(BaseModel):
datum: str
wert: Optional[float] = None
einheit: str = "ng/ml"
labor: Optional[str] = None
notiz: Optional[str] = None
class ProgestUpdate(BaseModel):
datum: Optional[str] = None
wert: Optional[float] = None
einheit: Optional[str] = None
labor: Optional[str] = None
notiz: Optional[str] = None
class DeckCreate(BaseModel):
deckdatum: str
laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = None
deckart: str = "natuerlich"
traechtig: int = 0
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
class DeckUpdate(BaseModel):
deckdatum: Optional[str] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = None
deckart: Optional[str] = None
traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
# ------------------------------------------------------------------
# Läufigkeit
# ------------------------------------------------------------------
@router.get("/laeufi/{hund_id}")
async def list_laeufi(hund_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
rows = conn.execute(
"SELECT * FROM laeufi_log WHERE hund_id=? ORDER BY beginn DESC",
(hund_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/laeufi/{hund_id}", status_code=201)
async def add_laeufi(hund_id: int, body: LaeufiCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"INSERT INTO laeufi_log (hund_id, beginn, ende, notiz) VALUES (?,?,?,?)",
(hund_id, body.beginn, body.ende, body.notiz)
)
row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/laeufi/entry/{laeufi_id}")
async def update_laeufi(laeufi_id: int, body: LaeufiUpdate, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE laeufi_log SET {sets} WHERE id=?", (*fields.values(), laeufi_id))
row = conn.execute("SELECT * FROM laeufi_log WHERE id=?", (laeufi_id,)).fetchone()
return dict(row)
@router.delete("/laeufi/entry/{laeufi_id}", status_code=204)
async def delete_laeufi(laeufi_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
conn.execute("DELETE FROM laeufi_log WHERE id=?", (laeufi_id,))
# ------------------------------------------------------------------
# Progesterontests
# ------------------------------------------------------------------
@router.get("/laeufi/entry/{laeufi_id}/prog")
async def list_prog(laeufi_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
rows = conn.execute(
"SELECT * FROM progesteron_tests WHERE laeufi_id=? ORDER BY datum",
(laeufi_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/laeufi/entry/{laeufi_id}/prog", status_code=201)
async def add_prog(laeufi_id: int, body: ProgestCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_laeufi_owner(laeufi_id, user, conn)
cur = conn.execute(
"INSERT INTO progesteron_tests (laeufi_id, datum, wert, einheit, labor, notiz) VALUES (?,?,?,?,?,?)",
(laeufi_id, body.datum, body.wert, body.einheit, body.labor, body.notiz)
)
row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.put("/laeufi/prog/{prog_id}")
async def update_prog(prog_id: int, body: ProgestUpdate, user=Depends(_require_breeder)):
with db() as conn:
pt = conn.execute(
"""SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt
JOIN laeufi_log l ON l.id = pt.laeufi_id
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE pt.id=?""", (prog_id,)
).fetchone()
if not pt:
raise HTTPException(404)
if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]:
raise HTTPException(403)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE progesteron_tests SET {sets} WHERE id=?", (*fields.values(), prog_id))
row = conn.execute("SELECT * FROM progesteron_tests WHERE id=?", (prog_id,)).fetchone()
return dict(row)
@router.delete("/laeufi/prog/{prog_id}", status_code=204)
async def delete_prog(prog_id: int, user=Depends(_require_breeder)):
with db() as conn:
pt = conn.execute(
"""SELECT pt.id, bp.user_id AS owner_user_id FROM progesteron_tests pt
JOIN laeufi_log l ON l.id = pt.laeufi_id
JOIN zucht_hunde zh ON zh.id = l.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE pt.id=?""", (prog_id,)
).fetchone()
if not pt:
raise HTTPException(404)
if user["rolle"] != "admin" and pt["owner_user_id"] != user["id"]:
raise HTTPException(403)
conn.execute("DELETE FROM progesteron_tests WHERE id=?", (prog_id,))
# ------------------------------------------------------------------
# Deckdaten & Trächtigkeit
# ------------------------------------------------------------------
@router.get("/laeufi/deck/{hund_id}")
async def list_deck(hund_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
rows = conn.execute(
"SELECT * FROM deckdaten WHERE hund_id=? ORDER BY deckdatum DESC",
(hund_id,)
).fetchall()
result = []
for r in rows:
d = dict(r)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
result.append(d)
return result
@router.post("/laeufi/deck/{hund_id}", status_code=201)
async def add_deck(hund_id: int, body: DeckCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"""INSERT INTO deckdaten
(hund_id, laeufi_id, deckdatum, ruede_id, ruede_name, deckart,
traechtig, ultraschall_datum, notiz)
VALUES (?,?,?,?,?,?,?,?,?)""",
(hund_id, body.laeufi_id, body.deckdatum, body.ruede_id, body.ruede_name,
body.deckart, body.traechtig, body.ultraschall_datum, body.notiz)
)
row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (cur.lastrowid,)).fetchone()
d = dict(row)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
return d
@router.put("/laeufi/deck/entry/{deck_id}")
async def update_deck(deck_id: int, body: DeckUpdate, user=Depends(_require_breeder)):
with db() as conn:
dk = conn.execute(
"""SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d
JOIN zucht_hunde zh ON zh.id = d.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE d.id=?""", (deck_id,)
).fetchone()
if not dk:
raise HTTPException(404)
if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]:
raise HTTPException(403)
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if fields:
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE deckdaten SET {sets} WHERE id=?", (*fields.values(), deck_id))
row = conn.execute("SELECT * FROM deckdaten WHERE id=?", (deck_id,)).fetchone()
d = dict(row)
d["meilensteine"] = _calc_meilensteine(d["deckdatum"])
return d
@router.delete("/laeufi/deck/entry/{deck_id}", status_code=204)
async def delete_deck(deck_id: int, user=Depends(_require_breeder)):
with db() as conn:
dk = conn.execute(
"""SELECT d.id, bp.user_id AS owner_user_id FROM deckdaten d
JOIN zucht_hunde zh ON zh.id = d.hund_id
JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE d.id=?""", (deck_id,)
).fetchone()
if not dk:
raise HTTPException(404)
if user["rolle"] != "admin" and dk["owner_user_id"] != user["id"]:
raise HTTPException(403)
conn.execute("DELETE FROM deckdaten WHERE id=?", (deck_id,))

View file

@ -256,6 +256,10 @@
style="display:none;color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg> Wurfverwaltung
</div>
<div class="sidebar-item" data-page="laeufi" id="sidebar-laeufi"
style="display:none;color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg> Läufigkeit
</div>
<div class="sidebar-item" data-page="social" id="sidebar-social"
style="display:none;color:var(--c-warning,#f59e0b)">
@ -435,6 +439,10 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-laeufi">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-wurfboerse">
<div class="page-body page-container"></div>
</section>
@ -583,10 +591,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=893"></script>
<script src="/js/ui.js?v=893"></script>
<script src="/js/app.js?v=893"></script>
<script src="/js/worlds.js?v=893"></script>
<script src="/js/api.js?v=894"></script>
<script src="/js/ui.js?v=894"></script>
<script src="/js/app.js?v=894"></script>
<script src="/js/worlds.js?v=894"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -719,6 +719,26 @@ const API = (() => {
detail(id) { return get(`/litters/${id}`); },
};
// ----------------------------------------------------------
// LÄUFIGKEIT & TRÄCHTIGKEIT
// ----------------------------------------------------------
const laeufi = {
list(hundId) { return get(`/laeufi/${hundId}`); },
add(hundId, data) { return post(`/laeufi/${hundId}`, data); },
update(id, data) { return put(`/laeufi/entry/${id}`, data); },
remove(id) { return del(`/laeufi/entry/${id}`); },
// Progesterontests
listProg(laeufiId) { return get(`/laeufi/entry/${laeufiId}/prog`); },
addProg(laeufiId, data) { return post(`/laeufi/entry/${laeufiId}/prog`, data); },
updateProg(id, data) { return put(`/laeufi/prog/${id}`, data); },
removeProg(id) { return del(`/laeufi/prog/${id}`); },
// Deckdaten
listDeck(hundId) { return get(`/laeufi/deck/${hundId}`); },
addDeck(hundId, data) { return post(`/laeufi/deck/${hundId}`, data); },
updateDeck(id, data) { return put(`/laeufi/deck/entry/${id}`, data); },
removeDeck(id) { return del(`/laeufi/deck/entry/${id}`); },
};
// ----------------------------------------------------------
// ZÜCHTER-FOTOS
// ----------------------------------------------------------
@ -782,7 +802,7 @@ const API = (() => {
auth, dogs, diary, health, tieraerzte, healthDocs, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
breeder, litters, breederPhotos, zuchthunde, zuchtKi,
breeder, litters, breederPhotos, zuchthunde, zuchtKi, laeufi,
osm,
subscribeToPush, getLocation, clientNow,
APIError,

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '893'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '894'; // ← 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
@ -70,6 +70,7 @@ const App = (() => {
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
laeufi: { title: 'Läufigkeit', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null },
jobs: { title: 'Wir suchen dich', module: null },
@ -549,6 +550,8 @@ const App = (() => {
if (littersItem) littersItem.style.display = isBreeder ? '' : 'none';
const zuchthundeItem = document.getElementById('sidebar-zuchthunde');
if (zuchthundeItem) zuchthundeItem.style.display = isBreeder ? '' : 'none';
const laeufiItem = document.getElementById('sidebar-laeufi');
if (laeufiItem) laeufiItem.style.display = isBreeder ? '' : 'none';
const socialItem = document.getElementById('sidebar-social');
if (socialItem) {
const isSocial = state.user.is_social_media || state.user.rolle === 'admin';

View file

@ -0,0 +1,604 @@
/* BAN YARO — Läufigkeit & Trächtigkeit (Züchter) */
window.Page_laeufi = (() => {
let _container, _appState;
let _hunde = []; // alle weiblichen Zuchthunde
let _openHundId = null;
// ----------------------------------------------------------
// Init / Refresh
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) {
_container.innerHTML = `<div style="text-align:center;padding:var(--space-10)">
<p style="color:var(--c-text-secondary)">Nur für verifizierte Züchter.</p></div>`;
return;
}
_render();
await _loadHunde();
}
function refresh() { _loadHunde(); }
function onDogChange() {}
// ----------------------------------------------------------
// Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div style="max-width:860px">
<div class="by-toolbar" style="margin-bottom:var(--space-4)">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('thermometer')} Läufigkeit & Trächtigkeit
</h2>
</div>
<div id="laeufi-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>`;
}
async function _loadHunde() {
try {
const alle = await API.zuchthunde.list();
_hunde = alle.filter(h => h.geschlecht === 'weiblich');
_renderHundeList();
} catch (err) {
document.getElementById('laeufi-list').innerHTML =
`<p style="color:var(--c-danger)">${UI.esc(err.message || 'Fehler')}</p>`;
}
}
function _renderHundeList() {
const el = document.getElementById('laeufi-list');
if (!_hunde.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4);
border:1px dashed var(--c-border);border-radius:var(--radius-lg)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">${UI.icon('gender-female')}</div>
<p style="font-weight:600;color:var(--c-text)">Keine Hündinnen in der Zuchtkartei</p>
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">
Lege zuerst weibliche Hunde in der Zuchtkartei an.
</p>
</div>`;
return;
}
el.innerHTML = _hunde.map(h => _hundCardHTML(h)).join('');
_hunde.forEach(h => {
document.getElementById(`laeufi-toggle-${h.id}`)
?.addEventListener('click', () => _toggleHund(h.id));
});
if (_openHundId) _toggleHund(_openHundId, true);
}
// ----------------------------------------------------------
// Hund-Karte
// ----------------------------------------------------------
function _hundCardHTML(h) {
const alter = h.geburtsdatum
? Math.floor((Date.now() - new Date(h.geburtsdatum)) / 31557600000) + ' J'
: '';
return `
<div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);
border-radius:var(--radius-lg);margin-bottom:var(--space-3);overflow:hidden"
id="laeufi-card-${h.id}">
<div id="laeufi-toggle-${h.id}"
style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3);
cursor:pointer;user-select:none">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-base);font-weight:700">${UI.esc(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">"${UI.esc(h.rufname)}"</span>` : ''}
${alter ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${alter}</span>` : ''}
</div>
${h.rasse_text || h.farbe ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${[h.rasse_text, h.farbe].filter(Boolean).map(s => UI.esc(s)).join(' · ')}
</div>` : ''}
</div>
<span style="color:var(--c-text-muted)">${UI.icon('caret-down')}</span>
</div>
<div id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)">
<div id="laeufi-content-${h.id}"
style="padding:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
</div>
</div>`;
}
async function _toggleHund(hundId, forceOpen = false) {
const detail = document.getElementById(`laeufi-detail-${hundId}`);
if (!detail) return;
const isOpen = detail.style.display !== 'none';
if (isOpen && !forceOpen) {
detail.style.display = 'none';
_openHundId = null;
return;
}
detail.style.display = '';
_openHundId = hundId;
await _loadHundContent(hundId);
}
// ----------------------------------------------------------
// Inhalt pro Hündin laden
// ----------------------------------------------------------
async function _loadHundContent(hundId) {
const el = document.getElementById(`laeufi-content-${hundId}`);
if (!el) return;
try {
const [laeufiList, deckList] = await Promise.all([
API.laeufi.list(hundId),
API.laeufi.listDeck(hundId),
]);
_renderHundContent(el, hundId, laeufiList, deckList);
} catch (err) {
el.innerHTML = `<p style="color:var(--c-danger)">${UI.esc(err.message || 'Fehler')}</p>`;
}
}
function _renderHundContent(container, hundId, laeufiList, deckList) {
container.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- LÄUFIGKEITEN -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-sm);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em">
${UI.icon('drop')} Läufigkeiten
</h3>
<button class="btn btn-secondary btn-xs" id="laeufi-add-btn-${hundId}">
${UI.icon('plus')} Eintragen
</button>
</div>
<div id="laeufi-entries-${hundId}">
${_renderLaeufiEntries(hundId, laeufiList)}
</div>
</div>
<!-- DECKDATEN & TRÄCHTIGKEIT -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-sm);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.06em">
${UI.icon('heart')} Deckdaten & Trächtigkeit
</h3>
<button class="btn btn-secondary btn-xs" id="deck-add-btn-${hundId}">
${UI.icon('plus')} Deckung eintragen
</button>
</div>
<div id="deck-entries-${hundId}">
${_renderDeckEntries(hundId, deckList)}
</div>
</div>
</div>`;
document.getElementById(`laeufi-add-btn-${hundId}`)
?.addEventListener('click', () => _showLaeufiForm(hundId, null, laeufiList));
document.getElementById(`deck-add-btn-${hundId}`)
?.addEventListener('click', () => _showDeckForm(hundId, null, laeufiList));
// Edit/Delete Events für Läufigkeiten
container.querySelectorAll('.laeufi-edit-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
const entry = laeufiList.find(l => l.id === id);
if (entry) btn.addEventListener('click', () => _showLaeufiForm(hundId, entry, laeufiList));
});
container.querySelectorAll('.laeufi-delete-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
btn.addEventListener('click', async () => {
if (!window.confirm('Läufigkeit und alle Progesterontests löschen?')) return;
try { await API.laeufi.remove(id); await _loadHundContent(hundId); }
catch (err) { UI.toast.error(err.message); }
});
});
container.querySelectorAll('.laeufi-prog-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
const entry = laeufiList.find(l => l.id === id);
if (entry) btn.addEventListener('click', () => _showProgModal(hundId, entry));
});
// Edit/Delete Events für Deckdaten
container.querySelectorAll('.deck-edit-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
const entry = deckList.find(d => d.id === id);
if (entry) btn.addEventListener('click', () => _showDeckForm(hundId, entry, laeufiList));
});
container.querySelectorAll('.deck-delete-btn').forEach(btn => {
const id = parseInt(btn.dataset.id);
btn.addEventListener('click', async () => {
if (!window.confirm('Deckdaten löschen?')) return;
try { await API.laeufi.removeDeck(id); await _loadHundContent(hundId); }
catch (err) { UI.toast.error(err.message); }
});
});
}
// ----------------------------------------------------------
// Läufigkeits-Einträge rendern
// ----------------------------------------------------------
function _renderLaeufiEntries(hundId, list) {
if (!list.length) return `
<div style="padding:var(--space-4);text-align:center;border:1px dashed var(--c-border);
border-radius:var(--radius-md);color:var(--c-text-muted);font-size:var(--text-sm)">
Noch keine Läufigkeit eingetragen.
</div>`;
return list.map(l => `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-2);display:flex;gap:var(--space-3);align-items:flex-start">
<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:600;font-size:var(--text-sm)">${_fmtDate(l.beginn)}</span>
${l.ende ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">→ ${_fmtDate(l.ende)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
</div>
${l.notiz ? `<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin:var(--space-1) 0 0;font-style:italic">${UI.esc(l.notiz)}</p>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs laeufi-prog-btn" data-id="${l.id}" title="Progesterontests">
${UI.icon('test-tube')}
</button>
<button class="btn btn-ghost btn-xs laeufi-edit-btn" data-id="${l.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-ghost btn-xs laeufi-delete-btn" data-id="${l.id}"
title="Löschen" style="color:var(--c-danger)">
${UI.icon('trash')}
</button>
</div>
</div>`).join('');
}
// ----------------------------------------------------------
// Deckdaten + Meilensteine rendern
// ----------------------------------------------------------
const _TRAECHTIG = { 0: { label: 'Unbekannt', color: '#6b7280' }, 1: { label: 'Trächtig ✓', color: '#16a34a' }, [-1]: { label: 'Nicht trächtig', color: '#dc2626' } };
const _DECKART = { natuerlich: 'Natürlich', ki_frisch: 'KI frisch', ki_tiefgekuehlt: 'KI tiefgekühlt', ki_gefroren: 'KI gefroren' };
function _renderDeckEntries(hundId, list) {
if (!list.length) return `
<div style="padding:var(--space-4);text-align:center;border:1px dashed var(--c-border);
border-radius:var(--radius-md);color:var(--c-text-muted);font-size:var(--text-sm)">
Noch keine Deckung eingetragen.
</div>`;
return list.map(d => {
const tc = _TRAECHTIG[d.traechtig] || _TRAECHTIG[0];
const heute = d.meilensteine?.find(m => !m.vorbei);
const naechster = heute || d.meilensteine?.[d.meilensteine.length - 1];
return `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md);
margin-bottom:var(--space-3);overflow:hidden">
<!-- Deck-Header -->
<div style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:700;font-size:var(--text-sm)">${UI.icon('heart')} Deckung ${_fmtDate(d.deckdatum)}</span>
<span style="background:${tc.color}1a;color:${tc.color};border:1px solid ${tc.color}30;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${tc.label}</span>
</div>
<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${d.ruede_name ? `<span>${UI.icon('dog')} Rüde: ${UI.esc(d.ruede_name)}</span>` : ''}
<span>${UI.icon('arrows-clockwise')} ${_DECKART[d.deckart] || d.deckart}</span>
${d.ultraschall_datum ? `<span>${UI.icon('heartbeat')} Ultraschall: ${_fmtDate(d.ultraschall_datum)}</span>` : ''}
</div>
${naechster && d.traechtig === 1 ? `
<div style="margin-top:var(--space-2);font-size:var(--text-xs);
background:var(--c-primary-light,#f5e6d3);color:var(--c-primary-dark,#a86e2e);
border-radius:var(--radius-sm);padding:var(--space-1) var(--space-2);display:inline-flex;gap:4px;align-items:center">
${UI.icon('calendar-dots')} ${naechster.vorbei ? 'Letzter' : 'Nächster'} Meilenstein:
<strong>${naechster.label}</strong> · Tag ${naechster.tag} · ${_fmtDate(naechster.datum)}
</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs deck-edit-btn" data-id="${d.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs deck-delete-btn" data-id="${d.id}" title="Löschen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
</div>
</div>
<!-- Meilensteine -->
${d.traechtig === 1 && d.meilensteine?.length ? _renderMeilensteine(d.meilensteine) : ''}
</div>`;
}).join('');
}
function _renderMeilensteine(meilensteine) {
return `
<div style="border-top:1px solid var(--c-border);padding:var(--space-3)">
<p style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-2)">${UI.icon('calendar-check')} Trächtigkeits-Meilensteine</p>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
${meilensteine.map(m => `
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);
opacity:${m.vorbei ? '.45' : '1'}">
<span style="width:18px;height:18px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;
background:${m.vorbei ? 'var(--c-success)' : 'var(--c-border)'};
color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px">
${m.vorbei ? '✓' : m.tag}
</span>
<span style="color:var(--c-text-secondary)">${_fmtDate(m.datum)}</span>
<span style="color:${m.vorbei ? 'var(--c-text-muted)' : 'var(--c-text)'};font-weight:${m.vorbei ? '400' : '600'}">
${UI.esc(m.label)}
</span>
</div>`).join('')}
</div>
</div>`;
}
// ----------------------------------------------------------
// Formulare
// ----------------------------------------------------------
function _showLaeufiForm(hundId, entry, _allLaeufi) {
const isEdit = !!entry;
const v = entry || {};
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen',
body: `
<form id="laeufi-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Beginn *</label>
<input class="form-control" type="date" name="beginn" required value="${v.beginn || today}">
</div>
<div class="form-group">
<label class="form-label">Ende</label>
<input class="form-control" type="date" name="ende" value="${v.ende || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<textarea class="form-control" name="notiz" rows="2">${UI.esc(v.notiz || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="laeufi-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('laeufi-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const data = { beginn: fd.get('beginn'), ende: fd.get('ende') || null, notiz: fd.get('notiz') || null };
try {
if (isEdit) await API.laeufi.update(entry.id, data);
else await API.laeufi.add(hundId, data);
UI.modal.close();
await _loadHundContent(hundId);
UI.toast.success(isEdit ? 'Gespeichert.' : 'Läufigkeit eingetragen.');
} catch (err) { UI.toast.error(err.message); }
});
}
function _showDeckForm(hundId, entry, laeufiList) {
const isEdit = !!entry;
const v = entry || {};
const today = new Date().toISOString().slice(0, 10);
const laeufiOpts = laeufiList.map(l =>
`<option value="${l.id}" ${v.laeufi_id === l.id ? 'selected' : ''}>${_fmtDate(l.beginn)}</option>`
).join('');
UI.modal.open({
title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen',
body: `
<form id="deck-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Deckdatum *</label>
<input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}">
</div>
<div class="form-group">
<label class="form-label">Zugehörige Läufigkeit</label>
<select class="form-control" name="laeufi_id">
<option value=""> keine </option>
${laeufiOpts}
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Rüde</label>
<input class="form-control" name="ruede_name" placeholder="Name des Deckrüden"
value="${UI.esc(v.ruede_name || '')}">
</div>
<div class="form-group">
<label class="form-label">Deckart</label>
<select class="form-control" name="deckart">
<option value="natuerlich" ${(v.deckart||'natuerlich') === 'natuerlich' ? 'selected':''}>Natürlich</option>
<option value="ki_frisch" ${v.deckart === 'ki_frisch' ? 'selected':''}>KI frisch</option>
<option value="ki_tiefgekuehlt"${v.deckart === 'ki_tiefgekuehlt'? 'selected':''}>KI tiefgekühlt</option>
<option value="ki_gefroren" ${v.deckart === 'ki_gefroren' ? 'selected':''}>KI gefroren</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Trächtigkeitsstatus</label>
<select class="form-control" name="traechtig">
<option value="0" ${(v.traechtig === 0 || v.traechtig == null) ? 'selected':''}>Unbekannt</option>
<option value="1" ${v.traechtig === 1 ? 'selected':''}>Trächtig </option>
<option value="-1" ${v.traechtig === -1 ? 'selected':''}>Nicht trächtig</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Ultraschall-Datum</label>
<input class="form-control" type="date" name="ultraschall_datum"
value="${v.ultraschall_datum || ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<textarea class="form-control" name="notiz" rows="2">${UI.esc(v.notiz || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="deck-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('deck-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const data = {
deckdatum: fd.get('deckdatum'),
laeufi_id: fd.get('laeufi_id') ? parseInt(fd.get('laeufi_id')) : null,
ruede_name: fd.get('ruede_name') || null,
deckart: fd.get('deckart'),
traechtig: parseInt(fd.get('traechtig')),
ultraschall_datum: fd.get('ultraschall_datum') || null,
notiz: fd.get('notiz') || null,
};
try {
if (isEdit) await API.laeufi.updateDeck(entry.id, data);
else await API.laeufi.addDeck(hundId, data);
UI.modal.close();
await _loadHundContent(hundId);
UI.toast.success(isEdit ? 'Gespeichert.' : 'Deckung eingetragen.');
} catch (err) { UI.toast.error(err.message); }
});
}
// ----------------------------------------------------------
// Progesterontests-Modal
// ----------------------------------------------------------
async function _showProgModal(hundId, laeufi) {
UI.modal.open({
title: `Progesterontests — ${_fmtDate(laeufi.beginn)}`,
body: `<div id="prog-modal-content"><p style="color:var(--c-text-muted)">Lädt…</p></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="prog-add-btn">${UI.icon('plus')} Test eintragen</button>`,
});
await _loadProgContent(laeufi.id);
document.getElementById('prog-add-btn')
?.addEventListener('click', () => _showProgForm(hundId, laeufi.id, null));
}
async function _loadProgContent(laeufiId) {
const el = document.getElementById('prog-modal-content');
if (!el) return;
const tests = await API.laeufi.listProg(laeufiId);
if (!tests.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm);text-align:center;padding:var(--space-4)">
Noch keine Tests eingetragen.</p>`;
return;
}
el.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="font-size:var(--text-xs);color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.05em">
<th style="text-align:left;padding:var(--space-1) var(--space-2)">Datum</th>
<th style="text-align:right;padding:var(--space-1) var(--space-2)">Wert</th>
<th style="text-align:left;padding:var(--space-1) var(--space-2)">Labor</th>
<th style="padding:var(--space-1) var(--space-2)"></th>
</tr>
</thead>
<tbody>
${tests.map(t => `
<tr style="border-top:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${_fmtDate(t.datum)}</td>
<td style="text-align:right;padding:var(--space-2);font-weight:600">
${t.wert != null ? `${t.wert} ${UI.esc(t.einheit)}` : '—'}
${t.wert != null ? `<span style="font-size:10px;margin-left:4px;color:var(--c-text-muted)">${_progEinschaetzung(t.wert, t.einheit)}</span>` : ''}
</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${t.labor ? UI.esc(t.labor) : '—'}</td>
<td style="padding:var(--space-2);text-align:right">
<button class="btn btn-ghost btn-xs prog-delete-btn" data-id="${t.id}"
style="color:var(--c-danger)">${UI.icon('trash')}</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
el.querySelectorAll('.prog-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await API.laeufi.removeProg(parseInt(btn.dataset.id));
await _loadProgContent(laeufiId);
} catch (err) { UI.toast.error(err.message); }
});
});
}
function _progEinschaetzung(wert, einheit) {
if (einheit !== 'ng/ml') return '';
if (wert < 2) return '(Basiswert)';
if (wert < 5) return '(Anstieg)';
if (wert < 10) return '(LH-Peak Nähe)';
if (wert < 15) return '(Ovulation)';
return '(Post-Ovulation)';
}
function _showProgForm(hundId, laeufiId, _entry) {
const today = new Date().toISOString().slice(0, 10);
UI.modal.open({
title: 'Progesterontest eintragen',
body: `
<form id="prog-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${today}">
</div>
<div class="form-group">
<label class="form-label">Einheit</label>
<select class="form-control" name="einheit">
<option value="ng/ml">ng/ml</option>
<option value="nmol/l">nmol/l</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wert</label>
<input class="form-control" type="number" step="0.01" name="wert" placeholder="z.B. 8.5">
</div>
<div class="form-group">
<label class="form-label">Labor / Tierarzt</label>
<input class="form-control" name="labor" placeholder="z.B. Tierarzt Müller">
</div>
</div>
<div class="form-group">
<label class="form-label">Notiz</label>
<input class="form-control" name="notiz">
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="prog-form" type="submit">Eintragen</button>`,
});
document.getElementById('prog-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const wertRaw = fd.get('wert');
const data = {
datum: fd.get('datum'),
wert: wertRaw ? parseFloat(wertRaw) : null,
einheit: fd.get('einheit'),
labor: fd.get('labor') || null,
notiz: fd.get('notiz') || null,
};
try {
await API.laeufi.addProg(laeufiId, data);
UI.modal.close();
// Prog-Modal neu öffnen
const laeufi = { id: laeufiId, beginn: '' };
await _showProgModal(hundId, laeufi);
await _loadHundContent(hundId);
UI.toast.success('Test eingetragen.');
} catch (err) { UI.toast.error(err.message); }
});
}
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _fmtDate(iso) {
if (!iso) return '—';
const [y, m, d] = iso.slice(0, 10).split('-');
return `${d}.${m}.${y}`;
}
function _daysDiff(a, b) {
if (!a || !b) return '';
return Math.round((new Date(b) - new Date(a)) / 86400000);
}
return { init, refresh, onDogChange };
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v893';
const CACHE_VERSION = 'by-v894';
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