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

@ -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 = `