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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff