banyaro/backend/static/js/pages/litters.js
rene c8ae514c01 Feature: Tierschutz-Check, KI-Züchter-Features, Export, SEO-Update
Tierschutz-System (immer aktiv, nicht abschaltbar):
- welfare_check.py: regelbasierte Prüfung IK, Alter, Deckpause, Wurfanzahl, Genetik
- Grün/Gelb/Rot-Modal bei Wurf anlegen + Probeverpaarung
- Bei kritischem Befund + "Trotzdem fortfahren" → automatische Admin-Mail
- Tierschutz-Check nie durch Nutzer deaktivierbar

KI-Züchter-Features (pro User an/abschaltbar außer Tierschutz):
- routes/zucht_ki.py: 5 Endpunkte — Wurfankündigung, Genetik-Erklärung,
  Paarungsanalyse, Hund-Beschreibung, Jahresbericht
- Toggles in Einstellungen (ki_zucht_* Felder)
- KI-Buttons in litters.js + zuchthunde.js

KI-Routing: Privilegierte Rollen (Admin, Züchter, Moderator, Manager)
nutzen Claude Sonnet primär, lokales LLM als Fallback

Datenexport: routes/breeder_export.py — ZIP mit HTML-Dossier + ODS
(odfpy hinzugefügt in requirements.txt)

Admin-Profil: POST /admin/breeder/create-profile für Schnellprofil ohne
Antragsprozess; Admin-Rolle bleibt erhalten

Wurfformular: Dropdown aus Zuchtkartei für Vater/Mutter mit Auto-Fill;
litters.vater_id + mutter_id als FK auf zucht_hunde

Probeverpaarung: heart-fill Icon + Welfare-Block im Ergebnis

Landing Page: Züchter-Section + Feature-Gruppe, Meta-Tags, JSON-LD,
keywords, softwareVersion 2.1

SEO: llms.txt vollständig überarbeitet, robots.txt Züchter-Pfade,
sitemap.xml um Wurfbörse + Züchter-Profile erweitert

SW by-v474, APP_VER 451
2026-04-28 19:49:54 +02:00

1143 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Wurfverwaltung
Züchter verwalten ihre Würfe und Welpen
============================================================ */
window.Page_litters = (() => {
let _container = null;
let _appState = null;
let _litters = []; // geladene Würfe
let _openId = null; // aufgeklappter Wurf
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _emptyState(icon, title, text) {
return `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon(icon)}</div>
<h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
</div>`;
}
function _esc(s) {
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _statusBadge(status) {
const map = {
geplant: { label: 'Geplant', color: '#6B7280' },
geboren: { label: 'Geboren', color: '#3B82F6' },
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
abgeschlossen: { label: 'Abgeschlossen', color: '#374151' },
};
const s = map[status] || { label: status, color: '#6B7280' };
return `<span class="litters-badge" style="background:${s.color}">${_esc(s.label)}</span>`;
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso + 'T12:00:00');
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function _genderIcon(g) {
if (g === 'maennlich') return UI.icon('gender-male');
if (g === 'weiblich') return UI.icon('gender-female');
return '';
}
function _puppyStatusBadge(status) {
const map = {
verfuegbar: { label: 'Verfügbar', color: '#22C55E' },
reserviert: { label: 'Reserviert', color: '#F59E0B' },
abgegeben: { label: 'Abgegeben', color: '#6B7280' },
};
const s = map[status] || { label: status, color: '#9CA3AF' };
return `<span class="litters-badge litters-badge--sm" style="background:${s.color}">${_esc(s.label)}</span>`;
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
// Auth-Guard
const u = _appState.user;
if (!u || (u.rolle !== 'breeder' && u.rolle !== 'admin')) {
_container.innerHTML = _emptyState('lock', 'Kein Zugriff', 'Diese Seite ist nur für verifizierte Züchter.');
return;
}
_render();
await _loadLitters();
}
function refresh() {
const u = _appState?.user;
if (!u || (u.rolle !== 'breeder' && u.rolle !== 'admin')) return;
_loadLitters();
}
function onDogChange() {}
// ----------------------------------------------------------
// Grundstruktur rendern
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="litters-layout">
<div class="by-toolbar">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('dog')} Meine Würfe
</h2>
<button class="btn btn-primary btn-sm" id="litters-new-btn">
${UI.icon('plus')} Neuer Wurf
</button>
</div>
<div id="litters-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
</div>
</div>
`;
document.getElementById('litters-new-btn')?.addEventListener('click', () => {
_showLitterForm(null);
});
}
// ----------------------------------------------------------
// Würfe laden
// ----------------------------------------------------------
async function _loadLitters() {
try {
_litters = await API.litters.myList();
_renderList();
} catch (err) {
if (err.status === 404) {
const el = document.getElementById('litters-list');
if (el) el.innerHTML = _emptyState('certificate', 'Kein Züchter-Profil',
'Stelle zuerst einen Züchter-Antrag in den Einstellungen, um Würfe verwalten zu können.');
} else {
UI.toast.error(err.message || 'Fehler beim Laden der Würfe.');
}
}
}
// ----------------------------------------------------------
// Würfe-Liste rendern
// ----------------------------------------------------------
function _renderList() {
const el = document.getElementById('litters-list');
if (!el) return;
if (!_litters.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="litters-first-btn">
${UI.icon('plus')} Ersten Wurf anlegen
</button>
</div>`;
document.getElementById('litters-first-btn')?.addEventListener('click', () => _showLitterForm(null));
return;
}
el.innerHTML = _litters.map(l => _litterCardHTML(l)).join('');
// Events
el.querySelectorAll('.litters-card-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_togglePuppies(id);
});
});
el.querySelectorAll('.litters-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (litter) _showPhotosModal('litter', litter.id, litter.zwingername || `Wurf #${litter.id}`);
});
});
el.querySelectorAll('.litters-parent-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (!litter) return;
const label = [litter.vater_name, litter.mutter_name].filter(Boolean).join(' × ') || `Eltern Wurf #${id}`;
_showPhotosModal('parent', litter.id, label);
});
});
el.querySelectorAll('.litters-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const litter = _litters.find(l => l.id === id);
if (litter) _showLitterForm(litter);
});
});
el.querySelectorAll('.litters-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_deleteLitter(id);
});
});
el.querySelectorAll('.litters-ki-announce-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_showKiAnnouncement(id);
});
});
el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
_showPuppyForm(id, null);
});
});
// Aufgeklappten Wurf wiederherstellen
if (_openId) _togglePuppies(_openId, true);
}
function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
const datumLabel = l.geburt_datum
? `Geburt: ${_fmtDate(l.geburt_datum)}`
: l.erwartetes_datum
? `Erwartet: ${_fmtDate(l.erwartetes_datum)}`
: '—';
const elternLabel = [l.vater_name, l.mutter_name]
.filter(Boolean)
.map(n => _esc(n))
.join(' × ') || '—';
const sichtbarLabel = l.sichtbar
? `<span style="color:var(--c-success);font-size:var(--text-xs)">${UI.icon('eye')} Öffentlich</span>`
: `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">${UI.icon('eye-slash')} Nicht öffentlich</span>`;
return `
<div class="litters-card" id="litter-card-${l.id}">
<div class="litters-card-header">
<div style="flex:1;min-width:0">
<div class="litters-card-title">
${elternLabel}
${_statusBadge(l.status)}
</div>
<div class="litters-card-meta">
${UI.icon('calendar-dots')} ${_esc(datumLabel)} &nbsp;·&nbsp;
${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar
&nbsp;·&nbsp; ${sichtbarLabel}
</div>
${l.preis_spanne ? `<div class="litters-card-meta">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</div>` : ''}
</div>
<div class="litters-card-actions">
<button class="btn btn-ghost btn-sm litters-card-toggle" data-id="${l.id}"
title="Welpen anzeigen">
${UI.icon('caret-down')} Welpen
</button>
<button class="btn btn-ghost btn-sm litters-photos-btn" data-id="${l.id}"
title="Wurf-Fotos verwalten">
${UI.icon('images')} Fotos
</button>
<button class="btn btn-ghost btn-sm litters-parent-photos-btn" data-id="${l.id}"
title="Elterntier-Fotos verwalten">
${UI.icon('users')} Eltern
</button>
${_appState.user?.ki_zucht_wurfankuendigung !== 0 ? `
<button class="btn btn-ghost btn-sm litters-ki-announce-btn" data-id="${l.id}"
title="KI: Wurfankündigung schreiben">
${UI.icon('sparkle')} Ankündigung
</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)
// ----------------------------------------------------------
async function _showLitterForm(litter) {
const isEdit = !!litter;
const v = litter || {};
const today = new Date().toISOString().slice(0, 10);
// Zuchtkartei laden für Elterntier-Auswahl
let zuchthunde = [];
try { zuchthunde = await API.zuchthunde.list(); } catch {}
const maennlich = zuchthunde.filter(h => h.geschlecht !== 'weiblich');
const weiblich = zuchthunde.filter(h => h.geschlecht !== 'maennlich');
const buildSelect = (name, idName, list, currentId, currentName, placeholder) => {
const opts = list.map(h => {
const label = h.name + (h.rufname ? ` (${h.rufname})` : '') + (h.zuchtbuchnummer ? ` · ${h.zuchtbuchnummer}` : '');
return `<option value="${h.id}" data-name="${_esc(h.name)}" ${currentId == h.id ? 'selected' : ''}>${_esc(label)}</option>`;
}).join('');
return `
<select class="form-control" name="${idName}" id="${idName}-sel" style="margin-bottom:var(--space-2)">
<option value="">— ${placeholder} —</option>
${opts}
</select>
<input class="form-control" type="text" name="${name}" id="${name}-txt"
value="${_esc(currentName || '')}" placeholder="oder Namen frei eingeben">`;
};
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">Vater</label>
${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')}
</div>
<div class="form-group">
<label class="form-label">Mutter</label>
${buildSelect('mutter_name', 'mutter_id', weiblich, v.mutter_id, v.mutter_name, 'Aus Zuchtkartei')}
</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);
// Auto-Fill: Dropdown → Namenfeld befüllen
['vater', 'mutter'].forEach(role => {
document.getElementById(`${role}_id-sel`)?.addEventListener('change', e => {
const sel = e.target;
const opt = sel.options[sel.selectedIndex];
const txt = document.getElementById(`${role}_name-txt`);
if (txt) txt.value = opt.value ? (opt.dataset.name || '') : '';
});
});
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,
vater_id: fd.get('vater_id') ? parseInt(fd.get('vater_id')) : null,
mutter_id: fd.get('mutter_id') ? parseInt(fd.get('mutter_id')) : 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.modal.close();
UI.toast.success('Wurf aktualisiert.');
_renderList();
} else {
const created = await API.litters.create(payload);
_litters.unshift(created);
UI.modal.close();
_renderList();
_showWelfareModal(created.welfare, created.id);
}
});
});
}
// ----------------------------------------------------------
// 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();
});
});
}
// ----------------------------------------------------------
// Tierschutz-Check Modal
// ----------------------------------------------------------
function _showWelfareModal(welfare, litterId) {
if (!welfare) return;
const color = { ok: '#16a34a', info: '#3b82f6', warning: '#f59e0b', critical: '#dc2626' }[welfare.level] || '#6b7280';
const title = { ok: 'Alles prima', info: 'Hinweis', warning: 'Bitte beachten', critical: 'Kritischer Hinweis' }[welfare.level];
const icon = { ok: 'check-circle', info: 'info', warning: 'warning', critical: 'warning-circle' }[welfare.level];
const issueHTML = (welfare.issues || []).map(i => `
<div style="display:flex;gap:8px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,.06)">
<span style="color:${color};flex-shrink:0">${UI.icon('warning')}</span>
<span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
</div>`).join('');
const okHTML = (welfare.ok_points || []).map(p => `
<div style="display:flex;gap:8px;padding:4px 0">
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span>
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
</div>`).join('');
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
UI.modal.open({
title: `<span style="color:${color}">${UI.icon(icon)} Tierschutz-Check: ${title}</span>`,
body: `
<div style="background:${color}18;border:1.5px solid ${color}40;border-radius:var(--radius-md);
padding:var(--space-4);margin-bottom:var(--space-4)">
${issueHTML || ''}
${okHTML}
</div>
${welfare.level === 'critical' ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-sm);
padding:var(--space-3)">
${UI.icon('info')} Wenn du fortfährst, wird der Administrator informiert.
</div>` : ''}
`,
footer: isProblematic ? `
<div style="display:flex;gap:var(--space-2);width:100%">
<button class="btn btn-secondary flex-1" id="welfare-back-btn">
${UI.icon('arrow-left')} Zurück
</button>
<button class="btn btn-ghost flex-1" id="welfare-confirm-btn"
style="color:${color}">
Trotzdem fortfahren
</button>
</div>` : `
<button class="btn btn-primary" data-modal-close style="width:100%">
${UI.icon('check')} Verstanden
</button>`,
});
document.getElementById('welfare-back-btn')?.addEventListener('click', () => {
UI.modal.close?.();
const litter = _litters.find(l => l.id === litterId);
API.litters.remove(litterId).catch(() => {});
_litters = _litters.filter(l => l.id !== litterId);
_renderList();
setTimeout(() => _showLitterForm(null), 150);
});
document.getElementById('welfare-confirm-btn')?.addEventListener('click', async () => {
await API.litters.welfareConfirm(litterId).catch(() => {});
UI.modal.close?.();
UI.toast.info('Wurf gespeichert.');
});
}
// ----------------------------------------------------------
// KI: Wurfankündigung
// ----------------------------------------------------------
async function _showKiAnnouncement(litterId) {
UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">
KI schreibt Wurfankündigung…
</p>`,
footer: '',
});
let text = '';
try {
const result = await API.zuchtKi.wurfankuendigung(litterId);
text = result.text || result.content || result.ankuendigung || JSON.stringify(result);
} catch (err) {
UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
return;
}
UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
footer: `
<button class="btn btn-secondary flex-1" id="ki-announce-copy">
${UI.icon('clipboard-text')} Kopieren
</button>
<button class="btn btn-primary flex-1" id="ki-announce-use">
${UI.icon('check')} In Beschreibung übernehmen
</button>`,
});
document.getElementById('ki-announce-copy')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(text);
UI.toast.success('Text kopiert.');
} catch {
UI.toast.error('Kopieren nicht möglich.');
}
});
document.getElementById('ki-announce-use')?.addEventListener('click', async () => {
const btn = document.getElementById('ki-announce-use');
await UI.asyncButton(btn, async () => {
await API.litters.update(litterId, { beschreibung: text });
UI.modal.close();
UI.toast.success('Beschreibung aktualisiert.');
await _loadLitters();
});
});
}
return { init, refresh, onDogChange };
})();