Feature: Vollständige Züchter-Rolle — Antrag, Würfe, Stammbaum, Genetik
Basis-Features (Schritte 1–11): - Züchter-Antrag mit Dokument-Upload, Admin-Prüfung, E-Mail-Benachrichtigungen - Öffentliches Züchter-Profil + Karten-Marker (lila, certificate-Icon) - Wurfverwaltung: Würfe, Welpen, Gewichtsverlauf, Foto-System - Wurfbörse (öffentlich) mit Filtersuche nach Rasse/Status - Läufigkeits-Tracker: Deckdatum + Wurftermin (+63 Tage, nur für Züchter) - Interessenten-Chat: Kontakt-Button in Wurfbörse und Züchter-Profil - Sidebar-Einträge: Zuchtkartei + Wurfverwaltung für Züchter/Admin Stammbaum & Genetik (Schritte 1–8): - Zuchtkartei: Hunde-Stammdaten mit Vater/Mutter-Verknüpfung - Stammbaum-Visualisierung: 4 Generationen, horizontales CSS-Grid - Gesundheitstests (HD, ED, OCD, Augen…) mit farbigen Ergebnis-Badges - Genetische Tests (MDR1, PRA, DM…): clear/carrier/affected - Titel & Auszeichnungen (CAC, CACIB, IPO…) - Probeverpaarung: IK-Berechnung nach Wright + Ampel-Bewertung - Teilen-Link für öffentliche Hunde-Profile - Kaufvertrag: druckbares HTML-Dokument pro Welpe Technisch: 4 neue Route-Dateien, 5 neue Page-Module, 11 neue DB-Tabellen, icons shield-check + certificate + tree-structure im Sprite — SW by-v465, APP_VER 444
This commit is contained in:
parent
58cb2b4ad3
commit
91340be5a3
24 changed files with 6660 additions and 27 deletions
|
|
@ -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 = `
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue