banyaro/backend/static/js/pages/litters.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

1583 lines
71 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 = [];
let _openId = null;
let _filterStatus = null;
let _breederInfo = null; // { zwingername, logo_url }
// ----------------------------------------------------------
// 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', cls: 'badge-warning' },
geboren: { label: 'Geboren', cls: 'badge-primary' },
verfuegbar: { label: 'Verfügbar', cls: 'badge-success' },
abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' },
};
const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge ${s.cls}">${_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', cls: 'badge-success' },
reserviert: { label: 'Reserviert', cls: 'badge-warning' },
abgegeben: { label: 'Abgegeben', cls: 'badge-muted' },
};
const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge badge-sm ${s.cls}">${_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();
API.breeder.status().then(s => {
_breederInfo = s?.profile ? { zwingername: s.profile.zwingername, logo_url: s.profile.logo_url } : null;
// Header nach Laden der Info neu rendern
const headerEl = _container.querySelector('#breeder-private-header');
if (headerEl) headerEl.outerHTML = _privateHeader();
}).catch(() => {});
await _loadLitters();
}
function refresh() {
const u = _appState?.user;
if (!u || (u.rolle !== 'breeder' && u.rolle !== 'admin')) return;
_loadLitters();
}
function onDogChange() {}
// ----------------------------------------------------------
// Grundstruktur rendern
// ----------------------------------------------------------
function _privateHeader() {
const zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
const logoUrl = _breederInfo?.logo_url || null;
const logoHtml = logoUrl
? `<img src="${_esc(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">`
: `<div style="width:48px;height:48px;border-radius:50%;background:rgba(196,132,58,.15);
border:2px solid rgba(196,132,58,.4);display:flex;align-items:center;
justify-content:center;flex-shrink:0">
<svg style="width:24px;height:24px;color:var(--c-primary)" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#paw-print"></use>
</svg>
</div>`;
return `
<div id="breeder-private-header" style="background:var(--c-bg-secondary);
border-bottom:1px solid var(--c-border);
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div class="flex-1-min">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span class="text-xs-secondary">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;
}
function _render() {
_container.innerHTML = `
<div class="litters-layout">
${_privateHeader()}
<div class="by-toolbar" style="flex-wrap:wrap">
<h2 style="margin:0;font-size:var(--text-lg);font-weight:var(--weight-semibold)">
${UI.icon('certificate')} 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-stats" style="display:none;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap"></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 _renderStats() {
const bar = document.getElementById('litters-stats');
if (!bar || !_litters.length) return;
const total = _litters.length;
const aktiv = _litters.filter(l => l.status === 'verfuegbar' || l.status === 'geboren').length;
const geplant = _litters.filter(l => l.status === 'geplant').length;
const welpen = _litters.reduce((s, l) => s + (l.welpen_gesamt || 0), 0);
const verfuegb = _litters.reduce((s, l) => s + (l.welpen_verfuegbar || 0), 0);
const statItems = [
{ icon: 'list-bullets', label: 'Alle Würfe', val: total, filter: null },
{ icon: 'baby', label: 'Aktiv', val: aktiv, filter: ['verfuegbar','geboren'], color: 'var(--c-success)' },
{ icon: 'calendar-dots',label: 'Geplant', val: geplant, filter: ['geplant'] },
{ icon: 'dog', label: 'Welpen ges.', val: welpen, filter: null },
{ icon: 'tag', label: 'Verfügbar', val: verfuegb,filter: ['verfuegbar'], color: verfuegb > 0 ? 'var(--c-primary)' : undefined },
];
bar.style.display = 'flex';
bar.innerHTML = statItems.map((s, i) => {
const isActive = JSON.stringify(_filterStatus) === JSON.stringify(s.filter);
const clickable = true;
return `
<div data-stat-idx="${i}"
style="background:${isActive ? 'var(--c-primary)' : 'var(--c-bg-secondary)'};
border:1px solid ${isActive ? 'var(--c-primary)' : 'var(--c-border)'};
border-radius:var(--radius-md);padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-2);flex:1;min-width:90px;
${clickable ? 'cursor:pointer;user-select:none;transition:opacity .15s' : ''}">
<span style="color:${isActive ? 'white' : (s.color || 'var(--c-text-muted)')};opacity:.9">${UI.icon(s.icon)}</span>
<div>
<div style="font-size:var(--text-lg);font-weight:700;
color:${isActive ? 'white' : (s.color || 'var(--c-text)')};line-height:1">${s.val}</div>
<div style="font-size:var(--text-xs);color:${isActive ? 'rgba(255,255,255,.75)' : 'var(--c-text-muted)'}">${s.label}</div>
</div>
</div>`;
}).join('');
bar.querySelectorAll('[data-stat-idx]').forEach(chip => {
const s = statItems[parseInt(chip.dataset.statIdx)];
chip.addEventListener('click', () => {
_filterStatus = JSON.stringify(_filterStatus) === JSON.stringify(s.filter) ? null : s.filter;
_renderStats();
_renderFilteredList();
});
});
}
function _renderFilteredList() {
const el = document.getElementById('litters-list');
if (!el) return;
const visible = _filterStatus
? _litters.filter(l => _filterStatus.includes(l.status))
: _litters;
if (!visible.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4);
border:1px dashed var(--c-border);border-radius:var(--radius-lg)">
<p class="text-muted">Keine Würfe für diesen Filter.</p>
</div>`;
return;
}
el.innerHTML = visible.map(l => _litterCardHTML(l)).join('');
_bindCardEvents(el, visible);
}
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 class="text-secondary">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary mt-4" id="litters-first-btn">
${UI.icon('plus')} Ersten Wurf anlegen
</button>
</div>`;
document.getElementById('litters-first-btn')?.addEventListener('click', () => _showLitterForm(null));
return;
}
_renderStats();
_filterStatus = null;
el.innerHTML = _litters.map(l => _litterCardHTML(l)).join('');
_bindCardEvents(el, _litters);
if (_openId) _togglePuppies(_openId, true);
}
function _bindCardEvents(el, litters) {
el.querySelectorAll('.litters-card-toggle').forEach(btn => {
btn.addEventListener('click', () => _togglePuppies(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const l = litters.find(x => x.id === id);
if (l) _showPhotosModal('litter', l.id, l.zwingername || `Wurf #${l.id}`);
});
});
el.querySelectorAll('.litters-parent-photos-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id);
const l = litters.find(x => x.id === id);
if (!l) return;
_showPhotosModal('parent', l.id, [l.vater_name, l.mutter_name].filter(Boolean).join(' × ') || `Eltern #${id}`);
});
});
el.querySelectorAll('.litters-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const l = litters.find(x => x.id === parseInt(btn.dataset.id));
if (l) _showLitterForm(l);
});
});
el.querySelectorAll('.litters-delete-btn').forEach(btn => {
btn.addEventListener('click', () => _deleteLitter(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-ki-announce-btn').forEach(btn => {
btn.addEventListener('click', () => _showKiAnnouncement(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-add-puppy-btn').forEach(btn => {
btn.addEventListener('click', () => _showPuppyForm(parseInt(btn.dataset.id), null));
});
el.querySelectorAll('.litters-waitlist-btn').forEach(btn => {
btn.addEventListener('click', () => _toggleWaitlist(parseInt(btn.dataset.id)));
});
el.querySelectorAll('.litters-add-waitlist-btn').forEach(btn => {
btn.addEventListener('click', () => _showWaitlistForm(parseInt(btn.dataset.id), null));
});
}
function _daysUntil(dateStr) {
if (!dateStr) return null;
const diff = Math.ceil((new Date(dateStr) - new Date()) / 86400000);
return diff;
}
function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
const elternLabel = [l.vater_name, l.mutter_name].filter(Boolean).map(n => _esc(n)).join(' × ') || '—';
// Datum + Countdown
let datumChip = '';
const refDate = l.geburt_datum || l.erwartetes_datum;
if (refDate) {
const days = _daysUntil(refDate);
const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`;
let countdownHtml = '';
if (days !== null && !l.geburt_datum) {
const c = days < 0 ? `<span class="text-danger">überfällig</span>`
: days === 0 ? `<span class="text-success">heute!</span>`
: days <= 7 ? `<span style="color:var(--c-warning,#f59e0b)">${days}d</span>`
: `<span class="text-muted">${days}d</span>`;
countdownHtml = ` · ${c}`;
}
datumChip = `<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('calendar-dots')} ${label}${countdownHtml}</span>`;
}
const sichtbarChip = l.sichtbar
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-success)">${UI.icon('eye')} Öffentlich</span>`
: `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-muted)">${UI.icon('eye-slash')} Nicht öffentlich</span>`;
const welpenChip = `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar</span>`;
const preisChip = l.preis_spanne
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</span>`
: '';
return `
<div class="litters-card" id="litter-card-${l.id}"
style="background:var(--c-bg-secondary);border:1px solid var(--c-border);border-radius:var(--radius-lg);
margin-bottom:var(--space-3);overflow:hidden">
<!-- Card-Header -->
<div style="padding:var(--space-4) var(--space-4) var(--space-3);border-bottom:1px solid var(--c-border)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap">
<div style="min-width:0">
${(l.wurf_rang || l.wurf_name) ? `
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
${l.wurf_rang ? `<span style="background:var(--c-primary);color:white;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${_esc(l.wurf_rang)}-Wurf</span>` : ''}
${l.wurf_name ? `<span style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${_esc(l.wurf_name)}</span>` : ''}
</div>` : ''}
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)">
<span class="text-sm-secondary">${elternLabel}</span>
${_statusBadge(l.status)}
${sichtbarChip}
</div>
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
${datumChip}
${welpenChip}
${preisChip}
</div>
</div>
<div style="display:flex;align-items:center;gap:var(--space-1);flex-shrink:0;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm litters-card-toggle" data-id="${l.id}" title="Welpen">
${UI.icon('caret-down')} Welpen
</button>
<button class="btn btn-ghost btn-sm litters-waitlist-btn" data-id="${l.id}" title="Warteliste">
${UI.icon('list-bullets')} Warteliste
</button>
<button class="btn btn-ghost btn-sm litters-photos-btn" data-id="${l.id}" title="Wurf-Fotos">
${UI.icon('images')}
</button>
<button class="btn btn-ghost btn-sm litters-parent-photos-btn" data-id="${l.id}" title="Elterntier-Fotos">
${UI.icon('users')}
</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">
${UI.icon('sparkle')}
</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"
class="text-danger">
${UI.icon('trash')}
</button>
</div>
</div>
${l.beschreibung ? `<p style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${_esc(l.beschreibung)}</p>` : ''}
</div>
<!-- Welpen-Bereich -->
<div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="puppies-inner-${l.id}">
<p class="text-sm-muted">Lädt…</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}"
class="mt-3">
${UI.icon('plus')} Welpen hinzufügen
</button>
</div>
<!-- Wartelisten-Bereich -->
<div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="waitlist-inner-${l.id}">
<p class="text-sm-muted">Lädt…</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}"
class="mt-3">
${UI.icon('plus')} Interessent eintragen
</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 class="text-sm-muted">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 class="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}" class="text-xs-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" class="mb-3">
<p class="text-sm-muted">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 class="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 class="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 class="text-sm-muted">Noch keine Messungen eingetragen.</p>`;
return;
}
// Chronologisch für Chart (API liefert DESC)
const asc = [...weights].reverse();
const first = asc[0].gewicht_g;
const last = asc[asc.length - 1].gewicht_g;
const gain = last - first;
const days = asc.length > 1
? Math.max(1, (new Date(asc[asc.length-1].gemessen_am) - new Date(asc[0].gemessen_am)) / 86400000)
: 1;
const dailyGain = asc.length > 1 ? (gain / days).toFixed(1) : '—';
// SVG Sparkline
const W = 400, H = 80;
const minW = Math.min(...asc.map(w => w.gewicht_g));
const maxW = Math.max(...asc.map(w => w.gewicht_g));
const range = maxW - minW || 1;
const pts = asc.map((w, i) => {
const x = asc.length === 1 ? W/2 : (i / (asc.length - 1)) * W;
const y = H - 8 - ((w.gewicht_g - minW) / range) * (H - 16);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const firstDate = asc[0].gemessen_am?.slice(5) || '';
const lastDate = asc[asc.length-1].gemessen_am?.slice(5) || '';
el.innerHTML = `
<!-- Stats-Zeile -->
<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
<div class="text-center">
<div class="text-xs-muted">Aktuell</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-primary)">${last} g</div>
</div>
<div class="text-center">
<div class="text-xs-muted">Zunahme</div>
<div style="font-size:var(--text-base);font-weight:700;color:${gain >= 0 ? 'var(--c-success)' : 'var(--c-danger)'}">
${gain >= 0 ? '+' : ''}${gain} g
</div>
</div>
<div class="text-center">
<div class="text-xs-muted">Ø tägl.</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${dailyGain} g</div>
</div>
<div class="text-center">
<div class="text-xs-muted">Messungen</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${weights.length}</div>
</div>
</div>
<!-- Wachstumskurve -->
${asc.length > 1 ? `
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-2) var(--space-1);margin-bottom:var(--space-3)">
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:80px;display:block" preserveAspectRatio="none">
<polyline points="${pts}" fill="none" stroke="var(--c-primary)"
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<!-- Punkte -->
${asc.map((w, i) => {
const x = (i / (asc.length - 1)) * W;
const y = H - 8 - ((w.gewicht_g - minW) / range) * (H - 16);
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3"
fill="var(--c-primary)" stroke="var(--c-bg)" stroke-width="1.5"/>`;
}).join('')}
</svg>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-text-muted);margin-top:2px">
<span>${firstDate}</span><span>${lastDate}</span>
</div>
</div>` : ''}
<!-- Tabelle -->
<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>
<th style="text-align:right;padding:var(--space-1) var(--space-2)">Veränderung</th>
</tr>
</thead>
<tbody>
${weights.map((w, i) => {
const prev = weights[i + 1];
const diff = prev ? w.gewicht_g - prev.gewicht_g : null;
const diffStr = diff === null ? '—'
: `<span style="color:${diff >= 0 ? 'var(--c-success)' : 'var(--c-danger)'}">${diff >= 0 ? '+' : ''}${diff} g</span>`;
return `
<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>
<td style="padding:var(--space-1) var(--space-2);text-align:right">${diffStr}</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>`;
}
}
// ----------------------------------------------------------
// Warteliste
// ----------------------------------------------------------
const _WL_STATUS = {
anfrage: { label: 'Anfrage', color: '#6b7280' },
vorgemerkt: { label: 'Vorgemerkt', color: '#f59e0b' },
bestaetigt: { label: 'Bestätigt', color: '#3b82f6' },
abgegeben: { label: 'Abgegeben', color: '#16a34a' },
abgesagt: { label: 'Abgesagt', color: '#dc2626' },
};
function _wlStatusBadge(status) {
const s = _WL_STATUS[status] || _WL_STATUS.anfrage;
return `<span style="background:${s.color}1a;color:${s.color};border:1px solid ${s.color}40;border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${s.label}</span>`;
}
async function _toggleWaitlist(litterId) {
const wrap = document.getElementById(`waitlist-wrap-${litterId}`);
if (!wrap) return;
const isOpen = wrap.style.display !== 'none';
if (isOpen) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
await _loadWaitlist(litterId);
}
async function _loadWaitlist(litterId) {
const inner = document.getElementById(`waitlist-inner-${litterId}`);
if (!inner) return;
try {
const entries = await API.litters.waitlist(litterId);
_renderWaitlist(inner, litterId, entries);
// Badge am Button aktualisieren
const btn = document.querySelector(`.litters-waitlist-btn[data-id="${litterId}"]`);
if (btn) {
const active = entries.filter(e => e.status !== 'abgesagt').length;
btn.innerHTML = `${UI.icon('list-bullets')} Warteliste${active ? ` <span style="background:var(--c-primary);color:white;border-radius:999px;padding:0 6px;font-size:10px;font-weight:700">${active}</span>` : ''}`;
}
} catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler.')}</p>`;
}
}
function _renderWaitlist(container, litterId, entries) {
const active = entries.filter(e => e.status !== 'abgesagt');
const statusCounts = {};
entries.forEach(e => { statusCounts[e.status] = (statusCounts[e.status] || 0) + 1; });
const summaryPills = Object.entries(statusCounts).map(([s, n]) => {
const cfg = _WL_STATUS[s] || _WL_STATUS.anfrage;
return `<span style="background:${cfg.color}1a;color:${cfg.color};border:1px solid ${cfg.color}30;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600">${cfg.label}: ${n}</span>`;
}).join('');
const header = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-sm);font-weight:700;color:var(--c-text)">${entries.length} Interessent${entries.length !== 1 ? 'en' : ''}</span>
${summaryPills}
</div>
</div>`;
if (!entries.length) {
container.innerHTML = `
<div style="text-align:center;padding:var(--space-6) var(--space-4);border:1px dashed var(--c-border);border-radius:var(--radius-md)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('users')}</div>
<p style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);margin-bottom:var(--space-1)">Noch keine Interessenten</p>
<p class="text-xs-muted">Trage Anfragen ein — mit Wunsch-Geschlecht, Kontaktdaten und Status.</p>
</div>`;
return;
}
container.innerHTML = header + `
<div class="flex-col-gap-2">
${entries.map((e, i) => `
<div style="background:var(--c-bg-secondary);border-radius:var(--radius-md);padding:var(--space-3) var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start" data-entry-id="${e.id}">
<div style="background:var(--c-primary);color:white;border-radius:50%;width:1.6rem;height:1.6rem;display:flex;align-items:center;justify-content:center;font-size:var(--text-xs);font-weight:700;flex-shrink:0;margin-top:2px">${i + 1}</div>
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:600;font-size:var(--text-sm)">${_esc(e.name)}</span>
${_wlStatusBadge(e.status)}
${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span class="text-xs-secondary">${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}</span>` : ''}
${e.wunsch_farbe ? `<span class="text-xs-secondary">${_esc(e.wunsch_farbe)}</span>` : ''}
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${e.email ? `<span>${UI.icon('envelope')} ${_esc(e.email)}</span>` : ''}
${e.telefon ? `<span>${UI.icon('phone')} ${_esc(e.telefon)}</span>` : ''}
<span>${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'}</span>
</div>
${e.nachricht ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-secondary);font-style:italic">"${_esc(e.nachricht)}"</div>` : ''}
${e.notiz ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);background:var(--c-warning-bg,#fffbeb);color:#92400e;border-radius:4px;padding:2px 6px">${UI.icon('note-pencil')} ${_esc(e.notiz)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs wl-edit-btn" data-entry-id="${e.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs wl-delete-btn" data-entry-id="${e.id}" title="Entfernen" class="text-danger">${UI.icon('trash')}</button>
</div>
</div>`).join('')}
</div>`;
container.querySelectorAll('.wl-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const entry = entries.find(e => e.id === parseInt(btn.dataset.entryId));
if (entry) _showWaitlistForm(litterId, entry);
});
});
container.querySelectorAll('.wl-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Interessenten aus der Warteliste entfernen?')) return;
try {
await API.litters.removeWaitlist(parseInt(btn.dataset.entryId));
await _loadWaitlist(litterId);
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}
function _showWaitlistForm(litterId, entry) {
const isEdit = !!entry;
const v = entry || {};
UI.modal.open({
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
body: `
<form id="wl-form" class="flex-col-gap-3">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required value="${_esc(v.name || '')}">
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email" value="${_esc(v.email || '')}">
</div>
<div class="form-group">
<label class="form-label">Telefon</label>
<input class="form-control" name="telefon" value="${_esc(v.telefon || '')}">
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Wunsch Geschlecht</label>
<select class="form-control" name="wunsch_geschlecht">
<option value="egal" ${(!v.wunsch_geschlecht || v.wunsch_geschlecht === 'egal') ? 'selected' : ''}>Egal</option>
<option value="maennlich" ${v.wunsch_geschlecht === 'maennlich' ? 'selected' : ''}>Rüde ♂</option>
<option value="weiblich" ${v.wunsch_geschlecht === 'weiblich' ? 'selected' : ''}>Hündin ♀</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Wunsch Farbe</label>
<input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${_esc(v.wunsch_farbe || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Nachricht des Interessenten</label>
<textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${_esc(v.nachricht || '')}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-control" name="status">
${Object.entries(_WL_STATUS).map(([k, s]) => `<option value="${k}" ${(v.status || 'anfrage') === k ? 'selected' : ''}>${s.label}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label class="form-label">Position</label>
<input class="form-control" type="number" name="prioritaet" min="0" value="${v.prioritaet ?? 0}">
</div>
</div>
<div class="form-group">
<label class="form-label">Interne Notiz</label>
<input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${_esc(v.notiz || '')}">
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="wl-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('wl-form').addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const data = {
name: fd.get('name')?.trim(),
email: fd.get('email')?.trim() || null,
telefon: fd.get('telefon')?.trim() || null,
nachricht: fd.get('nachricht')?.trim() || null,
wunsch_geschlecht: fd.get('wunsch_geschlecht'),
wunsch_farbe: fd.get('wunsch_farbe')?.trim() || null,
prioritaet: parseInt(fd.get('prioritaet')) || 0,
status: fd.get('status'),
notiz: fd.get('notiz')?.trim() || null,
};
try {
if (isEdit) {
await API.litters.updateWaitlist(entry.id, data);
} else {
await API.litters.addWaitlist(litterId, data);
}
UI.modal.close();
await _loadWaitlist(litterId);
UI.toast.success(isEdit ? 'Gespeichert.' : 'Interessent eingetragen.');
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
}
// ----------------------------------------------------------
// 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" class="mb-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 rangOpts = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(l =>
`<option value="${l}" ${v.wurf_rang === l ? 'selected' : ''}>${l}-Wurf</option>`
).join('');
const body = `
<form id="litter-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 2fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Wurf-Buchstabe</label>
<select class="form-control" name="wurf_rang">
<option value="">— kein —</option>
${rangOpts}
</select>
</div>
<div class="form-group">
<label class="form-label">Wurf-Name <span style="font-weight:normal;color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="wurf_name"
placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …"
value="${_esc(v.wurf_name || '')}">
</div>
</div>
<div class="grid-2">
<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);align-items:end">
<div class="form-group">
<label class="form-label">Erwarteter Geburtstermin <span style="font-weight:normal;color:var(--c-text-muted)">(geplant)</span></label>
<input class="form-control" type="date" name="erwartetes_datum"
value="${_esc(v.erwartetes_datum || '')}">
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Für geplante Würfe / laufende Trächtigkeit</p>
</div>
<div class="form-group">
<label class="form-label">Geburtsdatum <span style="font-weight:normal;color:var(--c-text-muted)">(tatsächlich)</span></label>
<input class="form-control" type="date" name="geburt_datum"
value="${_esc(v.geburt_datum || '')}">
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Wenn die Welpen bereits geboren sind</p>
</div>
</div>
<div class="grid-2">
<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 class="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 class="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 class="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 = {
wurf_rang: fd.get('wurf_rang') || null,
wurf_name: fd.get('wurf_name')?.trim() || null,
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 class="grid-2">
<div class="form-group">
<label class="form-label">Name <span class="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 class="grid-2">
<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 class="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 class="text-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 class="text-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 class="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 class="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}" class="mb-4">
<p class="text-sm-muted">Lädt…</p>
</div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="${uploadFormId}" class="flex-col-gap-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 class="text-sm-muted">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 class="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 class="text-sm-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 class="w-full">
${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 class="text-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 };
})();