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:
parent
58cb2b4ad3
commit
91340be5a3
24 changed files with 6660 additions and 27 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
366
backend/routes/breeder.py
Normal 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]
|
||||
356
backend/routes/breeder_photos.py
Normal file
356
backend/routes/breeder_photos.py
Normal 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
|
||||
|
|
@ -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
575
backend/routes/litters.py
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
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 & Unterschrift Verkäufer</p>
|
||||
</div>
|
||||
<div class="signature-line">
|
||||
<hr>
|
||||
<p>Ort, Datum & 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)
|
||||
779
backend/routes/zucht_hunde.py
Normal file
779
backend/routes/zucht_hunde.py
Normal 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)
|
||||
|
|
@ -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
|
||||
------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
210
backend/static/js/pages/breeder.js
Normal file
210
backend/static/js/pages/breeder.js
Normal 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 };
|
||||
|
||||
})();
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
972
backend/static/js/pages/litters.js
Normal file
972
backend/static/js/pages/litters.js
Normal 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 =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)} ·
|
||||
${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar
|
||||
· ${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 };
|
||||
|
||||
})();
|
||||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
280
backend/static/js/pages/wurfboerse.js
Normal file
280
backend/static/js/pages/wurfboerse.js
Normal 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 =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 };
|
||||
|
||||
})();
|
||||
718
backend/static/js/pages/zucht-profil.js
Normal file
718
backend/static/js/pages/zucht-profil.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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(' · ')}
|
||||
</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 ? ` · ${UI.icon('map-pin')} ${_esc(t.ort)}` : ''}
|
||||
${t.richter ? ` · ${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 };
|
||||
|
||||
})();
|
||||
1299
backend/static/js/pages/zuchthunde.js
Normal file
1299
backend/static/js/pages/zuchthunde.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue