Feature: Läufigkeit & Trächtigkeit — Zyklen, Progesterontests, Deckdaten, Meilensteine (SW by-v894)
This commit is contained in:
parent
5a639d47a9
commit
f3308a6a94
8 changed files with 997 additions and 8 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
307
backend/routes/laeufi.py
Normal 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,))
|
||||
|
|
@ -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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
604
backend/static/js/pages/laeufi.js
Normal file
604
backend/static/js/pages/laeufi.js
Normal 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 };
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue