Feature: Vollständige Züchter-Rolle — Antrag, Würfe, Stammbaum, Genetik

Basis-Features (Schritte 1–11):
- Züchter-Antrag mit Dokument-Upload, Admin-Prüfung, E-Mail-Benachrichtigungen
- Öffentliches Züchter-Profil + Karten-Marker (lila, certificate-Icon)
- Wurfverwaltung: Würfe, Welpen, Gewichtsverlauf, Foto-System
- Wurfbörse (öffentlich) mit Filtersuche nach Rasse/Status
- Läufigkeits-Tracker: Deckdatum + Wurftermin (+63 Tage, nur für Züchter)
- Interessenten-Chat: Kontakt-Button in Wurfbörse und Züchter-Profil
- Sidebar-Einträge: Zuchtkartei + Wurfverwaltung für Züchter/Admin

Stammbaum & Genetik (Schritte 1–8):
- Zuchtkartei: Hunde-Stammdaten mit Vater/Mutter-Verknüpfung
- Stammbaum-Visualisierung: 4 Generationen, horizontales CSS-Grid
- Gesundheitstests (HD, ED, OCD, Augen…) mit farbigen Ergebnis-Badges
- Genetische Tests (MDR1, PRA, DM…): clear/carrier/affected
- Titel & Auszeichnungen (CAC, CACIB, IPO…)
- Probeverpaarung: IK-Berechnung nach Wright + Ampel-Bewertung
- Teilen-Link für öffentliche Hunde-Profile
- Kaufvertrag: druckbares HTML-Dokument pro Welpe

Technisch: 4 neue Route-Dateien, 5 neue Page-Module, 11 neue DB-Tabellen,
icons shield-check + certificate + tree-structure im Sprite — SW by-v465, APP_VER 444
This commit is contained in:
rene 2026-04-28 18:25:21 +02:00
parent 58cb2b4ad3
commit 91340be5a3
24 changed files with 6660 additions and 27 deletions

View file

@ -549,6 +549,8 @@ def _migrate(conn_factory):
("notes", "location_name", "TEXT"),
("notes", "parent_label", "TEXT"),
("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"),
# Züchter-Rolle
("users", "breeder_status", "TEXT"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@ -1223,3 +1225,178 @@ def _migrate(conn_factory):
conn.execute("CREATE INDEX IF NOT EXISTS idx_diary_created ON diary(dog_id, created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(user_id, created_at DESC)")
logger.info("Migration: Performance-Indizes bereit.")
# Züchter-Tabellen
conn.executescript("""
CREATE TABLE IF NOT EXISTS breeder_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
zwingername TEXT NOT NULL,
rasse_text TEXT NOT NULL,
verein TEXT NOT NULL,
vdh_mitglied INTEGER NOT NULL DEFAULT 0,
stadt TEXT NOT NULL,
website TEXT,
beschreibung TEXT,
location_lat REAL,
location_lng REAL,
verified_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS breeder_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dokument_typ TEXT NOT NULL,
file_path TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
logger.info("Migration: breeder_profiles + breeder_documents bereit.")
# Würfe + Welpen
conn.executescript("""
CREATE TABLE IF NOT EXISTS litters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
breeder_id INTEGER NOT NULL REFERENCES breeder_profiles(id) ON DELETE CASCADE,
vater_name TEXT,
mutter_name TEXT,
geburt_datum TEXT,
erwartetes_datum TEXT,
welpen_gesamt INTEGER,
welpen_verfuegbar INTEGER,
beschreibung TEXT,
gesundheitstests TEXT,
preis_spanne TEXT,
status TEXT NOT NULL DEFAULT 'geplant',
sichtbar INTEGER NOT NULL DEFAULT 1,
sichtbar_bis TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_litters_breeder ON litters(breeder_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_litters_status ON litters(status, sichtbar);
CREATE TABLE IF NOT EXISTS puppies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wurf_id INTEGER NOT NULL REFERENCES litters(id) ON DELETE CASCADE,
name TEXT,
geschlecht TEXT,
farbe TEXT,
chip_nr TEXT,
geburtsgewicht REAL,
status TEXT NOT NULL DEFAULT 'verfuegbar',
status_sichtbar INTEGER NOT NULL DEFAULT 1,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_puppies_wurf ON puppies(wurf_id);
CREATE TABLE IF NOT EXISTS puppy_weights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
welpe_id INTEGER NOT NULL REFERENCES puppies(id) ON DELETE CASCADE,
gewicht_g REAL NOT NULL,
gemessen_am TEXT NOT NULL
);
""")
logger.info("Migration: litters + puppies + puppy_weights bereit.")
# Züchter-Fotos
conn.executescript("""
CREATE TABLE IF NOT EXISTS breeder_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
breeder_id INTEGER NOT NULL REFERENCES breeder_profiles(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
thumbnail_path TEXT,
caption TEXT,
is_primary INTEGER NOT NULL DEFAULT 0,
visibility TEXT NOT NULL DEFAULT 'public',
sort_order INTEGER NOT NULL DEFAULT 0,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_breeder_photos_entity
ON breeder_photos(entity_type, entity_id);
""")
logger.info("Migration: breeder_photos bereit.")
# Züchter-Hunde-Stammdaten (Stammbaum, Gesundheit, Genetik, Titel)
conn.executescript("""
CREATE TABLE IF NOT EXISTS zucht_hunde (
id INTEGER PRIMARY KEY AUTOINCREMENT,
breeder_id INTEGER REFERENCES breeder_profiles(id) ON DELETE SET NULL,
name TEXT NOT NULL,
rufname TEXT,
geschlecht TEXT,
geburtsdatum TEXT,
sterbedatum TEXT,
chip_nr TEXT,
taetowiernummer TEXT,
zuchtbuchnummer TEXT,
farbe TEXT,
vater_id INTEGER REFERENCES zucht_hunde(id),
mutter_id INTEGER REFERENCES zucht_hunde(id),
zuechter_name TEXT,
eigentuemer_name TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
notiz TEXT,
foto_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_zucht_hunde_breeder ON zucht_hunde(breeder_id);
CREATE INDEX IF NOT EXISTS idx_zucht_hunde_eltern ON zucht_hunde(vater_id, mutter_id);
CREATE TABLE IF NOT EXISTS dog_health_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE,
test_typ TEXT NOT NULL,
test_name TEXT,
ergebnis TEXT NOT NULL,
untersuch_am TEXT NOT NULL,
gueltig_bis TEXT,
untersucher TEXT,
labor TEXT,
zertifikat_nr TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dog_health_tests_hund ON dog_health_tests(hund_id);
CREATE TABLE IF NOT EXISTS dog_genetic_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE,
marker_name TEXT NOT NULL,
marker_kategorie TEXT,
genotyp TEXT,
ergebnis_klasse TEXT,
getestet_am TEXT NOT NULL,
labor TEXT,
zertifikat_nr TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dog_genetic_tests_hund ON dog_genetic_tests(hund_id);
CREATE TABLE IF NOT EXISTS dog_titles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hund_id INTEGER NOT NULL REFERENCES zucht_hunde(id) ON DELETE CASCADE,
titel_typ TEXT NOT NULL,
titel_name TEXT NOT NULL,
verliehen_am TEXT NOT NULL,
ort TEXT,
richter TEXT,
ausstellung TEXT,
formwert TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dog_titles_hund ON dog_titles(hund_id);
""")
logger.info("Migration: zucht_hunde + dog_health_tests + dog_genetic_tests + dog_titles bereit.")
# Läufigkeit: Deckdatum + Wurftermin
existing_h = [row[1] for row in conn.execute("PRAGMA table_info(health)").fetchall()]
for col, typedef in [("deckdatum", "TEXT"), ("wurftermin", "TEXT")]:
if col not in existing_h:
conn.execute(f"ALTER TABLE health ADD COLUMN {col} {typedef}")
logger.info(f"Migration: health.{col} hinzugefügt.")

View file

@ -1,18 +1,17 @@
"""
BAN YARO KI-Abstraktions-Layer
Drei Modi:
- "off" kein KI, Feature deaktiviert (Free-User ohne lokales Modell)
- "local" LM Studio auf DS1621 (OpenAI-kompatibler Endpunkt, kostenlos)
- "cloud" Claude API (nur für Premium-User, kostet Geld)
Routing-Logik:
1. Immer lokal (LM Studio) zuerst versuchen
2. Falls lokal nicht erreichbar Fallback auf Cloud (Claude), wenn ANTHROPIC_API_KEY gesetzt
3. Falls beides nicht geht KIUnavailableError
Wird über KI_MODE Umgebungsvariable gesteuert:
KI_MODE=local Entwicklung + Free-User auf DS
KI_MODE=cloud Production + Premium-User
Modi (KI_MODE Umgebungsvariable):
KI_MODE=local lokal + Cloud-Fallback wenn Key vorhanden
KI_MODE=cloud lokal + Cloud-Fallback (gleiche Logik, anderer Label)
KI_MODE=off kein KI verfügbar
Wichtig: cloud-Aufrufe IMMER mit requires_premium=True schützen.
Kein API-Geld ohne zahlenden User.
requires_premium=True schützt Features vor Free-Usern, ändert aber nicht das Routing.
"""
import os
@ -138,16 +137,7 @@ async def complete(
if requires_premium and not user_is_premium:
raise KIPremiumRequired("Dieses Feature ist Teil von Ban Yaro Premium.")
# Cloud-Aufruf: Premium UND cloud-Modus
if requires_premium and user_is_premium and KI_MODE == "cloud":
_check_weekly_cloud_limit(user_id)
text = await _cloud_complete(prompt, system, max_tokens, json_mode)
_track_usage(user_id, "cloud")
if return_model:
return (text, CLOUD_MODEL)
return (text, "cloud") if return_source else text
# Lokaler Aufruf + Cloud-Fallback
# Immer lokal zuerst — Cloud ist Fallback wenn lokal nicht erreichbar
if KI_MODE in ("local", "cloud"):
try:
text = await _local_complete(prompt, system, max_tokens, json_mode)
@ -157,7 +147,7 @@ async def complete(
return (text, "local") if return_source else text
except Exception as e:
logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}")
if ANTHROPIC_KEY and (KI_MODE == "cloud" or (requires_premium and user_is_premium)):
if ANTHROPIC_KEY:
logger.info("Fallback auf Cloud-KI.")
_check_weekly_cloud_limit(user_id)
text = await _cloud_complete(prompt, system, max_tokens, json_mode)

View file

@ -156,6 +156,10 @@ from routes.weather import router as weather_router
from routes.social import router as social_router
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.breeder_photos import router as breeder_photos_router
from routes.zucht_hunde import router as zucht_hunde_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -181,6 +185,10 @@ app.include_router(movies_router, prefix="/api/movies", tags=["Filme"])
app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"])
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(breeder_photos_router, prefix="/api", tags=["Züchter-Fotos"])
app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"])
@ -1019,6 +1027,16 @@ async def invite_page(token: str):
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"})
@app.get("/breeder/{zwingername}")
async def breeder_profile_page(zwingername: str):
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"})
@app.get("/litters")
async def litters_page():
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"})
# ------------------------------------------------------------------
# Widget-Vorschau /widget
# ------------------------------------------------------------------

View file

@ -534,6 +534,40 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
return {"ok": True, "job_id": job_id}
# ------------------------------------------------------------------
# GET /api/admin/ki/status — lokale LLM-Erreichbarkeit prüfen
# ------------------------------------------------------------------
@router.get("/ki/status")
async def ki_status(user=Depends(require_mod)):
import httpx
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
result = {
"mode": KI_MODE,
"local_url": LOCAL_BASE_URL if KI_MODE != "off" else None,
"local_model_config": LOCAL_MODEL,
"local_reachable": False,
"local_model_loaded": None,
"cloud_model": CLOUD_MODEL,
"cloud_key_set": bool(ANTHROPIC_KEY),
}
if KI_MODE != "off":
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(f"{LOCAL_BASE_URL}/models")
if resp.status_code == 200:
data = resp.json()
models = data.get("data", [])
result["local_reachable"] = True
if models:
result["local_model_loaded"] = models[0].get("id")
except Exception:
pass
return result
# ------------------------------------------------------------------
# GET /api/admin/system
# ------------------------------------------------------------------

366
backend/routes/breeder.py Normal file
View file

@ -0,0 +1,366 @@
"""BAN YARO — Züchter-Verwaltung (Antrag, Admin-Prüfung)"""
import os
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, require_premium
from mailer import send_email
router = APIRouter()
logger = logging.getLogger(__name__)
_TZ = ZoneInfo("Europe/Berlin")
BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs")
os.makedirs(BREEDER_DOCS_DIR, exist_ok=True)
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "mail@motocamp.de")
APP_URL = os.getenv("APP_URL", "https://banyaro.app")
# ------------------------------------------------------------------
# Dependency: nur verifizierte Züchter + Admins
# ------------------------------------------------------------------
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 require_admin(user=Depends(get_current_user)):
if user["rolle"] != "admin":
raise HTTPException(403, "Nur für Admins.")
return user
# ------------------------------------------------------------------
# GET /api/breeder/status — eigener Antragsstatus
# ------------------------------------------------------------------
@router.get("/breeder/status")
async def breeder_status(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT rolle, breeder_status FROM users WHERE id=?",
(user["id"],)
).fetchone()
if not row:
raise HTTPException(404, "User nicht gefunden.")
profile = None
if row["rolle"] == "breeder":
profile = conn.execute(
"SELECT zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung, verified_at "
"FROM breeder_profiles WHERE user_id=?",
(user["id"],)
).fetchone()
return {
"rolle": row["rolle"],
"breeder_status": row["breeder_status"],
"profile": dict(profile) if profile else None,
}
# ------------------------------------------------------------------
# POST /api/breeder/apply — Antrag stellen
# ------------------------------------------------------------------
@router.post("/breeder/apply")
async def breeder_apply(
zwingername: str = Form(...),
rasse_text: str = Form(...),
verein: str = Form(...),
vdh_mitglied: int = Form(0),
stadt: str = Form(...),
website: str = Form(""),
beschreibung: str = Form(""),
dokument: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
row = conn.execute(
"SELECT rolle, breeder_status FROM users WHERE id=?",
(user["id"],)
).fetchone()
if not row:
raise HTTPException(404, "User nicht gefunden.")
if row["rolle"] == "breeder":
raise HTTPException(400, "Du bist bereits verifizierter Züchter.")
if row["breeder_status"] == "pending":
raise HTTPException(400, "Du hast bereits einen offenen Antrag.")
# Dokument validieren und speichern
data = await dokument.read()
if len(data) > 10 * 1024 * 1024:
raise HTTPException(400, "Dokument zu groß (max. 10 MB).")
ext = os.path.splitext(dokument.filename or "")[1].lower()
if ext not in (".pdf", ".jpg", ".jpeg", ".png", ".webp"):
raise HTTPException(400, "Nur PDF, JPG, PNG oder WebP erlaubt.")
user_doc_dir = os.path.join(BREEDER_DOCS_DIR, str(user["id"]))
os.makedirs(user_doc_dir, exist_ok=True)
filename = f"antrag_{datetime.now(_TZ).strftime('%Y%m%d_%H%M%S')}{ext}"
filepath = os.path.join(user_doc_dir, filename)
with open(filepath, "wb") as f:
f.write(data)
with db() as conn:
conn.execute(
"UPDATE users SET breeder_status='pending' WHERE id=?",
(user["id"],)
)
# Profil-Entwurf anlegen (oder überschreiben wenn rejected)
conn.execute(
"INSERT INTO breeder_profiles (user_id, zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung) "
"VALUES (?,?,?,?,?,?,?,?) "
"ON CONFLICT(user_id) DO UPDATE SET "
"zwingername=excluded.zwingername, rasse_text=excluded.rasse_text, "
"verein=excluded.verein, vdh_mitglied=excluded.vdh_mitglied, "
"stadt=excluded.stadt, website=excluded.website, beschreibung=excluded.beschreibung, "
"verified_at=NULL",
(user["id"], zwingername, rasse_text, verein, vdh_mitglied, stadt, website, beschreibung)
)
conn.execute(
"INSERT INTO breeder_documents (user_id, dokument_typ, file_path) VALUES (?,?,?)",
(user["id"], "antrag", filepath)
)
# Admin benachrichtigen
admin_html = f"""
<h2>Neuer Züchter-Antrag</h2>
<p><b>Von:</b> {user['name']} ({user['email']})</p>
<p><b>Zwingername:</b> {zwingername}</p>
<p><b>Rasse:</b> {rasse_text}</p>
<p><b>Verein:</b> {verein}</p>
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
<p><b>Stadt:</b> {stadt}</p>
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
"""
try:
await send_email(
ADMIN_EMAIL,
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
admin_html,
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
)
except Exception as e:
logger.warning(f"Admin-Mail nicht gesendet: {e}")
return {"message": "Antrag eingereicht. Du wirst per E-Mail benachrichtigt."}
# ------------------------------------------------------------------
# GET /api/admin/breeders/pending — offene Anträge
# ------------------------------------------------------------------
@router.get("/admin/breeders/pending")
async def admin_pending_breeders(admin=Depends(require_admin)):
with db() as conn:
rows = conn.execute("""
SELECT u.id, u.name, u.email, u.created_at, u.breeder_status,
bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
bp.stadt, bp.website, bp.beschreibung, bp.created_at AS antrag_at,
(SELECT COUNT(*) FROM breeder_documents WHERE user_id=u.id) AS dok_count
FROM users u
JOIN breeder_profiles bp ON bp.user_id = u.id
WHERE u.breeder_status = 'pending'
ORDER BY bp.created_at ASC
""").fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags
# ------------------------------------------------------------------
@router.get("/admin/breeder/{user_id}/documents")
async def admin_breeder_documents(user_id: int, admin=Depends(require_admin)):
with db() as conn:
docs = conn.execute(
"SELECT id, dokument_typ, file_path, uploaded_at FROM breeder_documents WHERE user_id=?",
(user_id,)
).fetchall()
return [dict(d) for d in docs]
# ------------------------------------------------------------------
# GET /api/admin/breeder/{user_id}/document/{doc_id} — Datei herunterladen
# ------------------------------------------------------------------
@router.get("/admin/breeder/{user_id}/document/{doc_id}")
async def admin_download_document(user_id: int, doc_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute(
"SELECT file_path FROM breeder_documents WHERE id=? AND user_id=?",
(doc_id, user_id)
).fetchone()
if not row:
raise HTTPException(404, "Dokument nicht gefunden.")
path = row["file_path"]
if not os.path.exists(path):
raise HTTPException(404, "Datei nicht auf Datenträger.")
return FileResponse(path)
class RejectBody(BaseModel):
grund: str
# ------------------------------------------------------------------
# POST /api/admin/breeder/{user_id}/approve — Freischalten
# ------------------------------------------------------------------
@router.post("/admin/breeder/{user_id}/approve")
async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
with db() as conn:
user = conn.execute(
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
(user_id,)
).fetchone()
if not user:
raise HTTPException(404, "User nicht gefunden.")
if user["breeder_status"] != "pending":
raise HTTPException(400, "Kein offener Antrag.")
conn.execute(
"UPDATE users SET rolle='breeder', breeder_status='approved' WHERE id=?",
(user_id,)
)
conn.execute(
"UPDATE breeder_profiles SET verified_at=datetime('now') WHERE user_id=?",
(user_id,)
)
# Bestätigungs-Mail
html = f"""
<h2>Willkommen als Züchter bei Banyaro!</h2>
<p>Hallo {user['name']},</p>
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
<p><a href="{APP_URL}">Zur App</a></p>
"""
try:
await send_email(
user["email"],
"Willkommen als Züchter bei Banyaro!",
html,
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
)
except Exception as e:
logger.warning(f"Bestätigungs-Mail nicht gesendet: {e}")
return {"message": f"{user['name']} als Züchter freigeschaltet."}
# ------------------------------------------------------------------
# POST /api/admin/breeder/{user_id}/reject — Ablehnen
# ------------------------------------------------------------------
@router.post("/admin/breeder/{user_id}/reject")
async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(require_admin)):
with db() as conn:
user = conn.execute(
"SELECT id, name, email, breeder_status FROM users WHERE id=?",
(user_id,)
).fetchone()
if not user:
raise HTTPException(404, "User nicht gefunden.")
if user["breeder_status"] != "pending":
raise HTTPException(400, "Kein offener Antrag.")
conn.execute(
"UPDATE users SET breeder_status='rejected' WHERE id=?",
(user_id,)
)
# Ablehnungs-Mail
html = f"""
<h2>Dein Züchter-Antrag bei Banyaro</h2>
<p>Hallo {user['name']},</p>
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
<p><b>Grund:</b> {body.grund}</p>
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
"""
try:
await send_email(
user["email"],
"Dein Züchter-Antrag bei Banyaro",
html,
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
)
except Exception as e:
logger.warning(f"Ablehnungs-Mail nicht gesendet: {e}")
return {"message": f"Antrag von {user['name']} abgelehnt."}
# ------------------------------------------------------------------
# GET /api/breeder/profil/{zwingername} — öffentliches Profil
# ------------------------------------------------------------------
@router.get("/breeder/profil/{zwingername}")
async def breeder_public_profile(zwingername: str):
with db() as conn:
row = conn.execute("""
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied,
bp.stadt, bp.website, bp.beschreibung,
bp.location_lat, bp.location_lng, bp.verified_at, bp.created_at,
u.id AS zuechter_user_id,
u.name AS zuechter_name
FROM breeder_profiles bp
JOIN users u ON u.id = bp.user_id
WHERE LOWER(bp.zwingername) = LOWER(?)
AND u.rolle = 'breeder'
AND u.breeder_status = 'approved'
""", (zwingername,)).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
return dict(row)
# ------------------------------------------------------------------
# PUT /api/breeder/profile — eigenes Profil bearbeiten
# ------------------------------------------------------------------
class BreederProfileUpdate(BaseModel):
zwingername: Optional[str] = None
rasse_text: Optional[str] = None
verein: Optional[str] = None
vdh_mitglied: Optional[int] = None
stadt: Optional[str] = None
website: Optional[str] = None
beschreibung: Optional[str] = None
@router.put("/breeder/profile")
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)):
with db() as conn:
profile = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
).fetchone()
if not profile:
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if not fields:
return {"message": "Keine Änderungen."}
set_clause = ", ".join(f"{k}=?" for k in fields)
conn.execute(
f"UPDATE breeder_profiles SET {set_clause} WHERE id=?",
(*fields.values(), profile["id"])
)
return {"message": "Profil aktualisiert."}
# ------------------------------------------------------------------
# GET /api/breeder/map — alle Züchter für Karte
# ------------------------------------------------------------------
@router.get("/breeder/map")
async def breeder_map_markers():
with db() as conn:
rows = conn.execute("""
SELECT bp.id, bp.zwingername, bp.rasse_text, bp.stadt,
bp.location_lat, bp.location_lng
FROM breeder_profiles bp
JOIN users u ON u.id = bp.user_id
WHERE bp.verified_at IS NOT NULL
AND u.rolle = 'breeder'
""").fetchall()
return [dict(r) for r in rows]

View file

@ -0,0 +1,356 @@
"""BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional
import os, logging
from database import db
from auth import get_current_user, get_current_user_optional
from media_utils import validate_upload, generate_preview
import uuid
router = APIRouter()
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
_VALID_ENTITY_TYPES = {"breeder", "litter", "puppy", "parent"}
# ------------------------------------------------------------------
# Dependency: nur verifizierte Züchter + Admins
# ------------------------------------------------------------------
def _require_breeder(user=Depends(get_current_user)):
if user["rolle"] not in ("breeder", "admin"):
raise HTTPException(403, "Nur für Züchter.")
return user
# ------------------------------------------------------------------
# Modelle
# ------------------------------------------------------------------
class VisibilityBody(BaseModel):
visibility: str
class CaptionBody(BaseModel):
caption: Optional[str] = None
# ------------------------------------------------------------------
# Hilfsfunktion: Züchter-Profil für User laden
# ------------------------------------------------------------------
def _get_breeder_profile(conn, user_id: int):
row = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,)
).fetchone()
if not row:
raise HTTPException(404, "Züchter-Profil nicht gefunden.")
return row["id"]
# ------------------------------------------------------------------
# POST /api/breeder/photos/upload — Foto hochladen
# ------------------------------------------------------------------
@router.post("/breeder/photos/upload")
async def upload_photo(
entity_type: str = Form(...),
entity_id: int = Form(...),
visibility: str = Form("public"),
caption: str = Form(""),
is_primary: int = Form(0),
file: UploadFile = File(...),
user=Depends(_require_breeder),
):
if entity_type not in _VALID_ENTITY_TYPES:
raise HTTPException(400, f"Ungültiger entity_type. Erlaubt: {', '.join(_VALID_ENTITY_TYPES)}")
if visibility not in ("public", "inquiry", "private"):
raise HTTPException(400, "Ungültige Sichtbarkeit.")
raw_data = await file.read()
filename = file.filename or "upload.jpg"
try:
validate_upload(raw_data, filename)
except ValueError as e:
raise HTTPException(400, str(e))
ext = os.path.splitext(filename)[1].lower() or ".jpg"
with db() as conn:
breeder_id = _get_breeder_profile(conn, user["id"])
# Ownership prüfen (für entity_type != 'breeder')
if entity_type == "litter":
row = conn.execute(
"SELECT id FROM litters WHERE id=? AND breeder_id=?",
(entity_id, breeder_id)
).fetchone()
if not row and user["rolle"] != "admin":
raise HTTPException(403, "Kein Zugriff auf diesen Wurf.")
elif entity_type == "puppy":
row = conn.execute(
"""SELECT p.id FROM litter_puppies p
JOIN litters l ON l.id=p.litter_id
WHERE p.id=? AND l.breeder_id=?""",
(entity_id, breeder_id)
).fetchone()
if not row and user["rolle"] != "admin":
raise HTTPException(403, "Kein Zugriff auf diesen Welpen.")
elif entity_type == "parent":
# parent kann frei hochgeladen werden solange breeder stimmt
pass
elif entity_type == "breeder":
# entity_id muss das eigene Profil sein
if entity_id != breeder_id and user["rolle"] != "admin":
raise HTTPException(403, "Kein Zugriff auf dieses Züchter-Profil.")
# Speicherpfad anlegen
save_dir = os.path.join(MEDIA_DIR, "breeders", str(breeder_id), entity_type)
os.makedirs(save_dir, exist_ok=True)
file_uuid = str(uuid.uuid4())
file_path = os.path.join(save_dir, f"{file_uuid}.webp")
# Thumbnail erzeugen
thumb_bytes = generate_preview(raw_data, ext)
thumb_path = None
if thumb_bytes:
thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp")
with open(thumb_path, "wb") as f:
f.write(thumb_bytes)
# Originalbild konvertieren und speichern
# generate_preview liefert WebP, für das Original nehmen wir Pillow direkt
try:
import io
from PIL import Image, ImageOps
img = Image.open(io.BytesIO(raw_data))
img = ImageOps.exif_transpose(img)
img = img.convert("RGB")
img.save(file_path, format="WEBP", quality=85)
except Exception:
# Fallback: Rohdaten speichern
with open(file_path, "wb") as f:
f.write(raw_data)
# Relative Pfade für DB (relativ zu MEDIA_DIR)
rel_file = os.path.relpath(file_path, MEDIA_DIR)
rel_thumb = os.path.relpath(thumb_path, MEDIA_DIR) if thumb_path else None
# Falls is_primary: alle anderen auf 0 setzen
if is_primary:
conn.execute(
"UPDATE breeder_photos SET is_primary=0 WHERE breeder_id=? AND entity_type=? AND entity_id=?",
(breeder_id, entity_type, entity_id)
)
conn.execute(
"""INSERT INTO breeder_photos
(breeder_id, entity_type, entity_id, file_path, thumbnail_path,
caption, is_primary, visibility, sort_order, uploaded_at)
VALUES (?,?,?,?,?,?,?,?,
(SELECT COALESCE(MAX(sort_order),0)+1 FROM breeder_photos
WHERE breeder_id=? AND entity_type=? AND entity_id=?),
datetime('now'))""",
(breeder_id, entity_type, entity_id, rel_file, rel_thumb,
caption.strip() or None, 1 if is_primary else 0,
visibility,
breeder_id, entity_type, entity_id)
)
photo_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
photo = conn.execute(
"SELECT * FROM breeder_photos WHERE id=?", (photo_id,)
).fetchone()
return _photo_dict(photo)
# ------------------------------------------------------------------
# GET /api/photos/{entity_type}/{entity_id} — Fotos abrufen
# ------------------------------------------------------------------
@router.get("/photos/{entity_type}/{entity_id}")
async def get_photos(
entity_type: str,
entity_id: int,
user=Depends(get_current_user_optional),
):
if entity_type not in _VALID_ENTITY_TYPES:
raise HTTPException(400, f"Ungültiger entity_type.")
with db() as conn:
# Prüfen ob anfragender User Besitzer oder Admin ist
is_owner = False
if user:
if user["rolle"] == "admin":
is_owner = True
else:
bp = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
).fetchone()
if bp:
# Besitzer wenn entity dem Züchter gehört
if entity_type == "breeder":
is_owner = (bp["id"] == entity_id)
elif entity_type == "litter":
row = conn.execute(
"SELECT id FROM litters WHERE id=? AND breeder_id=?",
(entity_id, bp["id"])
).fetchone()
is_owner = bool(row)
elif entity_type == "puppy":
row = conn.execute(
"""SELECT p.id FROM litter_puppies p
JOIN litters l ON l.id=p.litter_id
WHERE p.id=? AND l.breeder_id=?""",
(entity_id, bp["id"])
).fetchone()
is_owner = bool(row)
elif entity_type == "parent":
row = conn.execute(
"SELECT id FROM breeder_photos WHERE entity_type='parent' AND entity_id=? AND breeder_id=?",
(entity_id, bp["id"])
).fetchone()
is_owner = bool(row)
if is_owner:
photos = conn.execute(
"SELECT * FROM breeder_photos WHERE entity_type=? AND entity_id=? ORDER BY sort_order, id",
(entity_type, entity_id)
).fetchall()
else:
photos = conn.execute(
"SELECT * FROM breeder_photos WHERE entity_type=? AND entity_id=? AND visibility='public' ORDER BY sort_order, id",
(entity_type, entity_id)
).fetchall()
return [_photo_dict(p) for p in photos]
# ------------------------------------------------------------------
# PATCH /api/breeder/photos/{id}/visibility
# ------------------------------------------------------------------
@router.patch("/breeder/photos/{photo_id}/visibility")
async def update_visibility(
photo_id: int,
body: VisibilityBody,
user=Depends(_require_breeder),
):
if body.visibility not in ("public", "inquiry", "private"):
raise HTTPException(400, "Ungültige Sichtbarkeit.")
with db() as conn:
photo = _get_own_photo(conn, photo_id, user)
conn.execute(
"UPDATE breeder_photos SET visibility=? WHERE id=?",
(body.visibility, photo_id)
)
updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone()
return _photo_dict(updated)
# ------------------------------------------------------------------
# PATCH /api/breeder/photos/{id}/primary
# ------------------------------------------------------------------
@router.patch("/breeder/photos/{photo_id}/primary")
async def set_primary(
photo_id: int,
user=Depends(_require_breeder),
):
with db() as conn:
photo = _get_own_photo(conn, photo_id, user)
# Alle anderen auf 0
conn.execute(
"UPDATE breeder_photos SET is_primary=0 WHERE breeder_id=? AND entity_type=? AND entity_id=?",
(photo["breeder_id"], photo["entity_type"], photo["entity_id"])
)
conn.execute(
"UPDATE breeder_photos SET is_primary=1 WHERE id=?", (photo_id,)
)
updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone()
return _photo_dict(updated)
# ------------------------------------------------------------------
# PATCH /api/breeder/photos/{id}/caption
# ------------------------------------------------------------------
@router.patch("/breeder/photos/{photo_id}/caption")
async def update_caption(
photo_id: int,
body: CaptionBody,
user=Depends(_require_breeder),
):
with db() as conn:
_get_own_photo(conn, photo_id, user)
conn.execute(
"UPDATE breeder_photos SET caption=? WHERE id=?",
(body.caption, photo_id)
)
updated = conn.execute("SELECT * FROM breeder_photos WHERE id=?", (photo_id,)).fetchone()
return _photo_dict(updated)
# ------------------------------------------------------------------
# DELETE /api/breeder/photos/{id}
# ------------------------------------------------------------------
@router.delete("/breeder/photos/{photo_id}")
async def delete_photo(
photo_id: int,
user=Depends(_require_breeder),
):
with db() as conn:
photo = _get_own_photo(conn, photo_id, user)
# Dateien löschen
for rel in (photo["file_path"], photo["thumbnail_path"]):
if rel:
abs_path = os.path.join(MEDIA_DIR, rel)
try:
if os.path.isfile(abs_path):
os.unlink(abs_path)
except OSError as e:
logger.warning("Konnte Datei nicht löschen: %s%s", abs_path, e)
conn.execute("DELETE FROM breeder_photos WHERE id=?", (photo_id,))
return {"ok": True}
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _get_own_photo(conn, photo_id: int, user: dict):
"""Lädt das Foto und prüft Ownership. Wirft 403/404 bei Fehler."""
photo = conn.execute(
"SELECT * FROM breeder_photos WHERE id=?", (photo_id,)
).fetchone()
if not photo:
raise HTTPException(404, "Foto nicht gefunden.")
if user["rolle"] == "admin":
return photo
# Prüfe ob Züchter-Profil dem User gehört
bp = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user["id"],)
).fetchone()
if not bp or bp["id"] != photo["breeder_id"]:
raise HTTPException(403, "Kein Zugriff auf dieses Foto.")
return photo
def _photo_dict(row) -> dict:
"""Konvertiert DB-Zeile in API-Response-Dict mit öffentlichen URLs."""
if row is None:
return {}
d = dict(row)
# Öffentliche URLs ableiten
if d.get("file_path"):
d["url"] = "/media/" + d["file_path"].replace("\\", "/")
else:
d["url"] = None
if d.get("thumbnail_path"):
d["thumbnail_url"] = "/media/" + d["thumbnail_path"].replace("\\", "/")
else:
d["thumbnail_url"] = d.get("url")
return d

View file

@ -48,6 +48,9 @@ class HealthCreate(BaseModel):
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
# Tierarzt-Verknüpfung
tierarzt_id: Optional[int] = None
# Züchter
deckdatum: Optional[str] = None
wurftermin: Optional[str] = None
class HealthUpdate(BaseModel):
@ -70,6 +73,8 @@ class HealthUpdate(BaseModel):
erinnerung: Optional[int] = None
intervall_tage: Optional[int] = None
tierarzt_id: Optional[int] = None
deckdatum: Optional[str] = None
wurftermin: Optional[str] = None
# ------------------------------------------------------------------
@ -159,13 +164,15 @@ async def create_health(dog_id: int, data: HealthCreate,
(dog_id, typ, bezeichnung, datum, naechstes, notiz,
wert, einheit, charge_nr, tierarzt_name, kosten, diagnose,
dosierung, haeufigkeit, aktiv, bis_datum,
schweregrad, reaktion, erinnerung, tierarzt_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
schweregrad, reaktion, erinnerung, tierarzt_id,
deckdatum, wurftermin)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes,
data.notiz, data.wert, data.einheit, data.charge_nr,
data.tierarzt_name, data.kosten, data.diagnose, data.dosierung,
data.haeufigkeit, data.aktiv, data.bis_datum,
data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id)
data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id,
data.deckdatum, data.wurftermin)
)
row = conn.execute(
"SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",

575
backend/routes/litters.py Normal file
View file

@ -0,0 +1,575 @@
"""BAN YARO — Wurfverwaltung (Züchter: Würfe & Welpen)"""
import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Dependency: nur verifizierte Züchter + Admins
# ------------------------------------------------------------------
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
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LitterCreate(BaseModel):
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
geburt_datum: Optional[str] = None # YYYY-MM-DD
erwartetes_datum: Optional[str] = None # YYYY-MM-DD
welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = None
gesundheitstests: Optional[str] = None
preis_spanne: Optional[str] = None
status: str = "geplant" # geplant|geboren|verfuegbar|abgeschlossen
sichtbar: int = 0
sichtbar_bis: Optional[str] = None
class LitterUpdate(BaseModel):
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
geburt_datum: Optional[str] = None
erwartetes_datum: Optional[str] = None
welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = None
gesundheitstests: Optional[str] = None
preis_spanne: Optional[str] = None
status: Optional[str] = None
sichtbar: Optional[int] = None
sichtbar_bis: Optional[str] = None
class PuppyCreate(BaseModel):
name: Optional[str] = None
geschlecht: Optional[str] = None # maennlich|weiblich
farbe: Optional[str] = None
chip_nr: Optional[str] = None
geburtsgewicht: Optional[float] = None # Gramm
status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben
status_sichtbar: int = 1
notiz: Optional[str] = None
class PuppyUpdate(BaseModel):
name: Optional[str] = None
geschlecht: Optional[str] = None
farbe: Optional[str] = None
chip_nr: Optional[str] = None
geburtsgewicht: Optional[float] = None
status: Optional[str] = None
status_sichtbar: Optional[int] = None
notiz: Optional[str] = None
class WeightEntry(BaseModel):
gewicht_g: float
gemessen_am: str # YYYY-MM-DD
# ------------------------------------------------------------------
# Hilfsfunktion: Züchter-Profil des Users ermitteln
# ------------------------------------------------------------------
def _get_breeder_profile(user_id: int, conn):
row = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,)
).fetchone()
return row
def _check_litter_owner(litter_id: int, user, conn):
"""Gibt den Wurf zurück wenn der User Eigentümer oder Admin ist."""
litter = conn.execute(
"SELECT l.*, bp.user_id AS owner_user_id "
"FROM litters l JOIN breeder_profiles bp ON bp.id = l.breeder_id "
"WHERE l.id=?",
(litter_id,)
).fetchone()
if not litter:
raise HTTPException(404, "Wurf nicht gefunden.")
if user["rolle"] != "admin" and litter["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
return litter
# ------------------------------------------------------------------
# GET /api/litters — öffentliche Übersicht
# ------------------------------------------------------------------
@router.get("/litters")
async def list_public_litters(
rasse: Optional[str] = None,
status: Optional[str] = None,
):
today = date.today().isoformat()
with db() as conn:
q = """
SELECT l.*,
bp.zwingername, bp.rasse_text, bp.stadt,
bp.user_id AS breeder_user_id,
u.name AS zuechter_name
FROM litters l
JOIN breeder_profiles bp ON bp.id = l.breeder_id
JOIN users u ON u.id = bp.user_id
WHERE l.sichtbar = 1
AND (l.sichtbar_bis IS NULL OR l.sichtbar_bis >= ?)
"""
params = [today]
if status:
q += " AND l.status = ?"
params.append(status)
else:
q += " AND l.status IN ('geplant', 'geboren', 'verfuegbar')"
if rasse:
q += " AND LOWER(bp.rasse_text) LIKE LOWER(?)"
params.append(f"%{rasse}%")
q += " ORDER BY l.created_at DESC"
rows = conn.execute(q, params).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# GET /api/litters/my — eigene Würfe (Züchter)
# ------------------------------------------------------------------
@router.get("/litters/my")
async def my_litters(user=Depends(_require_breeder)):
with db() as conn:
if user["rolle"] == "admin":
# Admin ohne eigenes Profil sieht alle Würfe aller Züchter
profile = _get_breeder_profile(user["id"], conn)
if not profile:
rows = conn.execute(
"SELECT l.*, bp.zwingername FROM litters l "
"JOIN breeder_profiles bp ON bp.id = l.breeder_id "
"ORDER BY l.created_at DESC"
).fetchall()
return [dict(r) for r in rows]
else:
profile = _get_breeder_profile(user["id"], conn)
if not profile:
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
rows = conn.execute(
"SELECT * FROM litters WHERE breeder_id=? ORDER BY created_at DESC",
(profile["id"],)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/litters — neuen Wurf anlegen
# ------------------------------------------------------------------
@router.post("/litters", status_code=201)
async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
with db() as conn:
profile = _get_breeder_profile(user["id"], conn)
if not profile:
raise HTTPException(404, "Züchter-Profil nicht gefunden.")
cur = conn.execute(
"""INSERT INTO litters
(breeder_id, vater_name, mutter_name, geburt_datum, erwartetes_datum,
welpen_gesamt, welpen_verfuegbar, beschreibung, gesundheitstests,
preis_spanne, status, sichtbar, sichtbar_bis)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
profile["id"],
body.vater_name,
body.mutter_name,
body.geburt_datum,
body.erwartetes_datum,
body.welpen_gesamt,
body.welpen_verfuegbar,
body.beschreibung,
body.gesundheitstests,
body.preis_spanne,
body.status,
body.sichtbar,
body.sichtbar_bis,
)
)
row = conn.execute(
"SELECT * FROM litters WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /api/litters/{id} — Wurf-Detail (öffentlich wenn sichtbar=1)
# ------------------------------------------------------------------
@router.get("/litters/{litter_id}")
async def get_litter(litter_id: int, user=Depends(get_current_user_optional)):
today = date.today().isoformat()
with db() as conn:
row = conn.execute(
"""SELECT l.*,
bp.zwingername, bp.rasse_text, bp.stadt,
bp.user_id AS owner_user_id,
u.name AS zuechter_name
FROM litters l
JOIN breeder_profiles bp ON bp.id = l.breeder_id
JOIN users u ON u.id = bp.user_id
WHERE l.id=?""",
(litter_id,)
).fetchone()
if not row:
raise HTTPException(404, "Wurf nicht gefunden.")
is_owner = user and (
user["rolle"] == "admin" or row["owner_user_id"] == user["id"]
)
# Nicht-öffentliche Würfe nur für Züchter/Admin
if not row["sichtbar"] and not is_owner:
raise HTTPException(404, "Wurf nicht gefunden.")
# Abgelaufene Würfe
if row["sichtbar_bis"] and row["sichtbar_bis"] < today and not is_owner:
raise HTTPException(404, "Wurf nicht mehr verfügbar.")
return dict(row)
# ------------------------------------------------------------------
# PUT /api/litters/{id} — Wurf bearbeiten
# ------------------------------------------------------------------
@router.put("/litters/{litter_id}")
async def update_litter(litter_id: int, body: LitterUpdate, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(litter_id)
conn.execute(
f"UPDATE litters SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute("SELECT * FROM litters WHERE id=?", (litter_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/litters/{id} — Wurf löschen
# ------------------------------------------------------------------
@router.delete("/litters/{litter_id}", status_code=204)
async def delete_litter(litter_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
conn.execute("DELETE FROM puppy_weights WHERE welpe_id IN (SELECT id FROM puppies WHERE wurf_id=?)", (litter_id,))
conn.execute("DELETE FROM puppies WHERE wurf_id=?", (litter_id,))
conn.execute("DELETE FROM litters WHERE id=?", (litter_id,))
return None
# ------------------------------------------------------------------
# GET /api/litters/{id}/puppies — Welpen eines Wurfs
# ------------------------------------------------------------------
@router.get("/litters/{litter_id}/puppies")
async def list_puppies(litter_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
litter = conn.execute(
"""SELECT l.sichtbar, l.sichtbar_bis, bp.user_id AS owner_user_id
FROM litters l JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE l.id=?""",
(litter_id,)
).fetchone()
if not litter:
raise HTTPException(404, "Wurf nicht gefunden.")
is_owner = user and (
user["rolle"] == "admin" or litter["owner_user_id"] == user["id"]
)
q = "SELECT * FROM puppies WHERE wurf_id=?"
params = [litter_id]
if not is_owner:
q += " AND status_sichtbar=1"
rows = conn.execute(q + " ORDER BY created_at ASC", params).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/litters/{id}/puppies — Welpe anlegen
# ------------------------------------------------------------------
@router.post("/litters/{litter_id}/puppies", status_code=201)
async def add_puppy(litter_id: int, body: PuppyCreate, user=Depends(_require_breeder)):
with db() as conn:
_check_litter_owner(litter_id, user, conn)
cur = conn.execute(
"""INSERT INTO puppies
(wurf_id, name, geschlecht, farbe, chip_nr, geburtsgewicht,
status, status_sichtbar, notiz)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
litter_id,
body.name,
body.geschlecht,
body.farbe,
body.chip_nr,
body.geburtsgewicht,
body.status,
body.status_sichtbar,
body.notiz,
)
)
row = conn.execute(
"SELECT * FROM puppies WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# PUT /api/litters/puppies/{id} — Welpe bearbeiten
# ------------------------------------------------------------------
@router.put("/litters/puppies/{puppy_id}")
async def update_puppy(puppy_id: int, body: PuppyUpdate, user=Depends(_require_breeder)):
with db() as conn:
puppy = conn.execute(
"""SELECT p.*, l.id AS litter_id, bp.user_id AS owner_user_id
FROM puppies p
JOIN litters l ON l.id = p.wurf_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE p.id=?""",
(puppy_id,)
).fetchone()
if not puppy:
raise HTTPException(404, "Welpe nicht gefunden.")
if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(puppy_id)
conn.execute(
f"UPDATE puppies SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute("SELECT * FROM puppies WHERE id=?", (puppy_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /api/litters/puppies/{id}/weights — Gewichtsverlauf laden
# ------------------------------------------------------------------
@router.get("/litters/puppies/{puppy_id}/weights")
async def get_weights(puppy_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
rows = conn.execute(
"SELECT id, gewicht_g, gemessen_am FROM puppy_weights WHERE welpe_id=? ORDER BY gemessen_am DESC",
(puppy_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/litters/puppies/{id}/weight — Gewicht erfassen
# ------------------------------------------------------------------
@router.post("/litters/puppies/{puppy_id}/weight", status_code=201)
async def add_weight(puppy_id: int, body: WeightEntry, user=Depends(_require_breeder)):
with db() as conn:
puppy = conn.execute(
"""SELECT p.id, bp.user_id AS owner_user_id
FROM puppies p
JOIN litters l ON l.id = p.wurf_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
WHERE p.id=?""",
(puppy_id,)
).fetchone()
if not puppy:
raise HTTPException(404, "Welpe nicht gefunden.")
if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
cur = conn.execute(
"INSERT INTO puppy_weights (welpe_id, gewicht_g, gemessen_am) VALUES (?,?,?)",
(puppy_id, body.gewicht_g, body.gemessen_am)
)
row = conn.execute(
"SELECT * FROM puppy_weights WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# GET /api/litters/puppies/{id}/contract — Kaufvertrag als HTML
# ------------------------------------------------------------------
@router.get("/litters/puppies/{puppy_id}/contract")
async def generate_contract(
puppy_id: int,
kaeufer_name: str,
kaeufer_adresse: str,
kaeufer_email: str = "",
preis: str = "",
user=Depends(_require_breeder),
):
with db() as conn:
puppy = conn.execute(
"""SELECT p.*, l.geburt_datum, l.id AS litter_id,
bp.user_id AS owner_user_id,
bp.zwingername, bp.rasse_text, bp.stadt,
u.name AS zuechter_name, u.email AS zuechter_email
FROM puppies p
JOIN litters l ON l.id = p.wurf_id
JOIN breeder_profiles bp ON bp.id = l.breeder_id
JOIN users u ON u.id = bp.user_id
WHERE p.id=?""",
(puppy_id,)
).fetchone()
if not puppy:
raise HTTPException(404, "Welpe nicht gefunden.")
if user["rolle"] != "admin" and puppy["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
def esc(s):
if not s:
return ""
return (str(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
heute = date.today().strftime("%d.%m.%Y")
geschlecht_label = (
"Rüde" if puppy["geschlecht"] == "maennlich" else
"Hündin" if puppy["geschlecht"] == "weiblich" else ""
)
geburtsdatum = ""
if puppy["geburt_datum"]:
try:
from datetime import date as _date
gd = _date.fromisoformat(puppy["geburt_datum"])
geburtsdatum = gd.strftime("%d.%m.%Y")
except Exception:
geburtsdatum = esc(puppy["geburt_datum"])
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Kaufvertrag {esc(puppy['name'] or 'Welpe')}</title>
<style>
body {{
font-family: Arial, Helvetica, sans-serif;
font-size: 12pt;
margin: 2cm 2.5cm;
color: #111;
}}
h1 {{ font-size: 18pt; text-align: center; margin-bottom: 0.2cm; }}
h2 {{ font-size: 13pt; margin-top: 1.2cm; border-bottom: 1px solid #999; padding-bottom: 3px; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 0.4cm; }}
td {{ padding: 4px 8px; vertical-align: top; }}
td:first-child {{ width: 45%; font-weight: bold; color: #444; }}
.section {{ margin-top: 1cm; }}
.signature-block {{ display: flex; gap: 4cm; margin-top: 2cm; }}
.signature-line {{ flex: 1; }}
.signature-line hr {{ border: none; border-top: 1px solid #333; margin-top: 2cm; }}
.signature-line p {{ font-size: 10pt; color: #555; margin: 4px 0 0; }}
@media print {{
.no-print {{ display: none; }}
body {{ margin: 1.5cm 2cm; }}
}}
</style>
</head>
<body>
<p style="text-align:right;font-size:10pt;color:#666">Datum: {heute}</p>
<h1>Kaufvertrag über einen Welpen</h1>
<p style="text-align:center;color:#555;font-size:10pt;margin-top:0">
Rassehund · {esc(puppy['rasse_text'] or '')}
</p>
<h2>Verkäufer (Züchter)</h2>
<table>
<tr><td>Zwingername</td><td>{esc(puppy['zwingername'] or '')}</td></tr>
<tr><td>Name</td><td>{esc(puppy['zuechter_name'] or '')}</td></tr>
<tr><td>Ort</td><td>{esc(puppy['stadt'] or '')}</td></tr>
<tr><td>E-Mail</td><td>{esc(puppy['zuechter_email'] or '')}</td></tr>
</table>
<h2>Käufer</h2>
<table>
<tr><td>Name</td><td>{esc(kaeufer_name)}</td></tr>
<tr><td>Adresse</td><td>{esc(kaeufer_adresse)}</td></tr>
<tr><td>E-Mail</td><td>{esc(kaeufer_email) if kaeufer_email else ''}</td></tr>
</table>
<h2>Welpe</h2>
<table>
<tr><td>Name</td><td>{esc(puppy['name'] or '')}</td></tr>
<tr><td>Geschlecht</td><td>{geschlecht_label}</td></tr>
<tr><td>Rasse</td><td>{esc(puppy['rasse_text'] or '')}</td></tr>
<tr><td>Geburtsdatum</td><td>{geburtsdatum or ''}</td></tr>
<tr><td>Chip-Nr.</td><td>{esc(puppy['chip_nr'] or '')}</td></tr>
<tr><td>Farbe / Fell</td><td>{esc(puppy['farbe'] or '')}</td></tr>
</table>
<h2>Kaufpreis</h2>
<table>
<tr><td>Vereinbarter Preis</td><td><strong>{esc(preis) if preis else ''}</strong></td></tr>
</table>
<div class="section">
<h2>Allgemeine Vereinbarungen</h2>
<p>Der Käufer bestätigt, den Welpen in einem einwandfreien Gesundheitszustand entgegengenommen zu haben.
Der Verkäufer sichert zu, dass der Welpe nach bestem Wissen und Gewissen aufgezogen wurde und die
angegebenen Gesundheitsinformationen der Wahrheit entsprechen. Der Käufer verpflichtet sich, den
Welpen artgerecht zu halten und tierärztlich versorgen zu lassen.</p>
</div>
<div class="signature-block">
<div class="signature-line">
<hr>
<p>Ort, Datum &amp; Unterschrift Verkäufer</p>
</div>
<div class="signature-line">
<hr>
<p>Ort, Datum &amp; Unterschrift Käufer</p>
</div>
</div>
<p class="no-print" style="margin-top:1.5cm;text-align:center">
<button onclick="window.print()"
style="padding:8px 24px;font-size:12pt;cursor:pointer;border:1px solid #333;border-radius:4px;background:#f5f5f5">
Drucken / Als PDF speichern
</button>
</p>
</body>
</html>"""
return HTMLResponse(content=html)

View file

@ -0,0 +1,779 @@
"""BAN YARO — Zuchtkartei (Hunde, Gesundheitstests, Gentests, Titel, Stammbaum, IK)"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
logger = logging.getLogger(__name__)
# ------------------------------------------------------------------
# Dependency: nur verifizierte Züchter + Admins
# ------------------------------------------------------------------
def _require_breeder(user=Depends(get_current_user)):
if user["rolle"] not in ("breeder", "admin"):
raise HTTPException(403, "Nur für Züchter.")
return user
# ------------------------------------------------------------------
# Hilfsfunktionen: Ownership
# ------------------------------------------------------------------
def _get_breeder_profile_id(user_id: int, conn) -> Optional[int]:
"""Gibt die breeder_profiles.id des Users zurück, oder None."""
row = conn.execute(
"SELECT id FROM breeder_profiles WHERE user_id=?", (user_id,)
).fetchone()
return row["id"] if row else None
def _check_hund_owner(hund_id: int, user: dict, conn) -> dict:
"""Gibt den Hund zurück wenn der User Eigentümer oder Admin ist."""
row = conn.execute(
"""SELECT zh.*, bp.user_id AS owner_user_id
FROM zucht_hunde zh
LEFT 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 dict(row)
def _check_hund_access(hund_id: int, user: Optional[dict], conn) -> dict:
"""Zugriff auf Hund: öffentlich wenn is_public=1, sonst nur Owner/Admin."""
row = conn.execute(
"""SELECT zh.*, bp.user_id AS owner_user_id
FROM zucht_hunde zh
LEFT 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.")
is_owner = user and (
user["rolle"] == "admin" or row["owner_user_id"] == user["id"]
)
if not row["is_public"] and not is_owner:
raise HTTPException(404, "Hund nicht gefunden.")
return dict(row)
# ------------------------------------------------------------------
# Stammbaum-Algorithmus
# ------------------------------------------------------------------
def _build_tree(conn, hund_id, depth: int):
if depth == 0 or hund_id is None:
return None
row = conn.execute(
"SELECT * FROM zucht_hunde WHERE id=?", (hund_id,)
).fetchone()
if not row:
return None
d = dict(row)
d["vater"] = _build_tree(conn, d["vater_id"], depth - 1)
d["mutter"] = _build_tree(conn, d["mutter_id"], depth - 1)
return d
# ------------------------------------------------------------------
# Inzucht-Koeffizient (Wright's Formel)
# ------------------------------------------------------------------
def _get_ancestors(conn, hund_id, depth: int, path: list) -> dict:
"""Gibt {ancestor_id: [paths]} zurück."""
if depth == 0 or hund_id is None:
return {}
row = conn.execute(
"SELECT vater_id, mutter_id, name FROM zucht_hunde WHERE id=?", (hund_id,)
).fetchone()
if not row:
return {}
result = {hund_id: [path]}
for parent_id in [row["vater_id"], row["mutter_id"]]:
if parent_id:
sub = _get_ancestors(conn, parent_id, depth - 1, path + [hund_id])
for aid, paths in sub.items():
result.setdefault(aid, []).extend(paths)
return result
def _calculate_ik(conn, vater_id, mutter_id, generations: int = 8) -> float:
fa = _get_ancestors(conn, vater_id, generations, [])
ma = _get_ancestors(conn, mutter_id, generations, [])
common = set(fa.keys()) & set(ma.keys())
ik = 0.0
for aid in common:
for pf in fa[aid]:
for pm in ma[aid]:
ik += 0.5 ** (len(pf) + len(pm) + 1)
return round(ik * 100, 2)
def _ik_rating(ik: float) -> str:
if ik < 2.5:
return "optimal"
if ik < 6.25:
return "akzeptabel"
if ik < 12.5:
return "erhoeht"
return "kritisch"
# ------------------------------------------------------------------
# Pydantic-Schemas
# ------------------------------------------------------------------
class HundCreate(BaseModel):
name: str
rufname: Optional[str] = None
geschlecht: str # maennlich|weiblich
geburtsdatum: Optional[str] = None
sterbedatum: Optional[str] = None
chip_nr: Optional[str] = None
taetowiernummer: Optional[str] = None
zuchtbuchnummer: Optional[str] = None
farbe: Optional[str] = None
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
zuechter_name: Optional[str] = None
eigentuemer_name: Optional[str] = None
is_public: int = 1
notiz: Optional[str] = None
foto_url: Optional[str] = None
class HundUpdate(BaseModel):
name: Optional[str] = None
rufname: Optional[str] = None
geschlecht: Optional[str] = None
geburtsdatum: Optional[str] = None
sterbedatum: Optional[str] = None
chip_nr: Optional[str] = None
taetowiernummer: Optional[str] = None
zuchtbuchnummer: Optional[str] = None
farbe: Optional[str] = None
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
zuechter_name: Optional[str] = None
eigentuemer_name: Optional[str] = None
is_public: Optional[int] = None
notiz: Optional[str] = None
foto_url: Optional[str] = None
class HealthTestCreate(BaseModel):
test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom
test_name: Optional[str] = None
ergebnis: Optional[str] = None
untersuch_am: Optional[str] = None
gueltig_bis: Optional[str] = None
untersucher: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
is_public: int = 1
class HealthTestUpdate(BaseModel):
test_typ: Optional[str] = None
test_name: Optional[str] = None
ergebnis: Optional[str] = None
untersuch_am: Optional[str] = None
gueltig_bis: Optional[str] = None
untersucher: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
is_public: Optional[int] = None
class GeneticTestCreate(BaseModel):
marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc.
marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft
genotyp: Optional[str] = None # +/+|+/-|-/-
ergebnis_klasse: Optional[str] = None # clear|carrier|affected
getestet_am: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
is_public: int = 1
class GeneticTestUpdate(BaseModel):
marker_name: Optional[str] = None
marker_kategorie: Optional[str] = None
genotyp: Optional[str] = None
ergebnis_klasse: Optional[str] = None
getestet_am: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
is_public: Optional[int] = None
class TitelCreate(BaseModel):
titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom
titel_name: str
verliehen_am: Optional[str] = None
ort: Optional[str] = None
richter: Optional[str] = None
ausstellung: Optional[str] = None
formwert: Optional[str] = None
is_public: int = 1
class TitelUpdate(BaseModel):
titel_typ: Optional[str] = None
titel_name: Optional[str] = None
verliehen_am: Optional[str] = None
ort: Optional[str] = None
richter: Optional[str] = None
ausstellung: Optional[str] = None
formwert: Optional[str] = None
is_public: Optional[int] = None
class TrialMatingBody(BaseModel):
vater_id: int
mutter_id: int
# ==================================================================
# HUNDE CRUD
# ==================================================================
# ------------------------------------------------------------------
# GET /api/zuchthunde — eigene Hunde
# ------------------------------------------------------------------
@router.get("/zuchthunde")
async def list_eigene_hunde(user=Depends(_require_breeder)):
with db() as conn:
if user["rolle"] == "admin":
profile_id = _get_breeder_profile_id(user["id"], conn)
if profile_id is None:
# Admin ohne Profil sieht alle Hunde
rows = conn.execute(
"SELECT * FROM zucht_hunde ORDER BY name ASC"
).fetchall()
return [dict(r) for r in rows]
else:
profile_id = _get_breeder_profile_id(user["id"], conn)
if profile_id is None:
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
rows = conn.execute(
"SELECT * FROM zucht_hunde WHERE breeder_id=? ORDER BY name ASC",
(profile_id,)
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/zuchthunde — Hund anlegen
# ------------------------------------------------------------------
@router.post("/zuchthunde", status_code=201)
async def create_hund(body: HundCreate, user=Depends(_require_breeder)):
with db() as conn:
profile_id = _get_breeder_profile_id(user["id"], conn)
if profile_id is None and user["rolle"] != "admin":
raise HTTPException(404, "Kein Züchter-Profil vorhanden.")
cur = conn.execute(
"""INSERT INTO zucht_hunde
(breeder_id, name, rufname, geschlecht, geburtsdatum, sterbedatum,
chip_nr, taetowiernummer, zuchtbuchnummer, farbe,
vater_id, mutter_id, zuechter_name, eigentuemer_name,
is_public, notiz, foto_url)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
profile_id,
body.name, body.rufname, body.geschlecht,
body.geburtsdatum, body.sterbedatum,
body.chip_nr, body.taetowiernummer, body.zuchtbuchnummer,
body.farbe, body.vater_id, body.mutter_id,
body.zuechter_name, body.eigentuemer_name,
body.is_public, body.notiz, body.foto_url,
)
)
row = conn.execute(
"SELECT * FROM zucht_hunde WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ==================================================================
# FIXE ROUTEN vor {id} — Route-Reihenfolge kritisch!
# ==================================================================
# ------------------------------------------------------------------
# POST /api/zuchthunde/trial-mating — Probeverpaarung / IK-Berechnung
# ------------------------------------------------------------------
@router.post("/zuchthunde/trial-mating")
async def trial_mating(body: TrialMatingBody, user=Depends(_require_breeder)):
with db() as conn:
vater = conn.execute(
"SELECT id, name FROM zucht_hunde WHERE id=?", (body.vater_id,)
).fetchone()
if not vater:
raise HTTPException(404, "Vater nicht gefunden.")
mutter = conn.execute(
"SELECT id, name FROM zucht_hunde WHERE id=?", (body.mutter_id,)
).fetchone()
if not mutter:
raise HTTPException(404, "Mutter nicht gefunden.")
ik_prozent = _calculate_ik(conn, body.vater_id, body.mutter_id, generations=8)
rating = _ik_rating(ik_prozent)
# Gemeinsame Vorfahren mit Namen ermitteln
fa = _get_ancestors(conn, body.vater_id, 8, [])
ma = _get_ancestors(conn, body.mutter_id, 8, [])
common_ids = set(fa.keys()) & set(ma.keys())
gemeinsame_vorfahren = []
for aid in common_ids:
anc = conn.execute(
"SELECT id, name FROM zucht_hunde WHERE id=?", (aid,)
).fetchone()
if not anc:
continue
# Minimale Pfadlängen für Anzeige
min_gen_vater = min(len(p) for p in fa[aid])
min_gen_mutter = min(len(p) for p in ma[aid])
gemeinsame_vorfahren.append({
"id": anc["id"],
"name": anc["name"],
"gen_vater": min_gen_vater,
"gen_mutter": min_gen_mutter,
})
gemeinsame_vorfahren.sort(key=lambda x: x["gen_vater"] + x["gen_mutter"])
return {
"ik_prozent": ik_prozent,
"ik_rating": rating,
"gemeinsame_vorfahren": gemeinsame_vorfahren,
}
# ------------------------------------------------------------------
# PUT /api/zuchthunde/health-tests/{tid}
# ------------------------------------------------------------------
@router.put("/zuchthunde/health-tests/{tid}")
async def update_health_test(tid: int, body: HealthTestUpdate, user=Depends(_require_breeder)):
with db() as conn:
test = conn.execute(
"""SELECT ht.*, bp.user_id AS owner_user_id
FROM dog_health_tests ht
JOIN zucht_hunde zh ON zh.id = ht.hund_id
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE ht.id=?""",
(tid,)
).fetchone()
if not test:
raise HTTPException(404, "Gesundheitstest nicht gefunden.")
if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(tid)
conn.execute(
f"UPDATE dog_health_tests SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute(
"SELECT * FROM dog_health_tests WHERE id=?", (tid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/zuchthunde/health-tests/{tid}
# ------------------------------------------------------------------
@router.delete("/zuchthunde/health-tests/{tid}", status_code=204)
async def delete_health_test(tid: int, user=Depends(_require_breeder)):
with db() as conn:
test = conn.execute(
"""SELECT ht.id, bp.user_id AS owner_user_id
FROM dog_health_tests ht
JOIN zucht_hunde zh ON zh.id = ht.hund_id
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE ht.id=?""",
(tid,)
).fetchone()
if not test:
raise HTTPException(404, "Gesundheitstest nicht gefunden.")
if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
conn.execute("DELETE FROM dog_health_tests WHERE id=?", (tid,))
return None
# ------------------------------------------------------------------
# PUT /api/zuchthunde/genetic-tests/{tid}
# ------------------------------------------------------------------
@router.put("/zuchthunde/genetic-tests/{tid}")
async def update_genetic_test(tid: int, body: GeneticTestUpdate, user=Depends(_require_breeder)):
with db() as conn:
test = conn.execute(
"""SELECT gt.*, bp.user_id AS owner_user_id
FROM dog_genetic_tests gt
JOIN zucht_hunde zh ON zh.id = gt.hund_id
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE gt.id=?""",
(tid,)
).fetchone()
if not test:
raise HTTPException(404, "Gentest nicht gefunden.")
if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(tid)
conn.execute(
f"UPDATE dog_genetic_tests SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute(
"SELECT * FROM dog_genetic_tests WHERE id=?", (tid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/zuchthunde/genetic-tests/{tid}
# ------------------------------------------------------------------
@router.delete("/zuchthunde/genetic-tests/{tid}", status_code=204)
async def delete_genetic_test(tid: int, user=Depends(_require_breeder)):
with db() as conn:
test = conn.execute(
"""SELECT gt.id, bp.user_id AS owner_user_id
FROM dog_genetic_tests gt
JOIN zucht_hunde zh ON zh.id = gt.hund_id
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE gt.id=?""",
(tid,)
).fetchone()
if not test:
raise HTTPException(404, "Gentest nicht gefunden.")
if user["rolle"] != "admin" and test["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
conn.execute("DELETE FROM dog_genetic_tests WHERE id=?", (tid,))
return None
# ------------------------------------------------------------------
# PUT /api/zuchthunde/titles/{tid}
# ------------------------------------------------------------------
@router.put("/zuchthunde/titles/{tid}")
async def update_titel(tid: int, body: TitelUpdate, user=Depends(_require_breeder)):
with db() as conn:
titel = conn.execute(
"""SELECT dt.*, bp.user_id AS owner_user_id
FROM dog_titles dt
JOIN zucht_hunde zh ON zh.id = dt.hund_id
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE dt.id=?""",
(tid,)
).fetchone()
if not titel:
raise HTTPException(404, "Titel nicht gefunden.")
if user["rolle"] != "admin" and titel["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(tid)
conn.execute(
f"UPDATE dog_titles SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute(
"SELECT * FROM dog_titles WHERE id=?", (tid,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/zuchthunde/titles/{tid}
# ------------------------------------------------------------------
@router.delete("/zuchthunde/titles/{tid}", status_code=204)
async def delete_titel(tid: int, user=Depends(_require_breeder)):
with db() as conn:
titel = conn.execute(
"""SELECT dt.id, bp.user_id AS owner_user_id
FROM dog_titles dt
JOIN zucht_hunde zh ON zh.id = dt.hund_id
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE dt.id=?""",
(tid,)
).fetchone()
if not titel:
raise HTTPException(404, "Titel nicht gefunden.")
if user["rolle"] != "admin" and titel["owner_user_id"] != user["id"]:
raise HTTPException(403, "Kein Zugriff.")
conn.execute("DELETE FROM dog_titles WHERE id=?", (tid,))
return None
# ==================================================================
# {id}-ROUTEN — nach den fixen Pfaden!
# ==================================================================
# ------------------------------------------------------------------
# GET /api/zuchthunde/{id} — Hund-Detail
# ------------------------------------------------------------------
@router.get("/zuchthunde/{hund_id}")
async def get_hund(hund_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
hund = _check_hund_access(hund_id, user, conn)
return hund
# ------------------------------------------------------------------
# PUT /api/zuchthunde/{id} — bearbeiten
# ------------------------------------------------------------------
@router.put("/zuchthunde/{hund_id}")
async def update_hund(hund_id: int, body: HundUpdate, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
fields, params = [], []
for field, value in body.model_dump(exclude_none=True).items():
fields.append(f"{field}=?")
params.append(value)
if not fields:
raise HTTPException(400, "Keine Felder zum Aktualisieren.")
params.append(hund_id)
conn.execute(
f"UPDATE zucht_hunde SET {', '.join(fields)} WHERE id=?", params
)
row = conn.execute(
"SELECT * FROM zucht_hunde WHERE id=?", (hund_id,)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/zuchthunde/{id} — löschen (cascade)
# ------------------------------------------------------------------
@router.delete("/zuchthunde/{hund_id}", status_code=204)
async def delete_hund(hund_id: int, user=Depends(_require_breeder)):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
conn.execute("DELETE FROM dog_health_tests WHERE hund_id=?", (hund_id,))
conn.execute("DELETE FROM dog_genetic_tests WHERE hund_id=?", (hund_id,))
conn.execute("DELETE FROM dog_titles WHERE hund_id=?", (hund_id,))
conn.execute("DELETE FROM zucht_hunde WHERE id=?", (hund_id,))
return None
# ------------------------------------------------------------------
# GET /api/zuchthunde/{id}/pedigree — Stammbaum
# ------------------------------------------------------------------
@router.get("/zuchthunde/{hund_id}/pedigree")
async def get_pedigree(
hund_id: int,
generations: int = Query(default=4, ge=1, le=8),
user=Depends(get_current_user_optional),
):
with db() as conn:
_check_hund_access(hund_id, user, conn)
tree = _build_tree(conn, hund_id, generations)
if not tree:
raise HTTPException(404, "Hund nicht gefunden.")
return tree
# ==================================================================
# GESUNDHEITSTESTS
# ==================================================================
# ------------------------------------------------------------------
# GET /api/zuchthunde/{id}/health-tests
# ------------------------------------------------------------------
@router.get("/zuchthunde/{hund_id}/health-tests")
async def list_health_tests(hund_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
_check_hund_access(hund_id, user, conn)
is_owner = user and (
user["rolle"] == "admin"
or conn.execute(
"""SELECT 1 FROM zucht_hunde zh
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE zh.id=? AND bp.user_id=?""",
(hund_id, user["id"])
).fetchone() is not None
)
q = "SELECT * FROM dog_health_tests WHERE hund_id=?"
params = [hund_id]
if not is_owner:
q += " AND is_public=1"
rows = conn.execute(q + " ORDER BY untersuch_am DESC", params).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/zuchthunde/{id}/health-tests
# ------------------------------------------------------------------
@router.post("/zuchthunde/{hund_id}/health-tests", status_code=201)
async def create_health_test(
hund_id: int, body: HealthTestCreate, user=Depends(_require_breeder)
):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"""INSERT INTO dog_health_tests
(hund_id, test_typ, test_name, ergebnis, untersuch_am, gueltig_bis,
untersucher, labor, zertifikat_nr, is_public)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
hund_id, body.test_typ, body.test_name, body.ergebnis,
body.untersuch_am, body.gueltig_bis, body.untersucher,
body.labor, body.zertifikat_nr, body.is_public,
)
)
row = conn.execute(
"SELECT * FROM dog_health_tests WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ==================================================================
# GENTESTS
# ==================================================================
# ------------------------------------------------------------------
# GET /api/zuchthunde/{id}/genetic-tests
# ------------------------------------------------------------------
@router.get("/zuchthunde/{hund_id}/genetic-tests")
async def list_genetic_tests(hund_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
_check_hund_access(hund_id, user, conn)
is_owner = user and (
user["rolle"] == "admin"
or conn.execute(
"""SELECT 1 FROM zucht_hunde zh
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE zh.id=? AND bp.user_id=?""",
(hund_id, user["id"])
).fetchone() is not None
)
q = "SELECT * FROM dog_genetic_tests WHERE hund_id=?"
params = [hund_id]
if not is_owner:
q += " AND is_public=1"
rows = conn.execute(q + " ORDER BY getestet_am DESC", params).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/zuchthunde/{id}/genetic-tests
# ------------------------------------------------------------------
@router.post("/zuchthunde/{hund_id}/genetic-tests", status_code=201)
async def create_genetic_test(
hund_id: int, body: GeneticTestCreate, user=Depends(_require_breeder)
):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"""INSERT INTO dog_genetic_tests
(hund_id, marker_name, marker_kategorie, genotyp, ergebnis_klasse,
getestet_am, labor, zertifikat_nr, is_public)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
hund_id, body.marker_name, body.marker_kategorie, body.genotyp,
body.ergebnis_klasse, body.getestet_am, body.labor,
body.zertifikat_nr, body.is_public,
)
)
row = conn.execute(
"SELECT * FROM dog_genetic_tests WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)
# ==================================================================
# TITEL
# ==================================================================
# ------------------------------------------------------------------
# GET /api/zuchthunde/{id}/titles
# ------------------------------------------------------------------
@router.get("/zuchthunde/{hund_id}/titles")
async def list_titles(hund_id: int, user=Depends(get_current_user_optional)):
with db() as conn:
_check_hund_access(hund_id, user, conn)
is_owner = user and (
user["rolle"] == "admin"
or conn.execute(
"""SELECT 1 FROM zucht_hunde zh
LEFT JOIN breeder_profiles bp ON bp.id = zh.breeder_id
WHERE zh.id=? AND bp.user_id=?""",
(hund_id, user["id"])
).fetchone() is not None
)
q = "SELECT * FROM dog_titles WHERE hund_id=?"
params = [hund_id]
if not is_owner:
q += " AND is_public=1"
rows = conn.execute(q + " ORDER BY verliehen_am DESC", params).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/zuchthunde/{id}/titles
# ------------------------------------------------------------------
@router.post("/zuchthunde/{hund_id}/titles", status_code=201)
async def create_titel(
hund_id: int, body: TitelCreate, user=Depends(_require_breeder)
):
with db() as conn:
_check_hund_owner(hund_id, user, conn)
cur = conn.execute(
"""INSERT INTO dog_titles
(hund_id, titel_typ, titel_name, verliehen_am, ort,
richter, ausstellung, formwert, is_public)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
hund_id, body.titel_typ, body.titel_name, body.verliehen_am,
body.ort, body.richter, body.ausstellung, body.formwert,
body.is_public,
)
)
row = conn.execute(
"SELECT * FROM dog_titles WHERE id=?", (cur.lastrowid,)
).fetchone()
return dict(row)

View file

@ -6551,6 +6551,132 @@ svg.empty-state-icon {
font-size: var(--text-sm);
}
/* ------------------------------------------------------------
WURFBÖRSE
------------------------------------------------------------ */
.wb-layout {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.wb-filter-bar {
display: flex;
gap: var(--space-2);
align-items: flex-end;
flex-wrap: wrap;
}
.wb-filter-fields {
display: flex;
gap: var(--space-2);
flex: 1;
flex-wrap: wrap;
}
.wb-filter-rasse { flex: 2; min-width: 160px; }
.wb-filter-status { flex: 1; min-width: 130px; }
.wb-filter-btn { white-space: nowrap; }
.wb-cards {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
@media (min-width: 768px) {
.wb-cards { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1200px) {
.wb-cards { grid-template-columns: repeat(3, 1fr); }
}
.wb-card {
background: var(--c-surface);
border: 1px solid var(--c-border-light);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
transition: box-shadow .15s;
}
.wb-card:hover { box-shadow: var(--shadow-md); }
.wb-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-2);
}
.wb-card-zuechter {
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
color: var(--c-text);
line-height: 1.3;
}
.wb-card-rasse {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
color: var(--c-primary);
display: flex;
align-items: center;
gap: var(--space-1);
}
.wb-card-details {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--c-text-secondary);
flex: 1;
}
.wb-card-eltern,
.wb-card-datum,
.wb-card-welpen,
.wb-card-preis,
.wb-card-gesundheit {
display: flex;
align-items: center;
gap: var(--space-1);
}
.wb-card-beschreibung {
margin-top: var(--space-1);
line-height: 1.4;
color: var(--c-text-secondary);
}
.wb-card-footer {
margin-top: var(--space-2);
padding-top: var(--space-2);
border-top: 1px solid var(--c-border-light);
}
/* Status-Badges */
.wb-badge {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.wb-badge--geplant { background: #6B7280; }
.wb-badge--geboren { background: #3B82F6; }
.wb-badge--verfuegbar { background: #22C55E; }
.wb-badge--abgeschlossen { background: #374151; }
/* ------------------------------------------------------------
OFFLINE-BANNER
------------------------------------------------------------ */

View file

@ -177,4 +177,13 @@
<rect width="256" height="256" fill="none"/>
<path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM152,160H136v16a8,8,0,0,1-16,0V160H104a8,8,0,0,1,0-16h16V128a8,8,0,0,1,16,0v16h16a8,8,0,0,1,0,16ZM48,80V48H72v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80Z"/>
</symbol>
<symbol id="tree-structure" viewBox="0 0 256 256">
<path d="M144,96V80H128a8,8,0,0,0-8,8v80a8,8,0,0,0,8,8h16V160a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16v48a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V192H128a24,24,0,0,1-24-24V136H72v8a16,16,0,0,1-16,16H24A16,16,0,0,1,8,144V112A16,16,0,0,1,24,96H56a16,16,0,0,1,16,16v8h32V88a24,24,0,0,1,24-24h16V48a16,16,0,0,1,16-16h48a16,16,0,0,1,16,16V96a16,16,0,0,1-16,16H160A16,16,0,0,1,144,96Z"/>
</symbol>
<symbol id="shield-check" viewBox="0 0 256 256">
<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm-34.32,69.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z"/>
</symbol>
<symbol id="certificate" viewBox="0 0 256 256">
<path d="M232,86.53V56a16,16,0,0,0-16-16H40A16,16,0,0,0,24,56V184a16,16,0,0,0,16,16H160v24A8,8,0,0,0,172,231l24-13.74L220,231A8,8,0,0,0,232,224V161.47a51.88,51.88,0,0,0,0-74.94ZM128,144H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm0-32H72a8,8,0,0,1,0-16h56a8,8,0,0,1,0,16Zm88,98.21-16-9.16a8,8,0,0,0-7.94,0l-16,9.16V172a51.88,51.88,0,0,0,40,0ZM196,160a36,36,0,1,1,36-36A36,36,0,0,1,196,160Z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Before After
Before After

View file

@ -173,6 +173,9 @@
<div class="sidebar-item" data-page="wiki">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#books"></use></svg> Wiki
</div>
<div class="sidebar-item" data-page="wurfboerse">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Wurfbörse
</div>
<div class="sidebar-item" data-page="knigge">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg> Knigge
</div>
@ -183,6 +186,15 @@
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Erste Hilfe
</div>
<div class="sidebar-item" data-page="zuchthunde" id="sidebar-zuchthunde"
style="display:none;color:var(--c-primary,#7c3aed)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree-structure"></use></svg> Zuchtkartei
</div>
<div class="sidebar-item" data-page="litters" id="sidebar-litters"
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="social" id="sidebar-social"
style="display:none;color:var(--c-warning,#f59e0b)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Social Media
@ -338,6 +350,24 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-zuchthunde">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-zucht-profil">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-litters">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-wurfboerse">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-breeder">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-social">
<div class="page-body page-container"></div>
</section>

View file

@ -606,12 +606,90 @@ const API = (() => {
return new Date().toLocaleString('sv').replace(' ', 'T');
}
// ----------------------------------------------------------
// ZÜCHTER
// ----------------------------------------------------------
const breeder = {
status() { return get('/breeder/status'); },
apply(form) { return upload('/breeder/apply', form); },
profile(zwingername) { return get(`/breeder/profil/${encodeURIComponent(zwingername)}`); },
mapMarkers() { return get('/breeder/map'); },
updateProfile(data) { return put('/breeder/profile', data); },
pendingList() { return get('/admin/breeders/pending'); },
documents(userId) { return get(`/admin/breeder/${userId}/documents`); },
documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; },
approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); },
reject(userId, grund) { return post(`/admin/breeder/${userId}/reject`, { grund }); },
};
// ----------------------------------------------------------
// WÜRFE (Züchter-Wurf-Verwaltung)
// ----------------------------------------------------------
const litters = {
// Züchter: eigene Würfe
myList() { return get('/litters/my'); },
create(data) { return post('/litters', data); },
update(id, data) { return put(`/litters/${id}`, data); },
remove(id) { return del(`/litters/${id}`); },
// Welpen
puppies(id) { return get(`/litters/${id}/puppies`); },
addPuppy(id, data) { return post(`/litters/${id}/puppies`, data); },
updatePuppy(id, data) { return put(`/litters/puppies/${id}`, data); },
addWeight(id, data) { return post(`/litters/puppies/${id}/weight`, data); },
// Öffentlich
public(params) { return get('/litters?' + new URLSearchParams(params || {}).toString()); },
detail(id) { return get(`/litters/${id}`); },
};
// ----------------------------------------------------------
// ZÜCHTER-FOTOS
// ----------------------------------------------------------
const breederPhotos = {
upload(form) { return upload('/breeder/photos/upload', form); },
list(entityType, entityId) { return get(`/photos/${entityType}/${entityId}`); },
updateVisibility(id, visibility) { return patch(`/breeder/photos/${id}/visibility`, { visibility }); },
setPrimary(id) { return patch(`/breeder/photos/${id}/primary`, {}); },
updateCaption(id, caption) { return patch(`/breeder/photos/${id}/caption`, { caption }); },
remove(id) { return del(`/breeder/photos/${id}`); },
};
// Öffentliche API
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
// ----------------------------------------------------------
// ZUCHTKARTEI (Hunde-Stammdaten, Gesundheit, Genetik, Titel)
// ----------------------------------------------------------
const zuchthunde = {
// Hunde
list() { return get('/zuchthunde'); },
get(id) { return get(`/zuchthunde/${id}`); },
create(data) { return post('/zuchthunde', data); },
update(id, data) { return put(`/zuchthunde/${id}`, data); },
remove(id) { return del(`/zuchthunde/${id}`); },
pedigree(id, gen=4) { return get(`/zuchthunde/${id}/pedigree?generations=${gen}`); },
// Gesundheitstests
healthTests(id) { return get(`/zuchthunde/${id}/health-tests`); },
addHealthTest(id, data) { return post(`/zuchthunde/${id}/health-tests`, data); },
updateHealthTest(tid, data) { return put(`/zuchthunde/health-tests/${tid}`, data); },
deleteHealthTest(tid) { return del(`/zuchthunde/health-tests/${tid}`); },
// Gentests
geneticTests(id) { return get(`/zuchthunde/${id}/genetic-tests`); },
addGeneticTest(id, data) { return post(`/zuchthunde/${id}/genetic-tests`, data); },
updateGeneticTest(tid, data) { return put(`/zuchthunde/genetic-tests/${tid}`, data); },
deleteGeneticTest(tid) { return del(`/zuchthunde/genetic-tests/${tid}`); },
// Titel
titles(id) { return get(`/zuchthunde/${id}/titles`); },
addTitle(id, data) { return post(`/zuchthunde/${id}/titles`, data); },
updateTitle(tid, data) { return put(`/zuchthunde/titles/${tid}`, data); },
deleteTitle(tid) { return del(`/zuchthunde/titles/${tid}`); },
// Probeverpaarung
trialMating(vaterId, mutterId) { return post('/zuchthunde/trial-mating', { vater_id: vaterId, mutter_id: mutterId }); },
};
breeder, litters, breederPhotos, zuchthunde,
subscribeToPush, getLocation, clientNow,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '429'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '444'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -62,6 +62,11 @@ const App = (() => {
datenschutz: { title: 'Datenschutz', module: null },
widget: { title: 'Widget', module: null, requiresAuth: true },
notifications: { title: 'Aktuelles', module: null, requiresAuth: true },
breeder: { title: 'Züchter-Profil', module: null },
litters: { title: 'Wurfverwaltung', module: null, requiresAuth: true },
wurfboerse: { title: 'Wurfbörse', module: null },
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null },
};
// ----------------------------------------------------------
@ -430,6 +435,11 @@ const App = (() => {
|| state.user.is_moderator;
moderationItem.style.display = isMod ? '' : 'none';
}
const isBreeder = state.user.rolle === 'breeder' || state.user.rolle === 'admin';
const littersItem = document.getElementById('sidebar-litters');
if (littersItem) littersItem.style.display = isBreeder ? '' : 'none';
const zuchthundeItem = document.getElementById('sidebar-zuchthunde');
if (zuchthundeItem) zuchthundeItem.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

@ -13,6 +13,7 @@ window.Page_admin = (() => {
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
@ -81,6 +82,7 @@ window.Page_admin = (() => {
case 'uebersicht': await _renderStats(el); break;
case 'nutzer': await _renderUsers(el); break;
case 'moderation': await _renderModeration(el); break;
case 'zuchter': await _renderZuechter(el); break;
case 'forum': await _renderForum(el); break;
case 'social': await _renderSocial(el); break;
case 'analytics': await _renderAnalytics(el); break;
@ -309,7 +311,39 @@ window.Page_admin = (() => {
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
async function _renderStats(el) {
const s = await API.get('/admin/stats');
const [s, ki] = await Promise.all([
API.get('/admin/stats'),
API.get('/admin/ki/status').catch(() => null),
]);
const _kiStatusBadge = () => {
if (!ki) return '';
if (ki.mode === 'off') {
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md)">
<span style="width:8px;height:8px;border-radius:50%;background:var(--c-text-muted);flex-shrink:0"></span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">KI-Modus: <strong>off</strong></span>
</div>`;
}
const dot = ki.local_reachable ? 'var(--c-success)' : 'var(--c-danger)';
const label = ki.local_reachable ? 'Lokal erreichbar' : 'Nicht erreichbar';
const model = ki.local_model_loaded || ki.local_model_config || '?';
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);flex-wrap:wrap">
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0;
box-shadow:0 0 4px ${dot}"></span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:600">${label}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">·</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-family:monospace">${_esc(model)}</span>
<span style="margin-left:auto;font-size:10px;padding:1px 6px;border-radius:10px;
background:var(--c-surface);color:var(--c-text-muted);border:1px solid var(--c-border)">
Modus: ${ki.local_reachable ? 'local' : 'cloud'}
</span>
</div>`;
};
el.innerHTML = `
<div class="adm-stats-grid">
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
@ -331,6 +365,7 @@ window.Page_admin = (() => {
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">KI-Nutzung</p>
${_kiStatusBadge()}
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${[
['☁️ Claude (7 Tage)', s.ki_cloud_week, 'var(--c-primary)'],
@ -1212,6 +1247,195 @@ window.Page_admin = (() => {
});
}
// ------------------------------------------------------------------
// TAB: ZÜCHTER-ANTRÄGE
// ------------------------------------------------------------------
async function _renderZuechter(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-zuchter-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-zuchter-list">Lade</div>
`;
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () =>
_loadZuechterAntraege(el.querySelector('#adm-zuchter-list'))
);
await _loadZuechterAntraege(el.querySelector('#adm-zuchter-list'));
}
async function _loadZuechterAntraege(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
let antraege;
try {
antraege = await API.breeder.pendingList();
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Anträge konnten nicht geladen werden.');
return;
}
if (!antraege.length) {
el.innerHTML = _emptyState('certificate', 'Keine offenen Anträge', 'Aktuell liegen keine Züchter-Anträge zur Prüfung vor.');
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${antraege.map(a => `
<div class="card" style="padding:var(--space-4)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
<!-- Infos -->
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);
color:var(--c-text);margin-bottom:var(--space-1)">
${_esc(a.name)}
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:400;margin-left:6px">
${_esc(a.email)}
</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-3);font-size:var(--text-xs);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
<span>${UI.icon('paw-print')} ${_esc(a.rasse_text || '')}</span>
<span>${UI.icon('house-line')} ${_esc(a.zwingername || '')}</span>
<span>${UI.icon('users')} ${_esc(a.verein || '')}</span>
<span>${UI.icon('map-pin')} ${_esc(a.stadt || '')}</span>
<span style="color:${a.vdh_mitglied ? 'var(--c-success)' : 'var(--c-text-muted)'}">
${UI.icon('certificate')} VDH: ${a.vdh_mitglied ? 'ja' : 'nein'}
</span>
${a.created_at ? `<span style="color:var(--c-text-muted)">${UI.icon('clock')} ${new Date(a.created_at).toLocaleDateString('de-DE')}</span>` : ''}
</div>
${a.beschreibung ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm);margin-top:var(--space-1)">
${_esc(a.beschreibung)}
</div>` : ''}
</div>
<!-- Aktionen -->
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-sm btn-secondary adm-breeder-docs"
data-uid="${a.user_id || a.id}">
${UI.icon('file-text')} Dokumente
</button>
<button class="btn btn-sm btn-primary adm-breeder-approve"
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}">
${UI.icon('check')} Freischalten
</button>
<button class="btn btn-sm btn-ghost adm-breeder-reject"
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}"
style="color:var(--c-danger)">
${UI.icon('x')} Ablehnen
</button>
</div>
</div>
</div>
`).join('')}
</div>
`;
// Dokumente anzeigen
el.querySelectorAll('.adm-breeder-docs').forEach(btn => {
btn.addEventListener('click', async () => {
const uid = btn.dataset.uid;
let docs;
try {
docs = await API.breeder.documents(uid);
} catch (e) {
UI.toast.error(e.message || 'Dokumente konnten nicht geladen werden.');
return;
}
UI.modal.open({
title: `${UI.icon('file-text')} Hochgeladene Dokumente`,
body: docs.length
? `<div style="display:flex;flex-direction:column;gap:var(--space-3)">
${docs.map(d => `
<a href="${_esc(API.breeder.documentUrl(uid, d.id))}"
target="_blank" rel="noopener"
class="btn btn-secondary"
style="text-align:left;word-break:break-all">
${UI.icon('file')} ${_esc(d.filename || d.name || 'Dokument ' + d.id)}
</a>`).join('')}
</div>`
: `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine Dokumente hochgeladen.</p>`,
});
});
});
// Freischalten
el.querySelectorAll('.adm-breeder-approve').forEach(btn => {
btn.addEventListener('click', async () => {
const ok = window.confirm(`${btn.dataset.name} als Züchter freischalten?`);
if (!ok) return;
btn.disabled = true;
try {
const res = await API.breeder.approve(btn.dataset.uid);
UI.toast.success(res.message || `${btn.dataset.name} freigeschaltet.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Freischaltung fehlgeschlagen.');
btn.disabled = false;
}
});
});
// Ablehnen
el.querySelectorAll('.adm-breeder-reject').forEach(btn => {
btn.addEventListener('click', () => {
const uid = btn.dataset.uid;
const name = btn.dataset.name;
UI.modal.open({
title: `${UI.icon('x-circle')} Antrag ablehnen: ${name}`,
body: `
<form id="breeder-reject-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Bitte gib einen Ablehnungsgrund an. Dieser wird dem Antragsteller mitgeteilt.
</p>
<textarea id="breeder-reject-grund" name="grund" rows="4" required
placeholder="z. B. Dokumente unvollständig, Rasse nicht unterstützt…"
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text);resize:vertical"></textarea>
</form>
`,
footer: `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" id="breeder-reject-submit"
form="breeder-reject-form" style="width:100%;background:var(--c-danger);border-color:var(--c-danger)">
Antrag ablehnen
</button>
<button class="btn btn-ghost" data-modal-close>Abbrechen</button>
</div>
`,
});
document.getElementById('breeder-reject-form')?.addEventListener('submit', async ev => {
ev.preventDefault();
const grund = document.getElementById('breeder-reject-grund')?.value?.trim();
if (!grund) {
UI.toast.warning('Bitte einen Ablehnungsgrund angeben.');
return;
}
const submitBtn = document.getElementById('breeder-reject-submit');
if (submitBtn) submitBtn.disabled = true;
try {
const res = await API.breeder.reject(uid, grund);
UI.modal.close?.();
UI.toast.success(res.message || `Antrag von ${name} abgelehnt.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Ablehnung fehlgeschlagen.');
if (submitBtn) submitBtn.disabled = false;
}
});
});
});
}
// ------------------------------------------------------------------
async function _renderJobs(el) {
el.innerHTML = `

View file

@ -0,0 +1,210 @@
/* ============================================================
BAN YARO Öffentliches Züchter-Profil
Seiten-Modul: Zeigt das verifizierte Profil eines Züchters.
============================================================ */
window.Page_breeder = (() => {
let _container = null;
let _appState = null;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState, params = {}) {
_container = container;
_appState = appState;
// Zwingername aus params oder URL-Pfad (/breeder/vom-sonnenfeld)
const zwingername = params?.zwingername
|| decodeURIComponent((window.location.pathname.split('/breeder/')[1] || '').replace(/\/$/, ''));
if (!zwingername) {
container.innerHTML = '<div style="padding:var(--space-6)">Kein Zwingername angegeben.</div>';
return;
}
container.innerHTML = '<div style="padding:var(--space-6);text-align:center">Lade…</div>';
try {
const p = await API.breeder.profile(zwingername);
_render(p);
} catch (e) {
container.innerHTML = `<div style="padding:var(--space-6)">${_esc(e.message || 'Züchter nicht gefunden.')}</div>`;
}
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
function _render(p) {
const verifiedDate = p.verified_at
? new Date(p.verified_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: null;
const websiteHtml = p.website
? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);text-decoration:none;word-break:break-all">
${UI.icon('arrow-square-out')} ${_esc(p.website)}
</a>`
: '';
const beschreibungHtml = p.beschreibung
? `<div class="card" style="margin-bottom:var(--space-3)">
<p style="margin:0;white-space:pre-line;color:var(--c-text-secondary)">${_esc(p.beschreibung)}</p>
</div>`
: '';
_container.innerHTML = `
<div style="padding:var(--space-4)">
<!-- Header-Card -->
<div class="card" style="margin-bottom:var(--space-3)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
<div style="flex:1;min-width:0">
<h2 style="margin:0 0 var(--space-1);font-size:var(--text-xl);word-break:break-word">
${UI.icon('certificate')} ${_esc(p.zwingername)}
</h2>
<span class="badge badge-primary" style="background:var(--c-success,#22C55E);color:#fff;font-size:var(--text-xs)">
${UI.icon('seal-check')} Verifizierter Züchter
</span>
</div>
</div>
</div>
<!-- Details-Card -->
<div class="card" style="margin-bottom:var(--space-3)">
<dl style="margin:0;display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Rasse</dt>
<dd style="margin:0;font-weight:600">${_esc(p.rasse_text || '')}</dd>
</div>
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Verein</dt>
<dd style="margin:0">${_esc(p.verein || '')}</dd>
</div>
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">VDH-Mitglied</dt>
<dd style="margin:0">
${p.vdh_mitglied
? `<span class="badge badge-primary">${UI.icon('check')} Ja</span>`
: `<span style="color:var(--c-text-secondary)">Nein</span>`}
</dd>
</div>
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Stadt</dt>
<dd style="margin:0">${_esc(p.stadt || '')}</dd>
</div>
${p.website ? `
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Website</dt>
<dd style="margin:0">${websiteHtml}</dd>
</div>` : ''}
${verifiedDate ? `
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Verifiziert</dt>
<dd style="margin:0;color:var(--c-text-secondary);font-size:var(--text-sm)">${verifiedDate}</dd>
</div>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:100px;font-size:var(--text-sm);flex-shrink:0">Züchter</dt>
<dd style="margin:0">${_esc(p.zuechter_name || '')}</dd>
</div>
</dl>
</div>
<!-- Beschreibung -->
${beschreibungHtml}
<!-- Fotos (werden asynchron nachgeladen) -->
<div id="breeder-photos-section"></div>
<!-- Kontakt-Button -->
${(() => {
if (!p.zuechter_user_id) return '';
const isLoggedIn = !!_appState?.user;
const isOwnProfile = _appState?.user?.id === p.zuechter_user_id;
if (isOwnProfile) return '';
if (isLoggedIn) {
return `<button class="btn btn-primary breeder-chat-btn" style="width:100%">
${UI.icon('chat-circle')} Nachricht senden
</button>`;
}
return `<button class="btn btn-primary breeder-login-btn" style="width:100%">
${UI.icon('sign-in')} Anmelden um zu schreiben
</button>`;
})()}
</div>
`;
_container.querySelector('.breeder-chat-btn')?.addEventListener('click', () => {
_contactBreeder(p.zuechter_user_id);
});
_container.querySelector('.breeder-login-btn')?.addEventListener('click', () => {
App.navigate('settings');
});
// Öffentliche Fotos nachladen
_loadBreederPhotos(p.id);
}
async function _loadBreederPhotos(breederId) {
const section = document.getElementById('breeder-photos-section');
if (!section) return;
try {
const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos || !photos.length) return;
section.innerHTML = `
<div class="card" style="margin-bottom:var(--space-3)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-md);font-weight:var(--weight-semibold)">
${UI.icon('images')} Fotos
</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:var(--space-2)">
${photos.map(ph => {
const thumb = ph.thumbnail_url || ph.url || '';
return `
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--c-border);aspect-ratio:1">
<img src="${_esc(thumb)}"
alt="${_esc(ph.caption || '')}"
loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'">
</a>`;
}).join('')}
</div>
</div>`;
} catch (_) {
// Fotos sind nicht kritisch — bei Fehler still ignorieren
}
}
async function _contactBreeder(breederId) {
if (!_appState?.user) {
App.navigate('settings');
return;
}
try {
await API.chat.start(breederId);
App.navigate('chat');
} catch (e) {
UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.');
}
}
function refresh() {}
function onDogChange() {}
return { init, refresh, onDogChange };
})();

View file

@ -1011,6 +1011,14 @@ window.Page_health = (() => {
if (e.datum) rows.push(['Datum', UI.time.format(e.datum + 'T00:00:00')]);
if (e.typ === 'laeufigkeit' && e.wert) rows.push(['Dauer', `${e.wert} Tage`]);
if (e.naechstes) rows.push([e.typ === 'laeufigkeit' ? 'Nächste erwartet' : 'Nächstes', UI.time.format(e.naechstes + 'T00:00:00')]);
if (e.typ === 'laeufigkeit' && e.deckdatum) rows.push(['Deckdatum', UI.time.format(e.deckdatum + 'T00:00:00')]);
if (e.typ === 'laeufigkeit' && e.wurftermin) {
const wurfDate = new Date(e.wurftermin + 'T00:00:00');
const today = new Date(); today.setHours(0,0,0,0);
const diffDays = Math.round((wurfDate - today) / 86400000);
const zukunft = diffDays > 0 ? ` <span style="color:var(--c-primary);font-weight:600">in ${diffDays} Tagen</span>` : '';
rows.push(['Wurftermin', UI.time.format(e.wurftermin + 'T00:00:00') + zukunft]);
}
if (e.tierarzt_id) {
const praxis = _praxen.find(p => p.id === e.tierarzt_id);
if (praxis) {
@ -1478,6 +1486,27 @@ window.Page_health = (() => {
<input class="form-control" type="date" name="naechstes"
value="${entry?.naechstes || ''}" id="laeufi-naechstes">
</div>
${['breeder', 'admin'].includes(_appState.user?.rolle) ? `
<div class="form-group" id="laeufi-zuechter-fields" style="margin-top:var(--space-4);
padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--space-3)">
Zucht (optional)
</div>
<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"
value="${entry?.deckdatum || ''}" id="laeufi-deckdatum">
</div>
<div class="form-group">
<label class="form-label">Wurftermin (63 Tage nach Deckung)</label>
<input class="form-control" type="date" name="wurftermin"
value="${entry?.wurftermin || ''}" id="laeufi-wurftermin" readonly
style="background:var(--c-surface-2)">
</div>
</div>
</div>` : ''}
<script>
(function() {
const datum = document.querySelector('[name="datum"]');
@ -1495,6 +1524,16 @@ window.Page_health = (() => {
datum?.addEventListener('change', updateNext);
interval?.addEventListener('change', updateNext);
if (!naechstes?.value) updateNext();
const deckdatum = document.getElementById('laeufi-deckdatum');
const wurftermin = document.getElementById('laeufi-wurftermin');
deckdatum?.addEventListener('change', e => {
const deckDate = new Date(e.target.value);
if (!isNaN(deckDate)) {
deckDate.setDate(deckDate.getDate() + 63);
wurftermin.value = deckDate.toISOString().split('T')[0];
}
});
})();
</script>
`;
@ -1524,6 +1563,8 @@ window.Page_health = (() => {
}
if (typ === 'laeufigkeit') {
p.bezeichnung = p.bezeichnung || 'Läufigkeit';
p.deckdatum = fd.deckdatum || null;
p.wurftermin = fd.wurftermin || null;
}
if (fd.kosten) p.kosten = parseFloat(fd.kosten.toString().replace(',', '.'));
if (fd.tierarzt_id) {

View file

@ -0,0 +1,972 @@
/* ============================================================
BAN YARO Wurfverwaltung
Züchter verwalten ihre Würfe und Welpen
============================================================ */
window.Page_litters = (() => {
let _container = null;
let _appState = null;
let _litters = []; // geladene Würfe
let _openId = null; // aufgeklappter Wurf
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _emptyState(icon, title, text) {
return `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon(icon)}</div>
<h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
</div>`;
}
function _esc(s) {
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _statusBadge(status) {
const map = {
geplant: { label: 'Geplant', color: '#6B7280' },
geboren: { label: 'Geboren', color: '#3B82F6' },
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
abgeschlossen: { label: 'Abgeschlossen', color: '#374151' },
};
const s = map[status] || { label: status, color: '#6B7280' };
return `<span class="litters-badge" style="background:${s.color}">${_esc(s.label)}</span>`;
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso + 'T12:00:00');
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function _genderIcon(g) {
if (g === 'maennlich') return UI.icon('gender-male');
if (g === 'weiblich') return UI.icon('gender-female');
return '';
}
function _puppyStatusBadge(status) {
const map = {
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
reserviert: { label: 'Reserviert', color: '#F59E0B' },
abgegeben: { label: 'Abgegeben', color: '#6B7280' },
};
const s = map[status] || { label: status, color: '#9CA3AF' };
return `<span class="litters-badge litters-badge--sm" style="background:${s.color}">${_esc(s.label)}</span>`;
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
// Auth-Guard
const u = _appState.user;
if (!u || (u.rolle !== 'breeder' && u.rolle !== 'admin')) {
_container.innerHTML = _emptyState('lock', 'Kein Zugriff', 'Diese Seite ist nur für verifizierte Züchter.');
return;
}
_render();
await _loadLitters();
}
function refresh() {
const u = _appState?.user;
if (!u || (u.rolle !== 'breeder' && u.rolle !== 'admin')) return;
_loadLitters();
}
function onDogChange() {}
// ----------------------------------------------------------
// Grundstruktur rendern
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="litters-layout">
<div class="by-toolbar">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('dog')} Meine Würfe
</h2>
<button class="btn btn-primary btn-sm" id="litters-new-btn">
${UI.icon('plus')} Neuer Wurf
</button>
</div>
<div id="litters-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
`;
document.getElementById('litters-new-btn')?.addEventListener('click', () => {
_showLitterForm(null);
});
}
// ----------------------------------------------------------
// Würfe laden
// ----------------------------------------------------------
async function _loadLitters() {
try {
_litters = await API.litters.myList();
_renderList();
} catch (err) {
if (err.status === 404) {
const el = document.getElementById('litters-list');
if (el) el.innerHTML = _emptyState('certificate', 'Kein Züchter-Profil',
'Stelle zuerst einen Züchter-Antrag in den Einstellungen, um Würfe verwalten zu können.');
} else {
UI.toast.error(err.message || 'Fehler beim Laden der Würfe.');
}
}
}
// ----------------------------------------------------------
// Würfe-Liste rendern
// ----------------------------------------------------------
function _renderList() {
const el = document.getElementById('litters-list');
if (!el) return;
if (!_litters.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="litters-first-btn">
${UI.icon('plus')} Ersten Wurf anlegen
</button>
</div>`;
document.getElementById('litters-first-btn')?.addEventListener('click', () => _showLitterForm(null));
return;
}
el.innerHTML = _litters.map(l => _litterCardHTML(l)).join('');
// Events
el.querySelectorAll('.litters-card-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_togglePuppies(id);
});
});
el.querySelectorAll('.litters-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (litter) _showPhotosModal('litter', litter.id, litter.zwingername || `Wurf #${litter.id}`);
});
});
el.querySelectorAll('.litters-parent-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (!litter) return;
const label = [litter.vater_name, litter.mutter_name].filter(Boolean).join(' × ') || `Eltern Wurf #${id}`;
_showPhotosModal('parent', litter.id, label);
});
});
el.querySelectorAll('.litters-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (litter) _showLitterForm(litter);
});
});
el.querySelectorAll('.litters-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_deleteLitter(id);
});
});
el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_showPuppyForm(id, null);
});
});
// Aufgeklappten Wurf wiederherstellen
if (_openId) _togglePuppies(_openId, true);
}
function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
const datumLabel = l.geburt_datum
? `Geburt: ${_fmtDate(l.geburt_datum)}`
: l.erwartetes_datum
? `Erwartet: ${_fmtDate(l.erwartetes_datum)}`
: '—';
const elternLabel = [l.vater_name, l.mutter_name]
.filter(Boolean)
.map(n => _esc(n))
.join(' × ') || '—';
const sichtbarLabel = l.sichtbar
? `<span style="color:var(--c-success);font-size:var(--text-xs)">${UI.icon('eye')} Öffentlich</span>`
: `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">${UI.icon('eye-slash')} Nicht öffentlich</span>`;
return `
<div class="litters-card" id="litter-card-${l.id}">
<div class="litters-card-header">
<div style="flex:1;min-width:0">
<div class="litters-card-title">
${elternLabel}
${_statusBadge(l.status)}
</div>
<div class="litters-card-meta">
${UI.icon('calendar-dots')} ${_esc(datumLabel)} &nbsp;·&nbsp;
${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar
&nbsp;·&nbsp; ${sichtbarLabel}
</div>
${l.preis_spanne ? `<div class="litters-card-meta">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</div>` : ''}
</div>
<div class="litters-card-actions">
<button class="btn btn-ghost btn-sm litters-card-toggle" data-id="${l.id}"
title="Welpen anzeigen">
${UI.icon('caret-down')} Welpen
</button>
<button class="btn btn-ghost btn-sm litters-photos-btn" data-id="${l.id}"
title="Wurf-Fotos verwalten">
${UI.icon('images')} Fotos
</button>
<button class="btn btn-ghost btn-sm litters-parent-photos-btn" data-id="${l.id}"
title="Elterntier-Fotos verwalten">
${UI.icon('users')} Eltern
</button>
<button class="btn btn-ghost btn-sm litters-edit-btn" data-id="${l.id}"
title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}"
title="Löschen" style="color:var(--c-danger)">
${UI.icon('trash')}
</button>
</div>
</div>
${l.beschreibung ? `<div class="litters-card-desc">${_esc(l.beschreibung)}</div>` : ''}
<div class="litters-puppies-wrap" id="puppies-wrap-${l.id}" style="display:none">
<div class="litters-puppies-inner" id="puppies-inner-${l.id}">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}"
style="margin-top:var(--space-3)">
${UI.icon('plus')} Welpen hinzufügen
</button>
</div>
</div>`;
}
// ----------------------------------------------------------
// Welpen aufklappen / zuklappen
// ----------------------------------------------------------
async function _togglePuppies(litterId, forceOpen = false) {
const wrap = document.getElementById(`puppies-wrap-${litterId}`);
if (!wrap) return;
const isOpen = wrap.style.display !== 'none';
if (isOpen && !forceOpen) {
wrap.style.display = 'none';
_openId = null;
// Caret zurücksetzen
const btn = document.querySelector(`.litters-card-toggle[data-id="${litterId}"]`);
if (btn) btn.innerHTML = `${UI.icon('caret-down')} Welpen`;
return;
}
wrap.style.display = '';
_openId = litterId;
const btn = document.querySelector(`.litters-card-toggle[data-id="${litterId}"]`);
if (btn) btn.innerHTML = `${UI.icon('caret-up')} Welpen`;
await _loadPuppies(litterId);
}
async function _loadPuppies(litterId) {
const inner = document.getElementById(`puppies-inner-${litterId}`);
if (!inner) return;
try {
const puppies = await API.litters.puppies(litterId);
_renderPuppies(inner, litterId, puppies);
} catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
}
}
function _renderPuppies(container, litterId, puppies) {
if (!puppies.length) {
container.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Welpen eingetragen.</p>`;
return;
}
container.innerHTML = puppies.map(p => `
<div class="litters-puppy-row" data-puppy-id="${p.id}">
<div class="litters-puppy-info">
${_genderIcon(p.geschlecht)}
<span class="litters-puppy-name">${p.name ? _esc(p.name) : '<em style="color:var(--c-text-muted)">Unbenannt</em>'}</span>
${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(p.farbe)}</span>` : ''}
${_puppyStatusBadge(p.status)}
<span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" style="font-size:var(--text-xs);color:var(--c-text-secondary)"></span>
</div>
<div class="litters-puppy-actions">
<button class="btn btn-ghost btn-xs litters-puppy-photo-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
title="Fotos">${UI.icon('image')}</button>
<button class="btn btn-ghost btn-xs litters-puppy-weight-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
title="Gewichtsverlauf">${UI.icon('scales')}</button>
<button class="btn btn-ghost btn-xs litters-puppy-contract-btn" data-puppy-id="${p.id}"
title="Kaufvertrag">${UI.icon('file-text')}</button>
<button class="btn btn-ghost btn-xs litters-puppy-edit-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
title="Welpe bearbeiten">${UI.icon('pencil-simple')}</button>
</div>
</div>`).join('');
container.querySelectorAll('.litters-puppy-photo-btn').forEach(btn => {
btn.addEventListener('click', () => {
const pid = parseInt(btn.dataset.puppyId);
const puppy = puppies.find(p => p.id === pid);
if (puppy) _showPhotosModal('puppy', puppy.id, puppy.name || 'Welpe');
});
});
container.querySelectorAll('.litters-puppy-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const pid = parseInt(btn.dataset.puppyId);
const lid = parseInt(btn.dataset.litterId);
const puppy = puppies.find(p => p.id === pid);
if (puppy) _showPuppyForm(lid, puppy);
});
});
container.querySelectorAll('.litters-puppy-weight-btn').forEach(btn => {
btn.addEventListener('click', () => {
const pid = parseInt(btn.dataset.puppyId);
const puppy = puppies.find(p => p.id === pid);
if (puppy) _showWeightModal(puppy);
});
});
container.querySelectorAll('.litters-puppy-contract-btn').forEach(btn => {
btn.addEventListener('click', () => {
const pid = parseInt(btn.dataset.puppyId);
const puppy = puppies.find(p => p.id === pid);
if (puppy) _showContractModal(puppy);
});
});
// Letztes Gewicht für jeden Welpen laden
puppies.forEach(p => _loadLastWeight(p.id));
}
async function _loadLastWeight(puppyId) {
try {
const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
const el = document.getElementById(`puppy-last-weight-${puppyId}`);
if (!el) return;
if (weights && weights.length) {
const w = weights[0];
el.innerHTML = `${UI.icon('scales')} ${w.gewicht_g} g (${_fmtDate(w.gemessen_am)})`;
}
} catch (_) {
// Gewichte nicht kritisch — still ignorieren
}
}
function _showWeightModal(puppy) {
const today = new Date().toISOString().slice(0, 10);
const puppyLabel = puppy.name || 'Welpe';
const body = `
<div id="weight-history" style="margin-bottom:var(--space-3)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="weight-form" style="display:flex;gap:var(--space-2);align-items:flex-end">
<div style="flex:1">
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Gewicht (g)</label>
<input class="form-control" name="gewicht_g" type="number" min="1" max="99999" step="1" required placeholder="z. B. 420">
</div>
<div style="flex:1">
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Datum</label>
<input class="form-control" name="gemessen_am" type="date" required value="${today}">
</div>
</form>
`;
const footer = `
<button type="submit" form="weight-form" class="btn btn-primary" id="weight-submit">
${UI.icon('floppy-disk')} Speichern
</button>
`;
UI.modal.open({
title: `${UI.icon('scales')} Gewichtsverlauf — ${_esc(puppyLabel)}`,
body,
footer,
});
// Gewichte laden und rendern
_loadWeightHistory(puppy.id);
document.getElementById('weight-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('weight-submit');
const fd = new FormData(e.target);
const payload = {
gewicht_g: parseFloat(fd.get('gewicht_g')),
gemessen_am: fd.get('gemessen_am'),
};
await UI.asyncButton(btn, async () => {
await API.litters.addWeight(puppy.id, payload);
UI.toast.success('Gewicht gespeichert.');
e.target.reset();
document.querySelector('[name="gemessen_am"]').value = today;
_loadWeightHistory(puppy.id);
// Letztes Gewicht im Welpen-Eintrag aktualisieren
_loadLastWeight(puppy.id);
});
});
}
async function _loadWeightHistory(puppyId) {
const el = document.getElementById('weight-history');
if (!el) return;
try {
const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
if (!weights || !weights.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Messungen eingetragen.</p>`;
return;
}
el.innerHTML = `
<table style="width:100%;font-size:var(--text-sm);border-collapse:collapse">
<thead>
<tr style="color:var(--c-text-secondary);font-size:var(--text-xs)">
<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)">Gewicht</th>
</tr>
</thead>
<tbody>
${weights.map((w, i) => `
<tr style="border-top:1px solid var(--c-border)${i === 0 ? ';font-weight:var(--weight-semibold)' : ''}">
<td style="padding:var(--space-1) var(--space-2)">${_fmtDate(w.gemessen_am)}</td>
<td style="padding:var(--space-1) var(--space-2);text-align:right">${w.gewicht_g} g</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch (err) {
el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
}
}
// ----------------------------------------------------------
// Wurf-Formular (neu / bearbeiten)
// ----------------------------------------------------------
function _showLitterForm(litter) {
const isEdit = !!litter;
const v = litter || {};
const today = new Date().toISOString().slice(0, 10);
const body = `
<form id="litter-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Vatername</label>
<input class="form-control" type="text" name="vater_name"
value="${_esc(v.vater_name || '')}" placeholder="Name des Vaters">
</div>
<div class="form-group">
<label class="form-label">Muttername</label>
<input class="form-control" type="text" name="mutter_name"
value="${_esc(v.mutter_name || '')}" placeholder="Name der Mutter">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Geburtsdatum</label>
<input class="form-control" type="date" name="geburt_datum"
value="${_esc(v.geburt_datum || '')}">
</div>
<div class="form-group">
<label class="form-label">Erwartetes Datum</label>
<input class="form-control" type="date" name="erwartetes_datum"
value="${_esc(v.erwartetes_datum || '')}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Welpen gesamt</label>
<input class="form-control" type="number" name="welpen_gesamt" min="0"
value="${v.welpen_gesamt != null ? v.welpen_gesamt : ''}">
</div>
<div class="form-group">
<label class="form-label">Welpen verfügbar</label>
<input class="form-control" type="number" name="welpen_verfuegbar" min="0"
value="${v.welpen_verfuegbar != null ? v.welpen_verfuegbar : ''}">
</div>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-control" name="status">
<option value="geplant" ${v.status === 'geplant' ? 'selected' : ''}>Geplant</option>
<option value="geboren" ${v.status === 'geboren' ? 'selected' : ''}>Geboren</option>
<option value="verfuegbar" ${v.status === 'verfuegbar' ? 'selected' : ''}>Verfügbar</option>
<option value="abgeschlossen" ${v.status === 'abgeschlossen' ? 'selected' : ''}>Abgeschlossen</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Preisspanne</label>
<input class="form-control" type="text" name="preis_spanne"
value="${_esc(v.preis_spanne || '')}" placeholder="z. B. 1.500 2.000 €">
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="Elternlinie, Besonderheiten, Charakter…">${_esc(v.beschreibung || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Gesundheitstests <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="gesundheitstests" rows="2"
placeholder="HD, ED, Gentest, Augenkontrolle…">${_esc(v.gesundheitstests || '')}</textarea>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="sichtbar" value="1" ${v.sichtbar ? 'checked' : ''}>
Öffentlich sichtbar
</label>
</div>
<div class="form-group">
<label class="form-label">Sichtbar bis <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="date" name="sichtbar_bis"
value="${_esc(v.sichtbar_bis || '')}">
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="lf-cancel">Abbrechen</button>
<button type="submit" form="litter-form" class="btn btn-primary flex-1" id="lf-submit">
${isEdit ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Anlegen`}
</button>
`;
UI.modal.open({
title: isEdit ? `${UI.icon('pencil-simple')} Wurf bearbeiten` : `${UI.icon('dog')} Neuer Wurf`,
body,
footer,
});
document.getElementById('lf-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('litter-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('lf-submit');
const fd = new FormData(e.target);
const payload = {
vater_name: fd.get('vater_name')?.trim() || null,
mutter_name: fd.get('mutter_name')?.trim() || null,
geburt_datum: fd.get('geburt_datum') || null,
erwartetes_datum: fd.get('erwartetes_datum') || null,
welpen_gesamt: fd.get('welpen_gesamt') ? parseInt(fd.get('welpen_gesamt')) : null,
welpen_verfuegbar: fd.get('welpen_verfuegbar') ? parseInt(fd.get('welpen_verfuegbar')) : null,
beschreibung: fd.get('beschreibung')?.trim() || null,
gesundheitstests: fd.get('gesundheitstests')?.trim() || null,
preis_spanne: fd.get('preis_spanne')?.trim() || null,
status: fd.get('status') || 'geplant',
sichtbar: fd.get('sichtbar') === '1' ? 1 : 0,
sichtbar_bis: fd.get('sichtbar_bis') || null,
};
await UI.asyncButton(btn, async () => {
if (isEdit) {
const updated = await API.litters.update(litter.id, payload);
const idx = _litters.findIndex(l => l.id === litter.id);
if (idx !== -1) _litters[idx] = updated;
UI.toast.success('Wurf aktualisiert.');
} else {
const created = await API.litters.create(payload);
_litters.unshift(created);
UI.toast.success('Wurf angelegt.');
}
UI.modal.close();
_renderList();
});
});
}
// ----------------------------------------------------------
// Wurf löschen
// ----------------------------------------------------------
async function _deleteLitter(litterId) {
const litter = _litters.find(l => l.id === litterId);
const label = [litter?.vater_name, litter?.mutter_name].filter(Boolean).join(' × ') || `Wurf #${litterId}`;
if (!window.confirm(`Wurf "${label}" wirklich löschen? Alle Welpen werden ebenfalls gelöscht.`)) return;
try {
await API.litters.remove(litterId);
_litters = _litters.filter(l => l.id !== litterId);
if (_openId === litterId) _openId = null;
_renderList();
UI.toast.success('Wurf gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
}
// ----------------------------------------------------------
// Welpen-Formular (neu / bearbeiten)
// ----------------------------------------------------------
function _showPuppyForm(litterId, puppy) {
const isEdit = !!puppy;
const v = puppy || {};
const body = `
<form id="puppy-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Name <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="name"
value="${_esc(v.name || '')}" placeholder="z. B. Max">
</div>
<div class="form-group">
<label class="form-label">Geschlecht</label>
<select class="form-control" name="geschlecht">
<option value="" ${!v.geschlecht ? 'selected' : ''}></option>
<option value="maennlich" ${v.geschlecht === 'maennlich' ? 'selected' : ''}>Männlich</option>
<option value="weiblich" ${v.geschlecht === 'weiblich' ? 'selected' : ''}>Weiblich</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Farbe / Fellzeichnung</label>
<input class="form-control" type="text" name="farbe"
value="${_esc(v.farbe || '')}" placeholder="z. B. schwarz-braun">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-control" name="status">
<option value="verfuegbar" ${v.status === 'verfuegbar' ? 'selected' : ''}>Verfügbar</option>
<option value="reserviert" ${v.status === 'reserviert' ? 'selected' : ''}>Reserviert</option>
<option value="abgegeben" ${v.status === 'abgegeben' ? 'selected' : ''}>Abgegeben</option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Chip-Nr.</label>
<input class="form-control" type="text" name="chip_nr"
value="${_esc(v.chip_nr || '')}" placeholder="15-stellig">
</div>
<div class="form-group">
<label class="form-label">Geburtsgewicht (g)</label>
<input class="form-control" type="number" name="geburtsgewicht" min="0" step="1"
value="${v.geburtsgewicht != null ? v.geburtsgewicht : ''}">
</div>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="status_sichtbar" value="1" ${v.status_sichtbar !== 0 ? 'checked' : ''}>
Status öffentlich anzeigen
</label>
</div>
<div class="form-group">
<label class="form-label">Notiz <span style="color:var(--c-text-secondary)">(intern)</span></label>
<textarea class="form-control" name="notiz" rows="2"
placeholder="Interne Notizen…">${_esc(v.notiz || '')}</textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="pf-cancel">Abbrechen</button>
<button type="submit" form="puppy-form" class="btn btn-primary flex-1" id="pf-submit">
${isEdit ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Hinzufügen`}
</button>
`;
UI.modal.open({
title: isEdit ? `${UI.icon('dog')} Welpe bearbeiten` : `${UI.icon('dog')} Welpe hinzufügen`,
body,
footer,
});
document.getElementById('pf-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('puppy-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('pf-submit');
const fd = new FormData(e.target);
const payload = {
name: fd.get('name')?.trim() || null,
geschlecht: fd.get('geschlecht') || null,
farbe: fd.get('farbe')?.trim() || null,
chip_nr: fd.get('chip_nr')?.trim() || null,
geburtsgewicht: fd.get('geburtsgewicht') ? parseFloat(fd.get('geburtsgewicht')) : null,
status: fd.get('status') || 'verfuegbar',
status_sichtbar: fd.get('status_sichtbar') === '1' ? 1 : 0,
notiz: fd.get('notiz')?.trim() || null,
};
await UI.asyncButton(btn, async () => {
if (isEdit) {
await API.litters.updatePuppy(puppy.id, payload);
UI.toast.success('Welpe aktualisiert.');
} else {
await API.litters.addPuppy(litterId, payload);
UI.toast.success('Welpe hinzugefügt.');
}
UI.modal.close();
// Welpen-Liste für diesen Wurf neu laden
await _loadPuppies(litterId);
});
});
}
// ----------------------------------------------------------
// Kaufvertrag Modal
// ----------------------------------------------------------
function _showContractModal(puppy) {
const puppyLabel = puppy.name || `Welpe #${puppy.id}`;
const body = `
<form id="contract-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name des Käufers <span style="color:var(--c-danger)">*</span></label>
<input class="form-control" type="text" name="kaeufer_name" required
placeholder="Vor- und Nachname">
</div>
<div class="form-group">
<label class="form-label">Adresse des Käufers <span style="color:var(--c-danger)">*</span></label>
<textarea class="form-control" name="kaeufer_adresse" rows="2" required
placeholder="Straße, PLZ, Ort"></textarea>
</div>
<div class="form-group">
<label class="form-label">E-Mail des Käufers <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="email" name="kaeufer_email"
placeholder="kaeufer@beispiel.de">
</div>
<div class="form-group">
<label class="form-label">Kaufpreis <span style="color:var(--c-text-secondary)">(optional)</span></label>
<input class="form-control" type="text" name="preis"
placeholder="z. B. 1.500 €">
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="contract-cancel">Abbrechen</button>
<button type="submit" form="contract-form" class="btn btn-primary flex-1" id="contract-submit">
${UI.icon('file-text')} Vertrag erstellen
</button>
`;
UI.modal.open({
title: `${UI.icon('file-text')} Kaufvertrag — ${_esc(puppyLabel)}`,
body,
footer,
});
document.getElementById('contract-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('contract-form')?.addEventListener('submit', e => {
e.preventDefault();
const fd = new FormData(e.target);
const params = new URLSearchParams({
kaeufer_name: fd.get('kaeufer_name')?.trim() || '',
kaeufer_adresse: fd.get('kaeufer_adresse')?.trim() || '',
kaeufer_email: fd.get('kaeufer_email')?.trim() || '',
preis: fd.get('preis')?.trim() || '',
});
const url = `/api/litters/puppies/${puppy.id}/contract?` + params.toString();
window.open(url, '_blank');
UI.modal.close();
});
}
// ----------------------------------------------------------
// Foto-Verwaltung Modal
// ----------------------------------------------------------
async function _showPhotosModal(entityType, entityId, label) {
const modalId = 'photos-modal';
const galleryId = 'photos-gallery';
const uploadFormId = 'photos-upload-form';
const visLabels = {
public: { text: 'Öffentlich', color: 'var(--c-success,#22C55E)' },
inquiry: { text: 'Anfrage', color: '#F59E0B' },
private: { text: 'Privat', color: 'var(--c-text-muted,#9CA3AF)' },
};
const visOrder = ['public', 'inquiry', 'private'];
const body = `
<div id="${galleryId}" style="margin-bottom:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
</div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="${uploadFormId}" style="display:flex;flex-direction:column;gap:var(--space-2)">
<label style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">
${UI.icon('upload-simple')} Foto hochladen
</label>
<input class="form-control" type="file" name="file" accept="image/*,.pdf" required>
</form>
`;
const footer = `
<button type="submit" form="${uploadFormId}" class="btn btn-primary" id="photos-upload-btn">
${UI.icon('upload-simple')} Hochladen
</button>
`;
UI.modal.open({
title: `${UI.icon('images')} Fotos — ${_esc(label)}`,
body,
footer,
});
// Galerie laden
async function _loadGallery() {
const el = document.getElementById(galleryId);
if (!el) return;
try {
const photos = await API.breederPhotos.list(entityType, entityId);
if (!photos.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos vorhanden.</p>`;
return;
}
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:var(--space-2)">
${photos.map(ph => {
const thumb = ph.thumbnail_url || ph.url || '';
const vis = visLabels[ph.visibility] || visLabels.private;
return `
<div style="position:relative;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--c-border);aspect-ratio:1">
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.src='/static/img/placeholder.webp'">
</a>
<button class="photos-vis-btn"
data-photo-id="${ph.id}"
data-vis="${_esc(ph.visibility)}"
title="Sichtbarkeit ändern"
style="position:absolute;bottom:0;left:0;right:0;
background:${vis.color};color:#fff;
border:none;cursor:pointer;font-size:10px;padding:2px 4px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(vis.text)}
</button>
<button class="photos-del-btn"
data-photo-id="${ph.id}"
title="Foto löschen"
style="position:absolute;top:2px;right:2px;
background:rgba(0,0,0,.55);color:#fff;
border:none;border-radius:50%;cursor:pointer;
width:22px;height:22px;display:flex;align-items:center;justify-content:center;font-size:12px">
${UI.icon('x')}
</button>
</div>`;
}).join('')}
</div>`;
// Sichtbarkeit rotieren
el.querySelectorAll('.photos-vis-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const photoId = parseInt(btn.dataset.photoId);
const cur = btn.dataset.vis;
const next = visOrder[(visOrder.indexOf(cur) + 1) % visOrder.length];
try {
await API.breederPhotos.updateVisibility(photoId, next);
_loadGallery();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Ändern der Sichtbarkeit.');
}
});
});
// Löschen
el.querySelectorAll('.photos-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const photoId = parseInt(btn.dataset.photoId);
if (!window.confirm('Foto wirklich löschen?')) return;
try {
await API.breederPhotos.remove(photoId);
_loadGallery();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
});
});
} catch (err) {
const el = document.getElementById(galleryId);
if (el) el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
}
}
_loadGallery();
// Upload
document.getElementById(uploadFormId)?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('photos-upload-btn');
const fd = new FormData(e.target);
const fileInput = e.target.querySelector('[name="file"]');
if (!fileInput?.files?.length) {
UI.toast.error('Bitte eine Datei auswählen.');
return;
}
const uploadFd = new FormData();
uploadFd.append('entity_type', entityType);
uploadFd.append('entity_id', String(entityId));
uploadFd.append('visibility', 'public');
uploadFd.append('file', fileInput.files[0]);
await UI.asyncButton(btn, async () => {
await API.breederPhotos.upload(uploadFd);
UI.toast.success('Foto hochgeladen.');
e.target.reset();
await _loadGallery();
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -54,6 +54,7 @@ window.Page_map = (() => {
parkplatz: [],
treffpunkt: [],
community: [],
zuechter: [],
};
const VISIBLE_KEY = 'by_map_visible_v1';
@ -89,6 +90,7 @@ window.Page_map = (() => {
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
};
// Frontend-Layer → Backend-Typ Mapping
@ -998,13 +1000,15 @@ window.Page_map = (() => {
(_layers.poison || []).forEach(m => m._dangerCircle?.remove());
Object.keys(_layers).forEach(k => { _layers[k] = []; });
const [places, poisonList] = await Promise.allSettled([
const [places, poisonList, breederList] = await Promise.allSettled([
API.places.list(),
_userPos ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) : Promise.resolve([]),
API.breeder.mapMarkers(),
]);
if (places.status === 'fulfilled') _addPlaces(places.value);
if (poisonList.status === 'fulfilled') _addPoison(poisonList.value);
if (breederList.status === 'fulfilled') _addBreeders(breederList.value);
_scheduleOsmLoad();
}
@ -1039,6 +1043,59 @@ window.Page_map = (() => {
});
}
function _addBreeders(breeders) {
if (!_map || !window.L) return;
const t = TYPEN.zuechter;
const cluster = _getCluster('zuechter');
const markers = [];
breeders.forEach(b => {
// Ohne Koordinaten: stillen Skip
if (b.location_lat == null || b.location_lng == null) return;
const icon = L.divIcon({
className: '',
html: `<div style="background:${t.color};color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.7)">${t.icon}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const marker = L.marker([b.location_lat, b.location_lng], { icon, zIndexOffset: t.z ?? 0 })
.bindTooltip(_esc(b.zwingername), { direction: 'top', offset: [0, -16] });
marker.on('click', () => {
const rasseText = b.rasse_text ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${_esc(b.rasse_text)}</div>` : '';
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${_esc(b.stadt)}</div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${_esc(b.zwingername)}</div>
${rasseText}${stadtText}
<button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button>
</div>
`, { maxWidth: 260 }).openPopup();
setTimeout(() => {
document.getElementById('breeder-profile-btn')?.addEventListener('click', () => {
marker.closePopup();
App.navigate('breeder', true, { zwingername: b.zwingername });
});
}, 50);
});
markers.push(marker);
_layers.zuechter.push(marker);
});
cluster.addLayers(markers);
if (_visible.zuechter !== false && _map && !_map.hasLayer(cluster)) {
cluster.addTo(_map);
}
}
function _createSimpleMarker(lat, lon, t, tooltip, onClick) {
const icon = L.divIcon({
className: '',

View file

@ -194,6 +194,9 @@ window.Page_settings = (() => {
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></div>
</div>
<!-- Züchter-Profil Slot -->
<div id="breeder-card-slot"></div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
@ -679,6 +682,280 @@ window.Page_settings = (() => {
});
_loadReferral();
_loadBreederCard();
}
// ----------------------------------------------------------
// ZÜCHTER-CARD — asynchron laden und in Slot rendern
// ----------------------------------------------------------
async function _loadBreederCard() {
const slot = document.getElementById('breeder-card-slot');
if (!slot) return;
let status = null;
try {
status = await API.breeder.status();
} catch {
// API nicht verfügbar — Card weglassen
return;
}
const { rolle, breeder_status, profile } = status;
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)`;
let statusBadge = '';
let actionBlock = '';
if (rolle === 'breeder' || rolle === 'admin') {
statusBadge = `<span class="badge badge-primary" style="background:var(--c-success);color:#fff">
${UI.icon('check-circle')} ${rolle === 'admin' ? 'Admin — alle Züchter-Features verfügbar' : 'Verifizierter Züchter'}
</span>`;
actionBlock = `
<div style="margin-top:var(--space-3);font-size:var(--text-sm);display:flex;flex-direction:column;gap:var(--space-1)">
${profile?.zwingername ? `<div style="color:var(--c-text-secondary)">Zwinger: <strong>${_esc(profile.zwingername)}</strong></div>` : ''}
${profile?.rasse_text ? `<div style="color:var(--c-text-secondary)">Rasse: <strong>${_esc(profile.rasse_text)}</strong></div>` : ''}
</div>
${rolle === 'breeder' && profile ? `
<button class="btn btn-secondary btn-sm" id="breeder-edit-profile-btn" style="margin-top:var(--space-3)">
${UI.icon('pencil-simple')} Profil bearbeiten
</button>` : ''}`;
} else if (breeder_status === 'pending') {
statusBadge = `<span class="badge" style="background:#f59e0b;color:#fff">
${UI.icon('hourglass')} Antrag wird geprüft
</span>`;
} else if (breeder_status === 'rejected') {
statusBadge = `<span class="badge" style="background:var(--c-danger);color:#fff">
${UI.icon('x-circle')} Abgelehnt
</span>`;
actionBlock = `
<div style="margin-top:var(--space-3)">
<button class="btn btn-secondary btn-sm" id="breeder-reapply-btn">
${UI.icon('arrow-counter-clockwise')} Neu beantragen
</button>
</div>`;
} else {
actionBlock = `
<div style="margin-top:var(--space-3)">
<button class="btn btn-primary btn-sm" id="breeder-apply-btn">
${UI.icon('certificate')} Züchter werden
</button>
</div>`;
}
slot.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
Züchter-Profil
</div>
<div style="padding:var(--space-4)">
${statusBadge}
${actionBlock}
</div>
</div>`;
// Button-Handler binden
const applyBtn = slot.querySelector('#breeder-apply-btn');
const reapplyBtn = slot.querySelector('#breeder-reapply-btn');
if (applyBtn || reapplyBtn) {
(applyBtn || reapplyBtn).addEventListener('click', () => _openBreederApplyModal());
}
slot.querySelector('#breeder-edit-profile-btn')?.addEventListener('click', () =>
_openBreederEditModal(profile)
);
}
// ----------------------------------------------------------
// ZÜCHTER-PROFIL BEARBEITEN MODAL
// ----------------------------------------------------------
function _openBreederEditModal(profile) {
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)`;
UI.modal.open({
title: `${UI.icon('pencil-simple')} Züchter-Profil bearbeiten`,
body: `
<form id="breeder-edit-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zwingername</label>
<input name="zwingername" type="text" maxlength="100" style="${inputStyle}"
value="${_esc(profile?.zwingername || '')}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Rasse</label>
<input name="rasse_text" type="text" maxlength="100" style="${inputStyle}"
value="${_esc(profile?.rasse_text || '')}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Zuchtverein</label>
<input name="verein" type="text" maxlength="100" style="${inputStyle}"
value="${_esc(profile?.verein || '')}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Stadt</label>
<input name="stadt" type="text" maxlength="80" style="${inputStyle}"
value="${_esc(profile?.stadt || '')}">
</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<input name="vdh_mitglied" type="checkbox" id="edit-breeder-vdh"
style="width:18px;height:18px;cursor:pointer;flex-shrink:0"
${profile?.vdh_mitglied ? 'checked' : ''}>
<label for="edit-breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">VDH-Mitglied</label>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Website (optional)</label>
<input name="website" type="url" maxlength="200" style="${inputStyle}"
value="${_esc(profile?.website || '')}" placeholder="https://mein-zwinger.de">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Beschreibung (optional)</label>
<textarea name="beschreibung" maxlength="500" rows="3"
style="${inputStyle};resize:vertical">${_esc(profile?.beschreibung || '')}</textarea>
</div>
</form>`,
footer: `
<div style="display:flex;gap:var(--space-2);width:100%">
<button type="submit" form="breeder-edit-form" class="btn btn-primary flex-1" id="breeder-edit-submit">Speichern</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>`,
});
document.getElementById('breeder-edit-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('breeder-edit-submit');
await UI.asyncButton(btn, async () => {
const form = e.target;
const data = {
zwingername: form.zwingername.value.trim() || undefined,
rasse_text: form.rasse_text.value.trim() || undefined,
verein: form.verein.value.trim() || undefined,
stadt: form.stadt.value.trim() || undefined,
vdh_mitglied: form.vdh_mitglied.checked ? 1 : 0,
website: form.website.value.trim() || undefined,
beschreibung: form.beschreibung.value.trim() || undefined,
};
await API.breeder.updateProfile(data);
UI.modal.close?.();
UI.toast.success('Profil aktualisiert.');
_loadBreederCard();
});
});
}
// ----------------------------------------------------------
// ZÜCHTER-ANTRAG MODAL
// ----------------------------------------------------------
function _openBreederApplyModal() {
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)`;
UI.modal.open({
title: `${UI.icon('certificate')} Züchter-Antrag stellen`,
body: `
<form id="breeder-apply-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Zwingername <span style="color:var(--c-danger)">*</span>
</label>
<input name="zwingername" type="text" maxlength="100" required
placeholder="z. B. vom Sonnenfeld"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Rasse <span style="color:var(--c-danger)">*</span>
</label>
<input name="rasse_text" type="text" maxlength="100" required
placeholder="z. B. Labrador Retriever"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Zuchtverein <span style="color:var(--c-danger)">*</span>
</label>
<input name="verein" type="text" maxlength="100" required
placeholder="z. B. DLRG, VDH, BCD"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Stadt <span style="color:var(--c-danger)">*</span>
</label>
<input name="stadt" type="text" maxlength="80" required
placeholder="z. B. München"
style="${inputStyle}">
</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<input name="vdh_mitglied" type="checkbox" id="breeder-vdh"
style="width:18px;height:18px;cursor:pointer;flex-shrink:0">
<label for="breeder-vdh" style="font-size:var(--text-sm);cursor:pointer">
VDH-Mitglied
</label>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Website (optional)
</label>
<input name="website" type="url" maxlength="200"
placeholder="https://mein-zwinger.de"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Beschreibung (optional)
</label>
<textarea name="beschreibung" maxlength="500" rows="3"
placeholder="Kurze Beschreibung deines Zwingers"
style="${inputStyle};resize:vertical"></textarea>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">
Dokument hochladen <span style="color:var(--c-danger)">*</span>
</label>
<input name="dokument" type="file" id="breeder-doc-input" required
accept=".pdf,.jpg,.jpeg,.png,.webp"
style="font-size:var(--text-sm);width:100%;box-sizing:border-box">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
Zuchtbuch-Eintrag, Vereinsmitgliedschaft o.ä. (PDF, JPG, PNG, WebP)
</div>
</div>
</form>
`,
footer: `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="breeder-apply-form" class="btn btn-primary" id="breeder-apply-submit"
style="width:100%">Antrag einreichen</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
</div>
`,
});
document.getElementById('breeder-apply-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('breeder-apply-submit');
await UI.asyncButton(btn, async () => {
const form = e.target;
const fd = new FormData(form);
// Checkbox-Wert normalisieren
fd.set('vdh_mitglied', form.querySelector('[name="vdh_mitglied"]').checked ? '1' : '0');
await API.breeder.apply(fd);
UI.modal.close?.();
UI.toast.success('Antrag eingereicht. Du wirst benachrichtigt sobald er geprüft wurde.');
// Card neu laden
_loadBreederCard();
});
});
}
// ----------------------------------------------------------

View file

@ -0,0 +1,280 @@
/* ============================================================
BAN YARO Wurfbörse
Öffentliche Wurfankündigungen aller Züchter
============================================================ */
window.Page_wurfboerse = (() => {
let _container = null;
let _appState = null;
let _data = [];
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _esc(s) {
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso + 'T12:00:00');
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function _statusBadge(status) {
const map = {
geplant: { label: 'Geplant', cls: 'wb-badge--geplant' },
geboren: { label: 'Geboren', cls: 'wb-badge--geboren' },
verfuegbar: { label: 'Verfügbar', cls: 'wb-badge--verfuegbar' },
abgeschlossen: { label: 'Abgeschlossen', cls: 'wb-badge--abgeschlossen' },
};
const s = map[status] || { label: status, cls: 'wb-badge--geplant' };
return `<span class="wb-badge ${s.cls}">${s.label}</span>`;
}
function _truncate(text, max) {
if (!text) return '';
return text.length > max ? text.slice(0, max).trimEnd() + '…' : text;
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
await _loadData();
}
function refresh() { _loadData(); }
function onDogChange() {}
// ----------------------------------------------------------
// Grundstruktur rendern
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="wb-layout">
<!-- Filter-Leiste -->
<div class="wb-filter-bar">
<div class="wb-filter-fields">
<input
class="form-control wb-filter-rasse"
id="wb-filter-rasse"
type="text"
placeholder="Rasse suchen…"
autocomplete="off"
>
<select class="form-control wb-filter-status" id="wb-filter-status">
<option value="">Alle Status</option>
<option value="geplant">Geplant</option>
<option value="verfuegbar">Verfügbar</option>
<option value="geboren">Geboren</option>
</select>
</div>
<button class="btn btn-primary wb-filter-btn" id="wb-search-btn">
${UI.icon('magnifying-glass')} Suchen
</button>
</div>
<!-- Ergebnisliste -->
<div id="wb-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
`;
// Suchen-Button
document.getElementById('wb-search-btn').addEventListener('click', () => _loadData());
// Enter im Rasse-Feld
document.getElementById('wb-filter-rasse').addEventListener('keydown', e => {
if (e.key === 'Enter') _loadData();
});
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _loadData() {
const rasseEl = document.getElementById('wb-filter-rasse');
const statusEl = document.getElementById('wb-filter-status');
const params = {};
if (rasseEl?.value.trim()) params.rasse = rasseEl.value.trim();
if (statusEl?.value) params.status = statusEl.value;
try {
_data = await API.litters.public(params);
_renderList();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden der Wurfbörse.');
_renderEmpty('Fehler beim Laden', 'Bitte später erneut versuchen.');
}
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
function _renderList() {
const el = document.getElementById('wb-list');
if (!el) return;
if (!_data.length) {
_renderEmpty('Keine Würfe gefunden', 'Für die gewählten Filter gibt es aktuell keine Wurfankündigungen.');
return;
}
el.innerHTML = `<div class="wb-cards">${_data.map(b => _cardHTML(b)).join('')}</div>`;
el.querySelectorAll('.wb-profile-btn').forEach(btn => {
btn.addEventListener('click', () => {
const zwingername = btn.dataset.zwingername;
App.navigate('breeder', true, { zwingername });
});
});
el.querySelectorAll('.wb-chat-btn').forEach(btn => {
btn.addEventListener('click', () => {
const breederId = parseInt(btn.dataset.breederUserId, 10);
_contactBreeder(breederId);
});
});
el.querySelectorAll('.wb-login-btn').forEach(btn => {
btn.addEventListener('click', () => App.navigate('settings'));
});
}
function _renderEmpty(title, text) {
const el = document.getElementById('wb-list');
if (!el) return;
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
<h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
</div>`;
}
// ----------------------------------------------------------
// Card HTML
// ----------------------------------------------------------
function _cardHTML(b) {
// Züchter-Kopfzeile
const zuechterName = b.zuechter_name || b.zwingername || '—';
const zwingername = b.zwingername ? ` (${_esc(b.zwingername)})` : '';
const stadtLine = b.stadt ? ` · ${_esc(b.stadt)}` : '';
// Elterntiere
const elternParts = [];
if (b.vater_name) elternParts.push(_esc(b.vater_name));
if (b.mutter_name) elternParts.push(_esc(b.mutter_name));
const elternLine = elternParts.length === 2
? `<div class="wb-card-eltern">${UI.icon('gender-male')} ${elternParts[0]} × ${UI.icon('gender-female')} ${elternParts[1]}</div>`
: elternParts.length === 1
? `<div class="wb-card-eltern">${elternParts[0]}</div>`
: '';
// Datum
let datumLine = '';
if (b.geburt_datum) {
datumLine = `<div class="wb-card-datum">${UI.icon('calendar-dots')} Geboren: ${_fmtDate(b.geburt_datum)}</div>`;
} else if (b.erwartetes_datum) {
datumLine = `<div class="wb-card-datum">${UI.icon('calendar-dots')} Erwartet: ${_fmtDate(b.erwartetes_datum)}</div>`;
}
// Welpen-Verfügbarkeit
let welpenLine = '';
if (b.welpen_gesamt != null || b.welpen_verfuegbar != null) {
const gesamt = b.welpen_gesamt != null ? b.welpen_gesamt : '?';
const verfuegb = b.welpen_verfuegbar != null ? b.welpen_verfuegbar : '?';
welpenLine = `<div class="wb-card-welpen">${UI.icon('paw-print')} Welpen verfügbar: ${_esc(String(verfuegb))} von ${_esc(String(gesamt))}</div>`;
}
// Preis
const preisLine = b.preis_spanne
? `<div class="wb-card-preis">${UI.icon('currency-eur')} Preis: ${_esc(b.preis_spanne)} €</div>`
: '';
// Gesundheitstests
const gesundheitLine = b.gesundheitstests
? `<div class="wb-card-gesundheit">${UI.icon('heart')} ${_esc(b.gesundheitstests)}</div>`
: '';
// Beschreibung (max. 150 Zeichen)
const beschreibungLine = b.beschreibung
? `<div class="wb-card-beschreibung">${_esc(_truncate(b.beschreibung, 150))}</div>`
: '';
return `
<div class="wb-card">
<div class="wb-card-header">
<div class="wb-card-zuechter">
${_esc(zuechterName)}${zwingername}${stadtLine}
</div>
${_statusBadge(b.status)}
</div>
${b.rasse_text ? `<div class="wb-card-rasse">${UI.icon('dog')} ${_esc(b.rasse_text)}</div>` : ''}
<div class="wb-card-details">
${elternLine}
${datumLine}
${welpenLine}
${preisLine}
${gesundheitLine}
${beschreibungLine}
</div>
<div class="wb-card-footer">
<button
class="btn btn-secondary btn-sm wb-profile-btn"
data-zwingername="${_esc(b.zwingername || '')}"
>
${UI.icon('user')} Profil ansehen
</button>
${(() => {
const isLoggedIn = !!_appState?.user;
const isOwnProfile = _appState?.user?.id === b.breeder_user_id;
if (isOwnProfile) return '';
if (isLoggedIn) {
return `<button
class="btn btn-primary btn-sm wb-chat-btn"
data-breeder-user-id="${b.breeder_user_id || ''}"
>
${UI.icon('chat-circle')} Nachricht senden
</button>`;
}
return `<button class="btn btn-primary btn-sm wb-login-btn">
${UI.icon('sign-in')} Anmelden um zu schreiben
</button>`;
})()}
</div>
</div>`;
}
// ----------------------------------------------------------
// Züchter per Chat kontaktieren
// ----------------------------------------------------------
async function _contactBreeder(breederId) {
if (!_appState?.user) {
App.navigate('settings');
return;
}
try {
const conv = await API.chat.start(breederId);
App.navigate('chat');
} catch (e) {
UI.toast.error(e.message || 'Chat konnte nicht geöffnet werden.');
}
}
return { init, refresh, onDogChange };
})();

View file

@ -0,0 +1,718 @@
/* ============================================================
BAN YARO Zucht-Profil
Vollständiges Profil eines Zuchthundes:
Basisdaten + Stammbaum (4 Generationen) + Gesundheitstests
+ Gentests + Titel.
============================================================ */
window.Page_zucht_profil = (() => {
let _container = null;
let _appState = null;
let _hundId = null;
let _hund = null;
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso + 'T12:00:00');
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
// ----------------------------------------------------------
// Badge-Farben
// ----------------------------------------------------------
function _healthBadge(testTyp, ergebnis) {
const e = (ergebnis || '').trim().toUpperCase();
let color = '#6B7280';
if (testTyp === 'HD') {
if (['A1', 'A2', 'A'].includes(e)) color = '#22C55E';
else if (['B1', 'B2', 'B'].includes(e)) color = '#86EFAC';
else if (e === 'C') color = '#EAB308';
else if (e === 'D') color = '#F97316';
else if (e === 'E') color = '#EF4444';
} else if (testTyp === 'ED') {
if (e === '0' || e === 'ED 0') color = '#22C55E';
else if (e === '1' || e === 'ED 1') color = '#EAB308';
else if (e === '2' || e === 'ED 2') color = '#F97316';
else if (e === '3' || e === 'ED 3') color = '#EF4444';
} else {
const el = e.toLowerCase();
if (el === 'clear') color = '#22C55E';
if (el === 'carrier') color = '#EAB308';
if (el === 'affected') color = '#EF4444';
}
return `<span class="zp-badge" style="background:${color}">${_esc(ergebnis || '—')}</span>`;
}
function _geneticBadge(ergebnis) {
const e = (ergebnis || '').toLowerCase().trim();
let color = '#6B7280';
if (e === 'clear') color = '#22C55E';
if (e === 'carrier') color = '#F59E0B';
if (e === 'affected') color = '#EF4444';
return `<span class="zp-badge" style="background:${color}">${_esc(ergebnis || '—')}</span>`;
}
function _titleTypBadge(typ) {
const t = (typ || '').toLowerCase();
const colors = {
ausstellung: '#8B5CF6',
arbeit: '#F59E0B',
champion: '#EF4444',
sport: '#3B82F6',
zucht: '#10B981',
};
const color = colors[t] || '#6B7280';
return `<span class="zp-badge" style="background:${color}">${_esc(typ || '—')}</span>`;
}
// ----------------------------------------------------------
// INIT / LIFECYCLE
// ----------------------------------------------------------
async function init(container, appState, params) {
_container = container;
_appState = appState;
_hundId = params?.id ? parseInt(params.id) : null;
if (!_hundId) {
_container.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('warning')}</div>
<p style="color:var(--c-text-secondary)">Kein Hund angegeben.</p>
</div>`;
return;
}
_renderSkeleton();
await _load();
}
function refresh() {
if (_hundId) _load();
}
function onDogChange() {}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _load() {
try {
const [hund, tree, health, genetic, titles] = await Promise.all([
API.zuchthunde.get(_hundId),
API.zuchthunde.pedigree(_hundId, 4),
API.zuchthunde.healthTests(_hundId),
API.zuchthunde.geneticTests(_hundId),
API.zuchthunde.titles(_hundId),
]);
_hund = hund;
_renderAll(hund, tree, health, genetic, titles);
} catch (err) {
_container.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('warning')}</div>
<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Laden.')}</p>
<button class="btn btn-secondary" onclick="history.back()">Zurück</button>
</div>`;
}
}
// ----------------------------------------------------------
// Skeleton während des Ladens
// ----------------------------------------------------------
function _renderSkeleton() {
_container.innerHTML = `
<div class="zp-layout">
<button class="btn btn-ghost btn-sm zp-back-btn" style="margin-bottom:var(--space-4)">
${UI.icon('arrow-left')} Zurück zur Zuchtkartei
</button>
${UI.skeleton(6)}
</div>`;
_container.querySelector('.zp-back-btn')?.addEventListener('click', () => {
if (window.history.length > 1) history.back();
else App.navigate('zuchthunde');
});
}
// ----------------------------------------------------------
// Vollständige Seite rendern
// ----------------------------------------------------------
function _renderAll(hund, tree, health, genetic, titles) {
_container.innerHTML = `
<div class="zp-layout">
<!-- Zurück + Link teilen -->
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-4)">
<button class="btn btn-ghost btn-sm zp-back-btn">
${UI.icon('arrow-left')} Zurück zur Zuchtkartei
</button>
<button class="btn btn-ghost btn-sm zp-share-btn" title="Link teilen">
${UI.icon('link-simple')} Link teilen
</button>
</div>
<!-- Header -->
${_renderHeader(hund)}
<!-- Stammbaum -->
<div class="zp-section">
<h3 class="zp-section-title">${UI.icon('tree-structure')} Stammbaum</h3>
<div class="zp-pedigree-wrap">
${_renderPedigree(tree, 4)}
</div>
</div>
<!-- Gesundheitstests -->
<div class="zp-section">
<h3 class="zp-section-title">${UI.icon('heart')} Gesundheitstests</h3>
${_renderHealthTable(health)}
</div>
<!-- Gentests -->
<div class="zp-section">
<h3 class="zp-section-title">${UI.icon('dna')} Gentests</h3>
${_renderGeneticTable(genetic)}
</div>
<!-- Titel -->
<div class="zp-section">
<h3 class="zp-section-title">${UI.icon('trophy')} Titel & Auszeichnungen</h3>
${_renderTitlesList(titles)}
</div>
</div>`;
// Zurück-Button verdrahten
_container.querySelector('.zp-back-btn')?.addEventListener('click', () => {
if (window.history.length > 1) history.back();
else App.navigate('zuchthunde');
});
// Link teilen
_container.querySelector('.zp-share-btn')?.addEventListener('click', () => {
const url = window.location.origin + '#zucht-profil&id=' + _hundId;
navigator.clipboard.writeText(url).then(() => {
UI.toast.success('Link kopiert!');
}).catch(() => {
UI.toast.error('Kopieren nicht möglich.');
});
});
// Stammbaum-Klicks verdrahten (außer Gen 1 = Proband selbst)
_container.querySelectorAll('.pedigree-cell[data-hund-id]').forEach(cell => {
const nodeId = parseInt(cell.dataset.hundId);
const gen = parseInt(cell.dataset.gen || '1');
if (gen === 1) return; // Proband — kein Klick nötig
cell.style.cursor = 'pointer';
cell.addEventListener('click', () => {
App.navigate('zucht-profil', true, { id: nodeId });
});
});
}
// ----------------------------------------------------------
// Header
// ----------------------------------------------------------
function _renderHeader(h) {
const gIcon = h.geschlecht === 'maennlich' ? UI.icon('gender-male') :
h.geschlecht === 'weiblich' ? UI.icon('gender-female') : UI.icon('dog');
const geburtsjahrLabel = h.geburtsdatum
? `*${new Date(h.geburtsdatum + 'T12:00:00').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}`
: null;
const geschlechtLabel = h.geschlecht === 'maennlich' ? 'Rüde' :
h.geschlecht === 'weiblich' ? 'Hündin' : null;
const metaItems = [
h.rasse ? `${UI.icon('paw-print')} ${_esc(h.rasse)}` : null,
geschlechtLabel ? `${gIcon} ${geschlechtLabel}` : null,
geburtsjahrLabel ? `${UI.icon('calendar-dots')} ${geburtsjahrLabel}` : null,
].filter(Boolean);
const identItems = [
h.chip_nr ? `${UI.icon('barcode')} Chip: ${_esc(h.chip_nr)}` : null,
h.zuchtbuchnummer ? `${UI.icon('book-open')} ZB-Nr.: ${_esc(h.zuchtbuchnummer)}` : null,
h.taetowier_nr ? `${UI.icon('pencil-simple')} Tätowierung: ${_esc(h.taetowier_nr)}` : null,
h.farbe ? `${UI.icon('palette')} ${_esc(h.farbe)}` : null,
].filter(Boolean);
const elternItems = [
h.vater_name ? `Vater: ${_esc(h.vater_name)}` : null,
h.mutter_name ? `Mutter: ${_esc(h.mutter_name)}` : null,
].filter(Boolean);
return `
<div class="zp-header">
<div class="zp-header-icon">${gIcon}</div>
<div class="zp-header-body">
<h2 class="zp-header-name">
${_esc(h.name)}
${h.rufname ? `<span class="zp-header-rufname">(${_esc(h.rufname)})</span>` : ''}
</h2>
${metaItems.length ? `
<div class="zp-header-meta">
${metaItems.map(m => `<span>${m}</span>`).join('<span class="zp-meta-sep">·</span>')}
</div>` : ''}
${identItems.length ? `
<div class="zp-header-meta zp-header-ident">
${identItems.map(m => `<span>${m}</span>`).join('')}
</div>` : ''}
${elternItems.length ? `
<div class="zp-header-meta" style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${elternItems.join(' &nbsp;·&nbsp; ')}
</div>` : ''}
${h.notiz ? `<div class="zp-header-notiz">${_esc(h.notiz)}</div>` : ''}
</div>
</div>`;
}
// ----------------------------------------------------------
// Stammbaum
// ----------------------------------------------------------
function _renderPedigree(tree, generations) {
const totalRows = Math.pow(2, generations - 1); // 8 für 4 Generationen
// Alle Knoten rekursiv einsammeln
function collect(node, gen, rowStart, rowSpan) {
if (gen > generations) return [];
const items = [{ node: node || null, gen, rowStart, rowSpan }];
if (gen < generations) {
const half = rowSpan / 2;
items.push(...collect(node?.vater || null, gen + 1, rowStart, half));
items.push(...collect(node?.mutter || null, gen + 1, rowStart + half, half));
}
return items;
}
const items = collect(tree, 1, 1, totalRows);
const cells = items.map(({ node, gen, rowStart, rowSpan }) => {
const isEmpty = !node;
return `
<div class="pedigree-cell ${isEmpty ? 'pedigree-empty' : ''}"
style="grid-column:${gen}; grid-row:${rowStart} / span ${rowSpan};
align-items:center; display:flex;"
data-gen="${gen}"
${node ? `data-hund-id="${node.id}"` : ''}>
${node ? _pedigreeNodeHTML(node, gen) : `<div class="pedigree-unknown">${UI.icon('question')}</div>`}
</div>`;
}).join('');
return `
<div class="pedigree-grid"
style="
display:grid;
grid-template-columns:repeat(${generations}, minmax(160px, 1fr));
grid-template-rows:repeat(${totalRows}, minmax(56px, auto));
gap:4px;
min-width:${generations * 170}px;
">
${cells}
</div>`;
}
function _pedigreeNodeHTML(node, gen) {
const gIcon = node.geschlecht === 'maennlich' ? UI.icon('gender-male') :
node.geschlecht === 'weiblich' ? UI.icon('gender-female') : '';
const dob = node.geburtsdatum
? `*${new Date(node.geburtsdatum + 'T12:00:00').getFullYear()}`
: '';
const isProband = gen === 1;
const bgColor = isProband ? 'var(--c-primary)' : 'var(--c-surface-2, var(--c-surface))';
const textColor = isProband ? '#fff' : 'var(--c-text)';
const borderColor = isProband ? 'var(--c-primary)' : 'var(--c-border)';
return `
<div class="pedigree-node pedigree-node--gen${gen}"
style="background:${bgColor};
color:${textColor};
border:1px solid ${borderColor};
border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);
width:100%;
box-sizing:border-box;">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
${gIcon} ${_esc(node.name)}
</div>
${node.rufname
? `<div style="font-size:var(--text-xs);opacity:.75;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;">${_esc(node.rufname)}</div>`
: ''}
${dob
? `<div style="font-size:var(--text-xs);opacity:.65;">${dob}</div>`
: ''}
${node.zuchtbuchnummer
? `<div style="font-size:var(--text-xs);opacity:.55;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;">${_esc(node.zuchtbuchnummer)}</div>`
: ''}
</div>`;
}
// ----------------------------------------------------------
// Gesundheitstests-Tabelle
// ----------------------------------------------------------
function _renderHealthTable(tests) {
if (!tests || !tests.length) {
return `<p class="zp-empty">Noch keine Gesundheitstests eingetragen.</p>`;
}
const rows = tests.map(t => `
<tr>
<td class="zp-td">
<span style="font-weight:var(--weight-medium)">${_esc(t.test_typ || 'Sonstiges')}</span>
${t.test_name ? `<br><span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(t.test_name)}</span>` : ''}
</td>
<td class="zp-td">${_healthBadge(t.test_typ || '', t.ergebnis)}</td>
<td class="zp-td zp-td-muted">${t.untersuch_am ? _fmtDate(t.untersuch_am) : '—'}</td>
<td class="zp-td zp-td-muted">${t.labor ? _esc(t.labor) : '—'}</td>
</tr>`).join('');
return `
<div class="zp-table-wrap">
<table class="zp-table">
<thead>
<tr>
<th class="zp-th">Test</th>
<th class="zp-th">Ergebnis</th>
<th class="zp-th">Datum</th>
<th class="zp-th">Labor / Institut</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ----------------------------------------------------------
// Gentests-Tabelle
// ----------------------------------------------------------
function _renderGeneticTable(tests) {
if (!tests || !tests.length) {
return `<p class="zp-empty">Noch keine Gentests eingetragen.</p>`;
}
const rows = tests.map(t => `
<tr>
<td class="zp-td">
<span style="font-weight:var(--weight-medium)">${_esc(t.marker_name || '—')}</span>
</td>
<td class="zp-td">${_geneticBadge(t.ergebnis_klasse)}</td>
<td class="zp-td zp-td-muted">${t.getestet_am ? _fmtDate(t.getestet_am) : '—'}</td>
<td class="zp-td zp-td-muted">${t.labor ? _esc(t.labor) : '—'}</td>
</tr>`).join('');
return `
<div class="zp-table-wrap">
<table class="zp-table">
<thead>
<tr>
<th class="zp-th">Marker / Gen</th>
<th class="zp-th">Ergebnis</th>
<th class="zp-th">Datum</th>
<th class="zp-th">Labor</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ----------------------------------------------------------
// Titel-Liste
// ----------------------------------------------------------
function _renderTitlesList(titles) {
if (!titles || !titles.length) {
return `<p class="zp-empty">Noch keine Titel eingetragen.</p>`;
}
// Chronologisch sortieren (neuestes zuerst)
const sorted = [...titles].sort((a, b) => {
const da = a.verliehen_am || '0000';
const db = b.verliehen_am || '0000';
return db.localeCompare(da);
});
const items = sorted.map(t => `
<div class="zp-title-item">
<div class="zp-title-badges">
${_titleTypBadge(t.titel_typ)}
${t.formwert
? `<span class="zp-badge" style="background:#3B82F6">${_esc(t.formwert)}</span>`
: ''}
</div>
<div class="zp-title-name">${_esc(t.titel_name || '—')}</div>
<div class="zp-title-meta">
${t.verliehen_am ? `${UI.icon('calendar-dots')} ${_fmtDate(t.verliehen_am)}` : ''}
${t.ort ? `&nbsp;·&nbsp; ${UI.icon('map-pin')} ${_esc(t.ort)}` : ''}
${t.richter ? `&nbsp;·&nbsp; ${UI.icon('user')} ${_esc(t.richter)}` : ''}
${t.ausstellung ? `<br><span style="font-size:var(--text-xs)">${UI.icon('ticket')} ${_esc(t.ausstellung)}</span>` : ''}
</div>
</div>`).join('');
return `<div class="zp-titles-list">${items}</div>`;
}
// ----------------------------------------------------------
// CSS (einmalig injizieren)
// ----------------------------------------------------------
(function _injectStyles() {
if (document.getElementById('zp-styles')) return;
const s = document.createElement('style');
s.id = 'zp-styles';
s.textContent = `
/* Layout */
.zp-layout {
padding: var(--space-4) 0;
}
/* Header */
.zp-header {
display: flex;
align-items: flex-start;
gap: var(--space-4);
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-5);
}
.zp-header-icon {
font-size: 2rem;
flex-shrink: 0;
line-height: 1;
margin-top: 2px;
}
.zp-header-body {
flex: 1;
min-width: 0;
}
.zp-header-name {
font-size: var(--text-xl);
font-weight: var(--weight-bold);
margin: 0 0 var(--space-1);
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2);
}
.zp-header-rufname {
font-size: var(--text-base);
font-weight: var(--weight-normal);
color: var(--c-text-secondary);
}
.zp-header-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--c-text-secondary);
margin-top: var(--space-1);
}
.zp-header-ident {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
font-size: var(--text-xs);
margin-top: var(--space-2);
}
.zp-meta-sep {
opacity: .4;
}
.zp-header-notiz {
font-size: var(--text-xs);
color: var(--c-text-secondary);
font-style: italic;
margin-top: var(--space-2);
}
/* Sektion */
.zp-section {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.zp-section-title {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
margin: 0 0 var(--space-4);
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Stammbaum-Wrapper: horizontal scrollbar auf Mobile */
.zp-pedigree-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: var(--space-2);
}
/* Stammbaum-Zellen */
.pedigree-cell {
box-sizing: border-box;
padding: 2px;
min-height: 56px;
}
.pedigree-cell:not(.pedigree-empty):hover .pedigree-node {
opacity: .85;
}
.pedigree-empty {
display: flex;
align-items: center;
justify-content: center;
}
.pedigree-unknown {
width: 100%;
min-height: 52px;
border: 1px dashed var(--c-border);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--c-text-muted);
font-size: var(--text-lg);
opacity: .5;
}
/* Tabellen */
.zp-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: var(--radius-md);
border: 1px solid var(--c-border);
}
.zp-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
white-space: nowrap;
}
.zp-th {
padding: var(--space-2) var(--space-3);
text-align: left;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: .04em;
color: var(--c-text-secondary);
background: var(--c-surface-2, var(--c-bg));
border-bottom: 1px solid var(--c-border);
}
.zp-td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--c-border);
vertical-align: middle;
}
.zp-table tbody tr:last-child .zp-td {
border-bottom: none;
}
.zp-table tbody tr:hover {
background: var(--c-surface-2, var(--c-bg));
}
.zp-td-muted {
color: var(--c-text-secondary);
font-size: var(--text-xs);
}
/* Badge */
.zp-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: #fff;
white-space: nowrap;
}
/* Titel-Liste */
.zp-titles-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.zp-title-item {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface-2, var(--c-bg));
}
.zp-title-badges {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex-shrink: 0;
}
.zp-title-name {
font-weight: var(--weight-semibold);
font-size: var(--text-sm);
flex: 1;
min-width: 0;
}
.zp-title-meta {
font-size: var(--text-xs);
color: var(--c-text-secondary);
margin-top: 2px;
}
/* Leer-Zustand */
.zp-empty {
color: var(--c-text-muted);
font-size: var(--text-sm);
margin: 0;
font-style: italic;
}
`;
document.head.appendChild(s);
})();
return { init, refresh, onDogChange };
})();

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v450';
const CACHE_VERSION = 'by-v465';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten