/* ============================================================ BAN YARO — Admin-Bereich Nur für Admins und Moderatoren. ============================================================ */ window.Page_admin = (() => { let _container = null; let _appState = null; let _tab = 'uebersicht'; let _delegationAttached = null; // WeakRef-Ersatz: zuletzt mit Delegation versehener Container const TABS = [ { 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', icon: 'chat-circle-dots' }, { id: 'social', label: 'Social Media', icon: 'camera' }, { id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Scheduler', icon: 'clock' }, { id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'referrals', label: 'Referrals', icon: 'share-network' }, { id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' }, { id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' }, ]; // ------------------------------------------------------------------ async function init(container, appState) { _container = container; _appState = appState; const u = appState.user; const isMod = u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator; if (!isMod) { container.innerHTML = _emptyState('shield', 'Kein Zugriff', 'Dieser Bereich ist nur für Admins und Moderatoren.'); return; } _render(); } function refresh() { _renderTab(); } function onDogChange() {} // ------------------------------------------------------------------ // SHELL // ------------------------------------------------------------------ function _render() { _container.innerHTML = `
${TABS.map(t => ` `).join('')}
`; _container.querySelector('#adm-tabs') ?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2)); // Event-Delegation: Listener EINMAL pro Container-Instanz setzen // (innerHTML überschreibt zwar das DOM, aber bei jedem init() würden ohne // Flag erneut N=18 Tab-Listener akkumuliert werden) if (_delegationAttached !== _container) { _container.addEventListener('click', _onContainerClick); _delegationAttached = _container; } _renderActionItems(); _renderTab(); } // Delegation-Handler für Tab-Buttons + Action-Item-Buttons function _onContainerClick(e) { // Tab-Button (#adm-tabs .by-tab) const tabBtn = e.target.closest('#adm-tabs .by-tab'); if (tabBtn && _container.contains(tabBtn)) { _tab = tabBtn.dataset.tab; _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab) ); _renderTab(); return; } // Action-Item-Button ([data-action-tab]) const actionBtn = e.target.closest('[data-action-tab]'); if (actionBtn && _container.contains(actionBtn)) { _tab = actionBtn.dataset.actionTab; _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab) ); _renderTab(); return; } } async function _renderActionItems() { const el = _container.querySelector('#adm-action-items'); if (!el) return; let d; try { d = await API.get('/admin/action-items'); } catch { return; } const items = [ { key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' }, { key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' }, { key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' }, { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, { key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' }, { key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' }, { key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' }, { key: 'partner_profiles_pending', label: 'Partner-Profile', tab: 'partner', icon: 'handshake' }, ]; const open = items.filter(i => d[i.key] > 0); const usersToday = d.users_today || 0; el.innerHTML = `
${UI.icon('check-square')} Zu erledigen ${open.length === 0 ? ` ${UI.icon('check-circle')} Alles erledigt ` : open.map(i => ` `).join('') } ${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
`; // Klicks auf [data-action-tab] werden zentral via _onContainerClick (Delegation) behandelt } async function _renderTab() { const el = _container.querySelector('#adm-content'); if (!el) return; el.innerHTML = `
Lade…
`; try { switch (_tab) { 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; case 'system': await _renderSystem(el); break; case 'jobs': await _renderJobs(el); break; case 'partner': await _renderPartner(el); break; case 'outreach': await _renderOutreach(el); break; case 'audit': await _renderAudit(el); break; case 'bewerbungen': await _renderBewerbungen(el); break; case 'hilfe': await _renderHilfe(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'referrals': await _renderReferrals(el); break; case 'upgrades': await _renderUpgrades(el); break; case 'rechnungen': await _renderRechnungen(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); } } // ------------------------------------------------------------------ // TAB: SOCIAL MEDIA async function _renderSocial(el) { const d = await API.get('/admin/social'); const _PL = { instagram: '📸 Instagram', tiktok: '🎵 TikTok', both: '📱 Beide' }; const _fmt = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '–'; // Manager-Tabelle const managerRows = d.managers.map(m => ` ${UI.escape(m.name)} ${m.published} ${m.with_link} ${m.published > 0 ? ` (${Math.round(m.with_link/m.published*100)}%)` : ''} ${m.scheduled} ${m.ideas} ${m.total} `).join(''); // Plattform-Balken const maxPlat = Math.max(...d.by_platform.map(p => p.n), 1); const platBars = d.by_platform.map(p => `
${_PL[p.platform]||p.platform}
${p.n}
`).join(''); // Monats-Timeline const maxMonth = Math.max(...d.by_month.map(m => m.n), 1); const monthBars = [...d.by_month].reverse().map(m => `
${m.monat}
${m.n}
`).join(''); // Veröffentlichte Posts (mit/ohne Link) const postRows = d.recent_published.map(p => ` ${_fmt(p.published_at)} ${UI.escape(p.topic)} ${_PL[p.platform]||p.platform||'–'} ${UI.escape(p.category||'–')} ${p.ai_score ? '⭐'.repeat(p.ai_score) : '–'} ${UI.escape(p.manager||'–')} ${p.post_url ? `🔗 Link` : ``} `).join(''); el.innerHTML = `
Veröffentlicht nach Plattform
${platBars || '
Noch keine Posts
'}
Posts pro Monat
${monthBars || '
Noch keine Posts
'}
${d.managers.length ? `
Manager-Übersicht
${managerRows}
Manager Veröffentlicht Mit Link Geplant Ideen Gesamt
` : ''}
Veröffentlichte Posts (letzte 50)
${postRows || ``}
DatumThemaPlattform KategorieScoreManagerLink
Noch keine Posts
`; } // ------------------------------------------------------------------ // TAB: ANALYTICS async function _renderAnalytics(el) { el.innerHTML = `
Lade Analytics…
`; let d; try { d = await API.get('/admin/analytics'); } catch (err) { el.innerHTML = `
${UI.icon('warning')} Fehler: ${UI.escape(err.message || String(err))}
`; return; } const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0); const fmt = v => Number(v).toLocaleString('de'); const pv = d.pageviews?.pageviews ?? []; const ses = d.pageviews?.sessions ?? []; // Bounce + Verweildauer const _bounces = tv(d.today?.bounces), _vis = tv(d.today?.visits); const bounceToday = _vis > 0 ? ((_bounces / _vis) * 100).toFixed(0) + ' %' : '—'; const _tt = tv(d.week?.totaltime), _vw = tv(d.week?.visits); const timeWeek = _tt > 0 && _vw > 0 ? Math.round(_tt / _vw) + ' s' : '—'; // Dual-Series Area+Line Chart (SVG) function _dualChart(pvData, sesData) { if (!pvData.length) return `

Keine Daten

`; const W = 800, H = 120, padX = 0, padY = 8; const pvVals = pvData.map(p => p.y ?? 0); const sesVals = sesData.map(p => p.y ?? 0); const maxVal = Math.max(...pvVals, ...sesVals, 1); const n = pvData.length; function toXY(vals) { return vals.map((v, i) => { const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1)); const y = H - padY - (v / maxVal) * (H - 2*padY); return [x.toFixed(1), y.toFixed(1)]; }); } const pvPts = toXY(pvVals); const sesPts = toXY(sesVals); const pvLine = pvPts.map(p => p.join(',')).join(' '); const sesLine = sesPts.map(p => p.join(',')).join(' '); // Filled area unter pageviews const areaPath = `M ${pvPts[0][0]},${H} ` + pvPts.map(p => `L ${p[0]},${p[1]}`).join(' ') + ` L ${pvPts[pvPts.length-1][0]},${H} Z`; // X-Achsen-Labels: Anfang, Mitte, Ende const labelIdx = [0, Math.floor(n/2), n-1].filter((v,i,a) => a.indexOf(v)===i); const labels = labelIdx.map(i => { const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1)); const date = pvData[i]?.x ? new Date(pvData[i].x).toLocaleDateString('de',{day:'2-digit',month:'2-digit'}) : ''; return `${date}`; }).join(''); return ` ${labels} `; } // Balken-Chart für Top-Pages / Referrers function _barChart(items, labelKey = 'x', valKey = 'y') { if (!items?.length) return `

Keine Daten

`; const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1); return `
${items.map(p => { const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(0); return `
${UI.escape(p[labelKey] || '—')} ${fmt(p[valKey] ?? 0)}
`; }).join('')}
`; } el.innerHTML = `
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')} ${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')} ${_statCard('users', 'Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')} ${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')} ${_statCard('users', 'Besucher 30 Tage', fmt(tv(d.month?.visitors)), 'var(--c-text-secondary)')} ${_statCard('eye', 'Aufrufe 30 Tage', fmt(tv(d.month?.pageviews)), 'var(--c-text-secondary)')} ${_statCard('arrow-u-up-left', 'Bounce heute', bounceToday, 'var(--c-text-secondary)')} ${_statCard('timer', 'Ø Verweildauer 7d', timeWeek, 'var(--c-text-secondary)')}
Verlauf — letzte 30 Tage
Aufrufe Besucher
${_dualChart(pv, ses)}
Jahresübersicht — letzte 12 Monate
Seitenaufrufe Neuanmeldungen
${(() => { const pvYear = d.pageviews_year?.pageviews ?? []; const regs = d.monthly_registrations ?? []; // Alle Monate der letzten 12 sammeln const months = []; const now = new Date(); for (let i = 11; i >= 0; i--) { const d2 = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push(d2.getFullYear() + '-' + String(d2.getMonth()+1).padStart(2,'0')); } // Umami liefert x als ISO-Datum-String const pvByMonth = {}; pvYear.forEach(p => { const key = (p.x || '').slice(0, 7); pvByMonth[key] = (pvByMonth[key] || 0) + (p.y ?? 0); }); const regByMonth = {}; regs.forEach(r => { regByMonth[r.month] = r.count; }); const pvVals = months.map(m => pvByMonth[m] || 0); const regVals = months.map(m => regByMonth[m] || 0); const maxPv = Math.max(...pvVals, 1); const maxReg = Math.max(...regVals, 1); const W = 800, H = 120, n = months.length; const barW = Math.floor((W - (n-1)*4) / n); const bars = months.map((m, i) => { const x = i * (barW + 4); const pvH = Math.round((pvVals[i] / maxPv) * (H - 20)); const regH = Math.round((regVals[i] / maxReg) * (H - 20)); const label = m.slice(5); // MM return ` ${label} `; }).join(''); return `${bars}`; })()}
Top Seiten — 30 Tage
${_barChart(d.top_pages)}
Referrers — 30 Tage
${_barChart(d.referrers)}
`; } // ------------------------------------------------------------------ // TAB: ÜBERSICHT // ------------------------------------------------------------------ async function _renderStats(el) { const [s, ki, kiH] = await Promise.all([ API.get('/admin/stats'), API.get('/admin/ki/status').catch(() => null), API.get('/admin/ki/history').catch(() => null), ]); const _kiStatusBadge = () => { if (!ki) return ''; if (ki.mode === 'off') { return `
KI-Modus: off
`; } 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 `
${label} · ${UI.escape(model)} Modus: ${ki.local_reachable ? 'local' : 'cloud'}
`; }; el.innerHTML = `
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')} ${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')} ${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')} ${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')} ${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')} ${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')} ${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')} ${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')} ${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')} ${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')} ${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')} ${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')} ${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')} ${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')} ${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}

KI-Nutzung

${s.ki_today ?? 0} heute · ${s.ki_week ?? 0} (7 Tage)
${_kiStatusBadge()}
${[ ['☁️ Claude', s.ki_cloud_week, 'var(--c-primary)'], ['🖥️ Lokal', s.ki_local_week, 'var(--c-success)'], ['🌙 Luna', s.ki_luna_week, 'var(--c-warning)'], ['📅 Monat', s.ki_month, 'var(--c-text-secondary)'], ['👥 User heute',s.ki_users_today, 'var(--c-text-secondary)'], ].map(([label, val, color]) => ` ${label} ${val ?? 0} `).join('')}
${(() => { const hist = kiH?.daily_history || []; if (!hist.length) return '

Noch keine Verlaufsdaten

'; const max = Math.max(...hist.map(d => d.total), 1); const W = 400, H = 60, n = hist.length; const pts = hist.map((d, i) => { const x = n === 1 ? W/2 : (i / (n - 1)) * W; const y = H - 5 - (d.total / max) * (H - 10); return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); const first = hist[0]?.date?.slice(5) || ''; const last = hist[hist.length-1]?.date?.slice(5) || ''; return `
${first}30 Tage${last}
`; })()}

Cloud-Limit: ${s.ki_cloud_weekly_limit ?? 20} Anfragen / Woche pro User

${(kiH?.top_users || []).length ? `

Aktivste Nutzer

${(kiH.top_users).map(u => ``).join('')}
NameE-Mail☁️ CloudGesamtZuletzt
${UI.escape(u.name)} ${UI.escape(u.email.length > 22 ? u.email.split('@')[1] : u.email)} ${u.cloud} ${u.total} ${u.last_date?.slice(5) || '—'}
` : ''}
${(() => { const POI_LABELS = { waste_basket: 'Mülleimer', dog_park: 'Hundewiese', drinking_water: 'Wasserstelle', tierarzt: 'Tierarzt', hundesalon: 'Hundesalon', hundeschule: 'Hundeschule', shop: 'Shop', restaurant: 'Café / Restaurant', bank: 'Sitzbank', hotel: 'Hotel', freilauf: 'Freilauf', kotbeutel: 'Kotbeutel', parkplatz: 'Parkplatz', treffpunkt: 'Treffpunkt', sonstiges: 'Sonstiges', giftkoeder: 'Giftköder', gefahr: 'Gefahr', }; const label = t => POI_LABELS[t] || t; const row = ([type, count]) => `
${label(type)} ${count.toLocaleString('de')}
`; const userByType = s.user_poi_by_type || {}; const userTotal = s.user_poi_total ?? 0; return `

OSM-Cache nach Typ — ${(s.osm_total || 0).toLocaleString('de')} gecacht

${Object.entries(s.osm_by_type).map(row).join('')}

Nutzer-POIs nach Typ — ${userTotal.toLocaleString('de')} gesamt

${Object.keys(userByType).length ? Object.entries(userByType).map(row).join('') : '
Noch keine Nutzer-POIs
'}
`; })()}

📱 Social Media Tracking

${[ ['Gesamt generiert', s.social_total, 'var(--c-text-secondary)'], ['Veröffentlicht', s.social_published, 'var(--c-success)'], ['Geplant', s.social_scheduled, 'var(--c-primary)'], ['Ideen offen', s.social_ideas, 'var(--c-warning)'], ['Diese Woche live', s.social_this_week, 'var(--c-success)'], ].map(([label, val, color]) => `
${label}
${val ?? 0}
`).join('')}
${Object.keys(s.social_by_cat||{}).length ? `
Kategorien
${Object.entries(s.social_by_cat).map(([cat, n]) => ` ${cat} ${n}`).join('')}
` : ''} ${s.social_recent?.length ? `
Letzte 10 Posts
${s.social_recent.map(p => `
${p.status} ${p.topic} ${(p.published_at||p.created_at||'').slice(0,10)}
`).join('')}
` : ''}

Ersten Admin per SQL setzen: UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de';

`; el.querySelector('#adm-overview-grid')?.addEventListener('click', e => { const card = e.target.closest('[data-adm-tab]'); if (!card) return; const tab = card.dataset.admTab; _container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click(); }); } function _statCard(icon, label, value, color, tab = null) { const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`; return `
${value ?? '—'}
${label}
`; } // ------------------------------------------------------------------ // TAB: NUTZER // ------------------------------------------------------------------ async function _renderUsers(el) { el.innerHTML = `
Lade…
`; const load = async () => { const q = el.querySelector('#adm-user-q').value; const rolle = el.querySelector('#adm-user-rolle').value; const data = await API.get(`/admin/users?q=${encodeURIComponent(q)}&rolle=${rolle}`); _renderUserList(el.querySelector('#adm-user-list'), data.users, data.total); }; let timer; el.querySelector('#adm-user-q').addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 350); }); el.querySelector('#adm-user-rolle').addEventListener('change', load); await load(); } function _renderUserList(el, users, total) { if (!users.length) { el.innerHTML = _emptyState('users', 'Keine Nutzer gefunden', ''); return; } const isAdmin = _appState.user?.rolle === 'admin'; el.innerHTML = `
${total} Nutzer gefunden
${users.map(u => `
${UI.escape(u.name[0].toUpperCase())}
${UI.escape(u.name)} ${u.is_banned ? ` GESPERRT` : ''}
${UI.escape(u.email)} · ${UI.escape(u.rolle)} · ${UI.escape(u.subscription_tier || 'standard')} · ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''} · ${u.thread_count} Threads
🗺 ${u.route_count} Routen · ${u.total_km} km · 📍 ${u.poi_count} POIs
${u.last_seen ? '🟢 zuletzt aktiv ' + new Date(u.last_seen).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : u.last_login ? '🔵 zuletzt eingeloggt ' + new Date(u.last_login).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '⚪ nie aktiv'}
${u.is_banned ? `` : `` } ${isAdmin ? ` ` : ''}
`).join('')}
`; // Events el.querySelectorAll('.adm-ban').forEach(btn => { btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, true)); }); el.querySelectorAll('.adm-unban').forEach(btn => { btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, false)); }); el.querySelectorAll('.adm-rolle').forEach(btn => { btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle)); }); el.querySelectorAll('.adm-tier').forEach(btn => { btn.addEventListener('click', () => _changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier)); }); el.querySelectorAll('.adm-delete').forEach(btn => { btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name)); }); } async function _banUser(uid, name, ban) { if (ban) { const reason = await _prompt(`${name} sperren — Grund (optional):`); if (reason === null) return; // abgebrochen try { await API.patch(`/admin/users/${uid}`, { is_banned: 1, ban_reason: reason || 'Kein Grund angegeben.' }); UI.toast.success(`${name} gesperrt.`); _renderTab(); } catch (e) { UI.toast.error(e.message); } } else { try { await API.patch(`/admin/users/${uid}`, { is_banned: 0, ban_reason: null }); UI.toast.success(`Sperre für ${name} aufgehoben.`); _renderTab(); } catch (e) { UI.toast.error(e.message); } } } async function _changeRolle(uid, name, currentRolle) { const rollen = ['user', 'moderator', 'admin'].filter(r => r !== currentRolle); UI.modal.open({ title: `Rolle ändern: ${name}`, body: `

Aktuelle Rolle: ${currentRolle}

${rollen.map(r => ` `).join('')}
`, }); document.querySelectorAll('.adm-rolle-choice').forEach(btn => { btn.addEventListener('click', async () => { UI.modal.close(); try { await API.patch(`/admin/users/${uid}`, { rolle: btn.dataset.rolle }); UI.toast.success(`${name} ist jetzt ${btn.dataset.rolle}.`); _renderTab(); } catch (e) { UI.toast.error(e.message); } }); }); } async function _changeTier(uid, name, currentTier) { const tiers = ['standard', 'pro', 'breeder', 'standard_test', 'pro_test', 'breeder_test']; const tierLabels = { standard: 'Standard (kostenlos)', pro: 'Pro (bezahlt)', breeder: 'Breeder (Züchter)', standard_test: 'Standard Test (intern)', pro_test: 'Pro Test (intern)', breeder_test: 'Breeder Test (intern)', }; UI.modal.open({ title: `Abo-Stufe ändern: ${name}`, body: `

Aktuelle Stufe: ${currentTier}

${tiers.map(t => ` `).join('')}
`, }); document.querySelectorAll('.adm-tier-choice:not([disabled])').forEach(btn => { btn.addEventListener('click', async () => { UI.modal.close(); try { await API.patch(`/admin/users/${uid}`, { subscription_tier: btn.dataset.tier }); // Eigenes Tier geändert → User-State neu laden + Welten neu rendern if (String(uid) === String(_appState?.user?.id)) { _appState.user.subscription_tier = btn.dataset.tier; if (window.Worlds) { window.Worlds.init(_appState); } UI.toast.success(`Dein Tier ist jetzt ${btn.dataset.tier} — Ansicht aktualisiert.`); } else { UI.toast.success(`${name}: Abo-Stufe ist jetzt ${btn.dataset.tier}.`); } _renderTab(); } catch (e) { UI.toast.error(e.message); } }); }); } async function _deleteUser(uid, name) { const ok = await UI.modal.confirm({ title: `${name} löschen?`, message: 'Alle Daten dieses Accounts werden unwiderruflich gelöscht — Hunde, Tagebuch, Beiträge.', confirmText: 'Endgültig löschen', }); if (!ok) return; try { await API.del(`/admin/users/${uid}`); UI.toast.success(`${name} gelöscht.`); _renderTab(); } catch (e) { UI.toast.error(e.message); } } // ------------------------------------------------------------------ // TAB: FORUM & MELDUNGEN // ------------------------------------------------------------------ async function _renderForum(el) { el.innerHTML = `
Lade…
`; el.querySelectorAll('.adm-forum-nav').forEach(btn => { btn.addEventListener('click', async () => { el.querySelectorAll('.adm-forum-nav').forEach(b => { b.className = b === btn ? 'btn btn-primary btn-sm adm-forum-nav' : 'btn btn-ghost btn-sm adm-forum-nav'; }); await _renderForumView(el.querySelector('#adm-forum-content'), btn.dataset.view); }); }); await _renderForumView(el.querySelector('#adm-forum-content'), 'reports'); } async function _renderForumView(el, view) { el.innerHTML = '
Lade…
'; if (view === 'reports') { const reports = await API.get('/admin/reports'); if (!reports.length) { el.innerHTML = _emptyState('check', 'Keine offenen Meldungen', 'Alles sauber.'); return; } el.innerHTML = `
${reports.map(r => `
${r.resolved ? '✓ Erledigt · ' : ''} ${UI.escape(r.target_type)} #${r.target_id} · Gemeldet von ${UI.escape(r.melder_name)}
Grund: ${UI.escape(r.grund)}
${r.content_preview ? `
${UI.escape(r.content_preview)}
` : ''}
${!r.resolved ? ` ` : ''}
`).join('')}
`; el.querySelectorAll('.adm-resolve-btn').forEach(btn => { btn.addEventListener('click', async () => { try { await API.patch(`/admin/reports/${btn.dataset.rid}`, {}); _renderForumView(el, 'reports'); } catch (e) { UI.toast.error(e.message); } }); }); el.querySelectorAll('.adm-del-content').forEach(btn => { btn.addEventListener('click', () => _deleteContent(btn.dataset.type, btn.dataset.id, el, 'reports')); }); } else { // Threads el.innerHTML = `
Lade…
`; const loadThreads = async () => { const q = el.querySelector('#adm-thread-q').value; const deleted = el.querySelector('#adm-show-deleted').checked ? 1 : 0; const data = await API.get(`/admin/forum/threads?q=${encodeURIComponent(q)}&deleted=${deleted}`); _renderThreadList(el.querySelector('#adm-thread-list'), data.threads, el); }; let t2; el.querySelector('#adm-thread-q').addEventListener('input', () => { clearTimeout(t2); t2 = setTimeout(loadThreads, 350); }); el.querySelector('#adm-show-deleted').addEventListener('change', loadThreads); await loadThreads(); } } function _renderThreadList(el, threads, parentEl) { if (!threads.length) { el.innerHTML = _emptyState('chat-circle-dots', 'Keine Threads', ''); return; } el.innerHTML = `
${threads.map(t => `
${t.is_deleted ? '' : ''}${UI.escape(t.titel)}${t.is_deleted ? '' : ''}
von ${UI.escape(t.autor_name)} · ${t.antworten} Antworten · ${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''}
${!t.is_deleted ? ` ` : ` `}
`).join('')}
`; el.querySelectorAll('.adm-pin').forEach(btn => { btn.addEventListener('click', async () => { await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_pinned: btn.dataset.pinned === '1' ? 0 : 1 }); parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input')); }); }); el.querySelectorAll('.adm-lock').forEach(btn => { btn.addEventListener('click', async () => { await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_locked: btn.dataset.locked === '1' ? 0 : 1 }); parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input')); }); }); el.querySelectorAll('.adm-del-thread').forEach(btn => { btn.addEventListener('click', () => _deleteContent('thread', btn.dataset.tid, parentEl, 'threads')); }); el.querySelectorAll('.adm-restore-thread').forEach(btn => { btn.addEventListener('click', async () => { await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_deleted: 0 }); parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input')); }); }); } async function _deleteContent(type, id, parentEl, view) { const ok = await UI.modal.confirm({ title: `${type === 'thread' ? 'Thread' : 'Beitrag'} löschen?`, message: 'Der Inhalt wird als gelöscht markiert.', confirmText: 'Löschen', }); if (!ok) return; try { await API.del(`/admin/forum/${type === 'thread' ? 'threads' : 'posts'}/${id}`); UI.toast.success('Gelöscht.'); _renderForumView(parentEl, view); } catch (e) { UI.toast.error(e.message); } } // ------------------------------------------------------------------ // TAB: SYSTEM // ------------------------------------------------------------------ async function _renderSystem(el) { el.innerHTML = `
Lade…
Medien
Wiki-Daten
Server-Logs
Lade…
`; const loadLogs = async () => { const level = el.querySelector('#adm-log-level').value; const box = el.querySelector('#adm-log-box'); box.textContent = 'Lade…'; const rows = await API.get(`/admin/logs?lines=200${level ? '&level=' + level : ''}`); const COLORS = { ERROR: '#ef4444', WARNING: '#f59e0b', INFO: '#6b7280', DEBUG: '#94a3b8' }; box.innerHTML = rows.reverse().map(r => { const color = COLORS[r.l] || '#6b7280'; return `
` + `${r.t} ` + `${r.l} ` + `${UI.escape(r.n)} ` + `${UI.escape(r.m)}
`; }).join('') || 'Keine Einträge'; }; el.querySelector('#adm-sys-refresh').addEventListener('click', () => { _loadSystemCards(el.querySelector('#adm-sys-cards')); loadLogs(); }); el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs); el.querySelector('#adm-log-level').addEventListener('change', loadLogs); el.querySelector('#adm-generate-previews').addEventListener('click', async (e) => { const btn = e.currentTarget; const res = el.querySelector('#adm-maint-result'); btn.disabled = true; res.textContent = 'Generiere Previews… (kann 1–2 Minuten dauern)'; try { const d = await API.post('/admin/media/generate-previews', {}); res.textContent = `✓ ${d.generated} neu generiert · ${d.skipped} bereits vorhanden · ${d.errors} Fehler`; } catch (err) { res.textContent = '✗ Fehler: ' + (err.message || err); } finally { btn.disabled = false; } }); el.querySelector('#adm-enrichment-status').addEventListener('click', async (e) => { const btn = e.currentTarget; const res = el.querySelector('#adm-maint-result'); btn.disabled = true; res.textContent = 'Lade…'; try { const d = await API.get('/admin/wiki/enrichment-status'); const modelList = Object.entries(d.by_model) .map(([m, n]) => `${m}: ${n}`).join(', '); res.textContent = `Gesamt: ${d.total} | Angereichert: ${d.enriched} | Kein Wiki: ${d.no_wiki} | Ausstehend: ${d.pending} | Mit Foto: ${d.with_photo} | Modelle: ${modelList || '–'}`; } catch (err) { res.textContent = '✗ Fehler: ' + (err.message || err); } finally { btn.disabled = false; } }); el.querySelector('#adm-fetch-photos').addEventListener('click', async (e) => { const btn = e.currentTarget; const res = el.querySelector('#adm-maint-result'); btn.disabled = true; res.textContent = 'Fotos werden geladen… (kann 30–60s dauern)'; try { const d = await API.post('/admin/wiki/fetch-photos?limit=50', {}); res.textContent = `✓ ${d.found} Foto(s) gespeichert`; } catch (err) { res.textContent = '✗ Fehler: ' + (err.message || err); } finally { btn.disabled = false; } }); el.querySelector('#adm-evaluate-breeds').addEventListener('click', async (e) => { const btn = e.currentTarget; const res = el.querySelector('#adm-maint-result'); const box = el.querySelector('#adm-eval-result'); btn.disabled = true; res.textContent = 'Bewertung läuft… (ca. 30s)'; box.style.display = 'none'; try { const d = await API.get('/admin/wiki/evaluate?sample=20'); if (d.error) { res.textContent = '✗ ' + d.error; return; } const avg = d.averages; const scoreColor = v => v >= 4 ? 'var(--c-success)' : v >= 3 ? 'var(--c-warning)' : 'var(--c-danger)'; const scoreBar = v => `${v.toFixed(1)}`; const rows = d.results.filter(r => !r.error).map(r => ` ${UI.escape(r.name)} ${scoreBar(r.vollstaendigkeit)} ${scoreBar(r.korrektheit)} ${scoreBar(r.sprachqualitaet)} ${scoreBar(r.konsistenz)} ${scoreBar(r.gesamt)} ${UI.escape(r.hinweis || '')} ` ).join(''); box.style.display = 'block'; box.innerHTML = `
Ø-Scores (${d.evaluated}/${d.sample_size} Rassen bewertet)
${['vollstaendigkeit','korrektheit','sprachqualitaet','konsistenz','gesamt'].map(k => `
${avg[k]?.toFixed(1) ?? '–'}
${{vollstaendigkeit:'Vollst.',korrektheit:'Korrekt.',sprachqualitaet:'Sprache',konsistenz:'Konsistenz',gesamt:'Gesamt'}[k]}
` ).join('')}
${rows}
Rasse Vollst.Korrekt. SpracheKonsis. Ges.Hinweis
`; const judge = d.judge_source === 'cloud' ? 'Claude (Cloud)' : d.judge_source === 'local' ? 'lokales Modell ⚠︎' : (d.judge_source || '–'); res.textContent = `✓ Bewertung abgeschlossen — Prüfer: ${judge}`; } catch (err) { res.textContent = '✗ Fehler: ' + (err.message || err); } finally { btn.disabled = false; } }); const [, orsStats] = await Promise.all([ _loadSystemCards(el.querySelector('#adm-sys-cards')), API.get('/admin/ors/stats').catch(() => null), ]); _renderOrsCard(el.querySelector('#adm-ors-card'), orsStats); await loadLogs(); } async function _loadSystemCards(el) { el.innerHTML = `
Lade…
`; const s = await API.get('/admin/system'); const diskUsedGb = s.disk_total_gb - s.disk_free_gb; const diskPct = s.disk_total_gb > 0 ? Math.round(diskUsedGb / s.disk_total_gb * 100) : 0; const uptime = _formatUptime(s.uptime_seconds); el.innerHTML = `
${_statCard('database', 'Datenbank', s.db_size_mb.toFixed(1) + ' MB', 'var(--c-primary)')} ${_statCard('image', 'Media-Ordner', s.media_size_mb.toFixed(1) + ' MB','var(--c-text-secondary)')} ${_statCard('timer', 'Uptime', uptime, 'var(--c-success)')} ${_statCard('hard-drive','Disk frei', s.disk_free_gb.toFixed(1) + ' GB','diskPct > 85 ? "var(--c-danger)" : "var(--c-text-secondary)"')}
Disk-Auslastung
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
Python ${UI.escape(s.python_version)} APP v${typeof APP_VER !== 'undefined' ? APP_VER : '—'} · ${UI.escape(s.sw_version || '?')}
`; } function _renderOrsCard(el, d) { if (!el) return; if (!d || d.today_count == null) { el.innerHTML = ''; return; } const limit = d.today_limit ?? 2000; const count = d.today_count ?? 0; const pct = Math.min(Math.round(count / limit * 100), 100); const barColor = pct < 50 ? '#4ade80' : pct <= 80 ? '#facc15' : '#f87171'; // Wochensumme + Gesamtsumme aus daily_history const hist7 = (d.daily_history || []).slice(-7); const weekTotal = hist7.reduce((s, h) => s + (h.count ?? 0), 0); const totalAll = (d.daily_history || []).reduce((s, h) => s + (h.count ?? 0), 0); // Sparkline aus daily_history (letzte 30 Tage) const hist = Array.isArray(d.daily_history) ? d.daily_history : []; const W = 400, H = 60, padY = 5; let sparkline = ''; if (hist.length >= 2) { const maxC = Math.max(...hist.map(h => h.count), 1); const pts = hist.map((h, i) => { const x = (i / (hist.length - 1)) * W; const y = H - padY - (h.count / maxC) * (H - 2 * padY); return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); sparkline = ``; } else { sparkline = ``; } const lastDate = hist.length ? hist[hist.length - 1].date : ''; // Top-Nutzer-Tabelle const topUsers = Array.isArray(d.top_users) ? d.top_users.slice(0, 10) : []; const userRows = topUsers.map(u => { const emailDisplay = (u.email || '').length > 20 ? '@' + (u.email || '').split('@')[1] : UI.escape(u.email || ''); return ` ${UI.escape(u.name || '–')} ${emailDisplay} ${u.total ?? 0} ${u.last_week || '–'} `; }).join(''); el.innerHTML = `
OpenRouteService
${count.toLocaleString('de')} / ${limit.toLocaleString('de')} heute ${weekTotal} diese Woche · ${totalAll} gesamt (30 Tage)
${sparkline}
30 Tage ${UI.escape(lastDate)}
${topUsers.length ? `
Aktivste Nutzer
${userRows}
Name E-Mail Gesamt Letzte Woche
` : ''}
`; } function _formatUptime(secs) { const d = Math.floor(secs / 86400); const h = Math.floor((secs % 86400) / 3600); const m = Math.floor((secs % 3600) / 60); if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}min`; return `${m}min`; } // ------------------------------------------------------------------ // TAB: JOBS // ------------------------------------------------------------------ // TAB: MODERATION // ------------------------------------------------------------------ function _ageLabel(createdAt) { if (!createdAt) return ''; const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000; const overdue = h >= 24; const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`; return ` ${overdue ? '⚠️ ' : ''}${label} `; } function _historySection(label, items, renderItem) { const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`; return `
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
${items.map(item => `
${renderItem(item)}
`).join('')}
`; } async function _renderModeration(el) { el.innerHTML = `
Lade…
`; el.querySelector('#adm-mod-refresh').addEventListener('click', () => _loadModeration(el.querySelector('#adm-mod-content'))); await _loadModeration(el.querySelector('#adm-mod-content')); } async function _loadModeration(el) { el.innerHTML = `
Lade…
`; const [zuchter, fotos, reports, poiEdits] = await Promise.all([ API.get('/wiki/zuchter/pending').catch(() => []), API.get('/wiki/foto-submissions').catch(() => []), API.get('/moderation/reports').catch(() => []), API.get('/moderation/poi-edits').catch(() => []), ]); const zuchterPending = zuchter.filter(z => !z.verified); const zuchterDone = zuchter.filter(z => z.verified); const fotosPending = fotos.filter(f => f.status === 'pending'); const fotosDone = fotos.filter(f => f.status !== 'pending'); const reportsPending = reports.filter(r => !r.resolved); const reportsDone = reports.filter(r => r.resolved); const poiPending = poiEdits.filter(e => e.status === 'pending'); const poiDone = poiEdits.filter(e => e.status !== 'pending'); const modItems = [ { label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' }, { label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' }, { label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' }, { label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' }, ].filter(i => i.count > 0); let html = `
${UI.icon('check-square')} Zu erledigen ${modItems.length === 0 ? ` ${UI.icon('check-circle')} Alles erledigt ` : modItems.map(i => ` ${UI.icon(i.icon)} ${i.label} ${i.count} `).join('') }
`; // --- Züchter-Einreichungen --- html += `

Züchter-Einreichungen ${zuchterPending.length}

`; if (!zuchterPending.length) { html += `

Keine ausstehenden Einreichungen.

`; } else { html += `
${zuchterPending.map((z, i) => ` `).join('')}
RasseName / Zwingername OrtVDHAlterWebsite
${UI.escape(z.rasse_slug)} ${UI.escape(z.name)}${z.zwingername ? `
${UI.escape(z.zwingername)}` : ''}
${UI.escape([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))} ${z.vdh_mitglied ? ` VDH` : '—'} ${_ageLabel(z.created_at)} ${z.website ? `Link` : '—'}
`; } // Züchter-History if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone, z => `${UI.escape(z.name)} · ${UI.escape(z.rasse_slug)} · ${UI.icon('check-circle')} ${UI.escape(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`); // --- Wiki-Foto-Einreichungen --- html += `

Wiki-Foto-Einreichungen ${fotosPending.length}

`; if (!fotosPending.length) { html += `

Keine ausstehenden Foto-Einreichungen.

`; } else { html += `
${fotosPending.map(f => `
${UI.escape(f.rasse_name)}
von ${UI.escape(f.user_name)}
${_ageLabel(f.created_at)}
${f.aktuell_foto ? `Aktuell
↑ aktuelles Foto
` : ''}
`).join('')}
`; } // Fotos-History if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone, f => ` ${UI.escape(f.rasse_name||'?')} · von ${UI.escape(f.user_name||'?')} · ${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`} ${f.reviewed_by_name ? ` von ${UI.escape(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`); // --- Forum-Meldungen --- html += `

Forum-Meldungen ${reportsPending.length}

`; if (!reportsPending.length) { html += `

Keine offenen Meldungen.

`; } else { html += `
${reportsPending.map(r => `
${UI.escape(r.target_type)} #${r.target_id} · Gemeldet von ${UI.escape(r.melder_name || '?')} ${_ageLabel(r.created_at)}
Grund: ${UI.escape(r.grund)}
${r.content_preview ? `
${UI.escape(r.content_preview)}
` : ''}
`).join('')}
`; } // Meldungen-History if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone, r => `${UI.escape(r.target_type)} #${r.target_id} · ${UI.escape(r.grund)} · Gemeldet von ${UI.escape(r.melder_name||'?')} · ${UI.icon('check-circle')} ${UI.escape(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`); // --- POI-Korrekturen --- html += `

POI-Korrekturen ${poiPending.length}

`; if (!poiPending.length) { html += `

Keine ausstehenden POI-Korrekturen.

`; } else { html += `
${poiPending.map((e, i) => ` `).join('')}
Ort Feld Alt Neu Von Alter
${UI.escape(e.poi_name || `OSM #${e.osm_id}`)} ${UI.escape(e.field)} ${UI.escape(e.old_value || '—')} ${UI.escape(e.new_value || '—')} ${UI.escape(e.einreicher_name || '?')} ${_ageLabel(e.created_at)}
`; } // POI-History if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone, e => `${UI.escape(e.poi_name||`OSM #${e.osm_id}`)} · ${UI.escape(e.field)}: ${UI.escape(e.old_value||'—')} → ${UI.escape(e.new_value||'—')} · ${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`} ${e.mod_name ? ` von ${UI.escape(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`); el.innerHTML = html; // Züchter freigeben el.querySelectorAll('.adm-zuchter-approve').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; await API.patch(`/wiki/zuchter/${btn.dataset.id}/verify`, {}); await _loadModeration(el); }); }); // Züchter löschen el.querySelectorAll('.adm-zuchter-delete').forEach(btn => { btn.addEventListener('click', async () => { if (!window.confirm('Eintrag löschen?')) return; btn.disabled = true; await API.delete(`/admin/wiki/zuchter/${btn.dataset.id}`); await _loadModeration(el); }); }); // Foto freigeben el.querySelectorAll('.adm-foto-approve').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; await API.patch(`/wiki/foto-submissions/${btn.dataset.id}`, {action: 'approve'}); await _loadModeration(el); }); }); // Foto ablehnen el.querySelectorAll('.adm-foto-reject').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; await API.patch(`/wiki/foto-submissions/${btn.dataset.id}`, {action: 'reject', reject_reason: 'Nicht geeignet.'}); await _loadModeration(el); }); }); // Forum-Meldung erledigen el.querySelectorAll('.adm-mod-resolve').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; try { await API.patch(`/moderation/reports/${btn.dataset.rid}`, {}); await _loadModeration(el); } catch (e) { UI.toast.error(e.message); btn.disabled = false; } }); }); // POI-Korrektur freigeben el.querySelectorAll('.adm-poi-approve').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; try { await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' }); UI.toast.success('Korrektur übernommen.'); await _loadModeration(el); } catch (e) { UI.toast.error(e.message); btn.disabled = false; } }); }); // POI-Korrektur ablehnen el.querySelectorAll('.adm-poi-reject').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; try { await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' }); UI.toast.success('Korrektur abgelehnt.'); await _loadModeration(el); } catch (e) { UI.toast.error(e.message); btn.disabled = false; } }); }); } // ------------------------------------------------------------------ // TAB: ZÜCHTER-ANTRÄGE // ------------------------------------------------------------------ async function _renderZuechter(el) { el.innerHTML = `
Lade…
Lade…
`; el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => { _loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')); _loadZuechterListe(el.querySelector('#adm-zuchter-liste')); }); await Promise.all([ _loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')), _loadZuechterListe(el.querySelector('#adm-zuchter-liste')), ]); } async function _loadZuechterAntraege(el) { el.innerHTML = `
Lade…
`; 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 = `
Offene Anträge
${UI.icon('check-circle')} Keine offenen Anträge
`; return; } el.innerHTML = `
Offene Anträge (${antraege.length})
${antraege.map(a => `
${UI.escape(a.name)} ${UI.escape(a.email)}
${UI.icon('paw-print')} ${UI.escape(a.rasse_text || '–')} ${UI.icon('house-line')} ${UI.escape(a.zwingername || '–')} ${UI.icon('users')} ${UI.escape(a.verein || '–')} ${UI.icon('map-pin')} ${UI.escape(a.stadt || '–')} ${UI.icon('certificate')} VDH: ${a.vdh_mitglied ? 'ja' : 'nein'} ${a.created_at ? `${UI.icon('clock')} ${new Date(a.created_at).toLocaleDateString('de-DE')}` : ''}
${a.beschreibung ? `
${UI.escape(a.beschreibung)}
` : ''}
`).join('')}
`; // 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 ? `` : `

Keine Dokumente hochgeladen.

`, }); }); }); // 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: `

Bitte gib einen Ablehnungsgrund an. Dieser wird dem Antragsteller mitgeteilt.

`, footer: `
`, }); 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 _loadZuechterListe(el) { el.innerHTML = `
Lade…
`; let breeders; try { breeders = await API.breeder.allList(); } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message); return; } const tierBadge = t => { if (t === 'breeder') return `Züchter-Abo`; if (t === 'breeder_test') return `Test`; return `Standard`; }; const rows = breeders.map(b => `
${UI.escape(b.name)}
${UI.escape(b.email)}
${UI.escape(b.zwingername || '—')} ${UI.escape(b.rasse_text || '—')} ${UI.escape(b.stadt || '—')} ${b.wuerfe_count || 0} Würfe
${b.hunde_count || 0} Hunde ${tierBadge(b.subscription_tier)} ${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'} `).join(''); el.innerHTML = `
Alle Züchter (${breeders.length})
${['Nutzer','Zwingername','Rasse','Stadt','Aktivität','Abo','Seit',''].map(h => `` ).join('')} ${rows || ``}
${h}
Noch keine Züchter
`; el.querySelectorAll('.adm-breeder-tier-btn').forEach(btn => { btn.addEventListener('click', () => _changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier) ); }); } // ------------------------------------------------------------------ async function _renderJobs(el) { el.innerHTML = `
Lade…
`; el.querySelector('#adm-jobs-refresh').addEventListener('click', () => _loadJobs(el.querySelector('#adm-jobs-list'))); await _loadJobs(el.querySelector('#adm-jobs-list')); } async function _loadJobs(el) { el.innerHTML = `
Lade…
`; const jobs = await API.get('/admin/scheduler/jobs'); if (!jobs.length) { el.innerHTML = _emptyState('timer', 'Keine Jobs', 'Der Scheduler hat keine registrierten Jobs.'); return; } el.innerHTML = `
${jobs.map((j, i) => ` `).join('')}
Job Nächster Lauf Trigger
${UI.escape(j.name)}
${UI.escape(j.id)}
${j.next_run_time ? _formatDateTime(j.next_run_time) : ''} ${UI.escape(j.trigger)}
`; el.querySelectorAll('.adm-job-trigger').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; try { await API.post(`/admin/scheduler/trigger/${encodeURIComponent(btn.dataset.id)}`, {}); UI.toast.success(`Job "${btn.dataset.name}" wird ausgeführt.`); } catch (e) { UI.toast.error(e.message || 'Fehler beim Auslösen des Jobs.'); } finally { btn.disabled = false; } }); }); } function _formatDateTime(iso) { try { const d = new Date(iso); return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }); } catch { return iso; } } // ------------------------------------------------------------------ // TAB: AUDIT-LOG // ------------------------------------------------------------------ async function _renderPartner(el) { const codes = (await API.get('/admin/partner/codes')) || []; const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || []; const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || []; el.innerHTML = `

So funktioniert das Partner-System

1. Partner-Code erstellen — Erstelle einen Code (z. B. HUNDEBLOG) für einen Influencer oder Partner. Der Code wird an die Person weitergegeben.

2. Registrierung mit Code — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als Gründer markiert (Platz #1–100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.

3. Partner-Status vergeben — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt Partner-Badge (blaues Badge im Profil) und Gründer-Lizenz. So ist auch er als Gründer #X sichtbar.

Max. 100 Gründer — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.

Freunde werben — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — dauerhaft auf Ban Yaro Pro. Gründer können zusätzlich ihren Geworbenen 50 % schenken (begrenzt durch ihre Gründer-Tickets, Standard 25).

Neuen Partner-Code erstellen

Aktive Codes

${codes.length === 0 ? `

Noch keine Partner-Codes angelegt.

` : ` ${codes.map(c => ` `).join('')}
Code Bezeichnung Nutzungen Gründer
${c.code} ${c.active ? '' : `
⏸ pausiert
`}
${c.label}
${c.owner_name ? `👤 ${UI.escape(c.owner_name)}` : ``}
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''} ${c.grants_founder ? '✓' : '—'} ${c.uses > 0 ? ` ` : ''}
` }

QR-Kontingente

Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt.

${qrBatches.length === 0 ? `

Noch keine Kontingente bestellt.

` : ` ${qrBatches.map(b => ` `).join('')}
Code Kontingent Stk. Scans Registr. Versuche
${UI.escape(b.code)} ${UI.escape(b.label)}
${(b.created_at || '').slice(0, 10)}
${b.quantity} ${b.scans} ${b.registrations} ${b.attempts} ${b.registrations + b.attempts > 0 ? ` ` : ''} ${UI.icon('file-pdf')} PDF
`}

Profil-Freigaben ${profiles.filter(p => p.submitted_at && p.approved === 0).length ? `${profiles.filter(p => p.submitted_at && p.approved === 0).length} offen` : ''}

${profiles.length === 0 ? `

Noch keine Partner-Profile angelegt.

` : profiles.map(p => `
${p.logo_url ? `` : `
`}
${UI.escape(p.display_name || p.name)}
${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}
${p.approved === 1 ? `✓ Frei` : p.approved === -1 ? `✗ Abgelehnt` : p.submitted_at ? `⏳ Prüfen` : `Entwurf`} ${p.approved !== 1 ? `` : ''} ${p.approved !== -1 ? `` : ''}
`).join('')}

Nutzer-Status manuell vergeben

`; // Alle Einlösungen eines Codes (lazy, .hidden via classList) — mit Kanal-Spalte el.querySelectorAll('.adm-code-regs').forEach(btn => { btn.addEventListener('click', async () => { const row = el.querySelector(`#adm-code-regs-${btn.dataset.id}`); if (!row) return; row.classList.toggle('hidden'); if (row.classList.contains('hidden') || row.dataset.loaded === '1') return; try { const regs = await API.get(`/admin/partner/codes/${btn.dataset.id}/registrations`); row.dataset.loaded = '1'; const cell = row.querySelector('td'); cell.innerHTML = !regs.length ? `
Keine Accounts.
` : regs.map(u => `
${UI.escape(u.name)} · ${UI.escape(u.email)}
${u.qr_seq ? `QR #${u.qr_seq}` : 'Link/manuell'} ${(u.created_at || '').slice(0, 16).replace(' ', ' · ')} ${u.email_verified ? `✓ bestätigt` : `⏳ unbestätigt`}
`).join(''); } catch (err) { UI.toast.error(err.message); } }); }); // Code pausieren/aktivieren (Notbremse bei geleakten Codes) el.querySelectorAll('.adm-toggle-code').forEach(btn => { btn.addEventListener('click', async () => { try { const r = await API.post(`/admin/partner/codes/${btn.dataset.id}/toggle`, {}); UI.toast.success(r.active ? 'Code wieder aktiv.' : 'Code pausiert — Einlösungen sind gesperrt.'); await _renderPartner(el); } catch (err) { UI.toast.error(err.message); } }); }); // Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner) el.querySelectorAll('.adm-code-owner').forEach(btn => { btn.addEventListener('click', async () => { const q = window.prompt('Benutzername des Partners (exakt):'); if (!q) return; try { const hits = await API.get(`/admin/users/search?q=${encodeURIComponent(q.trim())}`); const hit = (hits || []).find(u => u.name.toLowerCase() === q.trim().toLowerCase()) || (hits || [])[0]; if (!hit) { UI.toast.warning('Kein User gefunden.'); return; } await API.post(`/admin/partner/codes/${btn.dataset.id}/owner`, { user_id: hit.id }); UI.toast.success(`Code gehört jetzt ${hit.name} — er sieht seine QR-Kontingente im Partner-Profil.`); await _renderPartner(el); } catch (err) { UI.toast.error(err.message); } }); }); // QR-Kontingent anlegen el.querySelector('#adm-qr-create')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); const fd = UI.formData(e.target); await UI.asyncButton(btn, async () => { const b = await API.post(`/admin/partner/codes/${fd.code_id}/qr-batches`, { label: fd.label, quantity: parseInt(fd.quantity), }); UI.toast.success(`Kontingent "${b.label}" mit ${b.quantity} QR-Codes erstellt.`); await _renderPartner(el); }); }); // QR-Detail: Accounts hinter einem Kontingent (lazy laden, .hidden via classList) el.querySelectorAll('.adm-qr-detail').forEach(btn => { btn.addEventListener('click', async () => { const row = el.querySelector(`#adm-qr-detail-${btn.dataset.id}`); if (!row) return; row.classList.toggle('hidden'); if (row.classList.contains('hidden') || row.dataset.loaded === '1') return; try { const regs = await API.get(`/admin/partner/qr-batches/${btn.dataset.id}/registrations`); row.dataset.loaded = '1'; const cell = row.querySelector('td'); cell.innerHTML = !regs.length ? `
Keine Accounts.
` : regs.map(u => `
${UI.escape(u.name)} · ${UI.escape(u.email)}
#${u.seq} ${(u.created_at || '').slice(0, 16).replace(' ', ' · ')} ${u.email_verified ? `✓ bestätigt` : `⏳ Versuch`}
`).join(''); } catch (err) { UI.toast.error(err.message); } }); }); // QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal) el.querySelectorAll('.adm-qr-del').forEach(btn => { btn.addEventListener('click', async () => { if (btn.dataset.armed !== '1') { btn.dataset.armed = '1'; btn.textContent = 'Wirklich löschen?'; setTimeout(() => { btn.dataset.armed = '0'; btn.innerHTML = UI.icon('trash'); }, 3000); return; } try { await API.del(`/admin/partner/qr-batches/${btn.dataset.id}`); UI.toast.success(`Kontingent "${btn.dataset.label}" gelöscht.`); await _renderPartner(el); } catch (err) { UI.toast.error(err.message); } }); }); // Partner-Profil-Vorschau auf-/zuklappen (.hidden hat !important → classList) el.querySelectorAll('.adm-pp-preview').forEach(btn => { btn.addEventListener('click', () => { el.querySelector(`#adm-pp-preview-${btn.dataset.uid}`)?.classList.toggle('hidden'); }); }); // Partner-Profil freigeben / ablehnen el.querySelectorAll('.adm-pp-review').forEach(btn => { btn.addEventListener('click', async () => { try { await API.post(`/admin/partner/profiles/${btn.dataset.uid}/review`, { approved: parseInt(btn.dataset.val) }); UI.toast.success(btn.dataset.val === '1' ? 'Profil freigegeben.' : 'Profil abgelehnt.'); await _renderPartner(el); } catch (err) { UI.toast.error(err.message); } }); }); // Code erstellen el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); const fd = UI.formData(e.target); const code = (fd.code || '').trim().toUpperCase(); if (!code) return; await UI.asyncButton(btn, async () => { await API.post('/admin/partner/codes', { code, label: fd.label || code, grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0, max_uses: fd.max_uses ? parseInt(fd.max_uses) : null, }); UI.toast.success(`Code "${code}" erstellt.`); await _renderPartner(el); }); }); // Code löschen el.querySelectorAll('.adm-del-code').forEach(btn => { btn.addEventListener('click', async () => { if (!window.confirm(`Code wirklich löschen?`)) return; const id = btn.dataset.id; await UI.asyncButton(btn, async () => { await API.del(`/admin/partner/codes/${id}`); UI.toast.success('Code gelöscht.'); await _renderPartner(el); }); }); }); // User suchen für Status-Vergabe let _grantUserId = null; const searchInput = el.querySelector('#adm-grant-search'); const grantResult = el.querySelector('#adm-grant-result'); let _searchTimeout = null; searchInput?.addEventListener('input', () => { clearTimeout(_searchTimeout); _grantUserId = null; const q = searchInput.value.trim(); if (q.length < 1) { grantResult.innerHTML = ''; return; } _searchTimeout = setTimeout(async () => { try { const res = await API.get(`/admin/users?q=${encodeURIComponent(q)}&limit=10`); const users = res?.users || res || []; if (!users || !users.length) { grantResult.innerHTML = `

Kein User gefunden.

`; return; } grantResult.innerHTML = users.map(u => `
${u.name} ${u.rolle}${u.is_founder ? ' · ⭐' : ''}${u.is_partner ? ' · 🤝' : ''}
`).join(''); grantResult.querySelectorAll('.adm-grant-user').forEach(div => { div.addEventListener('click', () => { _grantUserId = parseInt(div.dataset.id); searchInput.value = div.dataset.name; // Aktuellen Status in Checkboxen setzen const form = el.querySelector('#adm-partner-grant'); if (form) { form.querySelector('[name="is_founder"]').checked = div.dataset.founder === '1'; form.querySelector('[name="is_partner"]').checked = div.dataset.partner === '1'; form.querySelector('[name="founder_tickets"]').value = div.dataset.tickets ?? 25; } grantResult.innerHTML = `

✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}

`; }); }); } catch(e) { grantResult.innerHTML = `

${e.message || 'Suchfehler'}

`; } }, 400); }); el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => { e.preventDefault(); if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; } const btn = e.target.querySelector('[type="submit"]'); const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0; const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0; const ticketsRaw = e.target.querySelector('[name="founder_tickets"]').value.trim(); await UI.asyncButton(btn, async () => { const body = { is_founder: isFounder, is_partner: isPartner }; if (ticketsRaw !== '') body.founder_tickets = parseInt(ticketsRaw); const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, body); if (!result) throw new Error('Keine Antwort vom Server.'); UI.toast.success(`Status für ${result.name} gesetzt.`); grantResult.innerHTML = `

✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'} | 🎟 ${result.founder_referral_tickets ?? 25} Tickets

`; }).catch(e => UI.toast.error(e.message || 'Fehler beim Speichern.')); }); } async function _renderOutreach(el) { const [templates, log] = await Promise.all([ API.get('/outreach/templates').catch(() => []), API.get('/outreach/log').catch(() => []), ]); const accountBadge = a => a === 'support' ? `support@` : `partner@`; el.innerHTML = `

Vorlagen

${templates.length === 0 ? `

Noch keine Vorlagen.

` : `
${templates.map(t => `
${UI.escape(t.label)} ${accountBadge(t.from_account)}
${UI.escape(t.subject)}
`).join('')}
`}

E-Mail senden

{name} wird nicht automatisch ersetzt — bitte manuell anpassen.

Versand-Log

${log.length === 0 ? `

Noch keine E-Mails gesendet.

` : ` ${log.map((l, i) => ` `).join('')}
Von Empfänger Betreff Wer Wann
${accountBadge(l.from_account)} ${UI.escape(l.recipient)} ${UI.escape(l.subject)} ${UI.escape(l.sent_by_name || '')} ${(l.sent_at||'').slice(0,16).replace('T',' ')}
`}
`; // Log-Zeile: Mail-Inhalt anzeigen el.querySelectorAll('tr[data-log-idx]').forEach(row => { row.addEventListener('click', () => { const l = log[Number(row.dataset.logIdx)]; if (!l) return; UI.modal.open({ title: UI.escape(l.subject), body: `
An: ${UI.escape(l.recipient)}  ·  Von: ${UI.escape(l.from_account)}@banyaro.app  ·  ${(l.sent_at||'').slice(0,16).replace('T',' ')}
${UI.escape(l.body || '(kein Text gespeichert)')}
`, footer: ``, }); }); }); // Vorlage in Compose laden function _loadTplIntoCompose(id) { const tpl = templates.find(t => t.id === id); if (!tpl) return; el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner'; el.querySelector('#adm-outreach-subject').value = tpl.subject; el.querySelector('#adm-outreach-body').value = tpl.body; } el.querySelectorAll('.adm-tpl-load').forEach(btn => { btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id))); }); // Vorlage löschen el.querySelectorAll('.adm-tpl-del').forEach(btn => { btn.addEventListener('click', async () => { if (!window.confirm('Vorlage löschen?')) return; await API.del(`/outreach/templates/${btn.dataset.id}`); await _renderOutreach(el); }); }); // Vorlage bearbeiten el.querySelectorAll('.adm-tpl-edit').forEach(btn => { btn.addEventListener('click', () => { const tpl = templates.find(t => t.id === Number(btn.dataset.id)); if (tpl) _openTplModal(el, tpl); }); }); // Neue Vorlage el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null)); // Senden el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); const from_account = el.querySelector('#adm-outreach-from').value; const to = (el.querySelector('#adm-outreach-to').value || '') .split(',').map(s => s.trim()).filter(Boolean); const subject = el.querySelector('#adm-outreach-subject').value.trim(); const body = el.querySelector('#adm-outreach-body').value.trim(); if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } if (!body) { UI.toast.warning('Text fehlt.'); return; } await UI.asyncButton(btn, async () => { const res = await API.post('/outreach/send', { to, subject, body, from_account }); if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`); if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`); await _renderOutreach(el); }); }); } function _openTplModal(el, tpl) { const isNew = !tpl; const id = `adm-tpl-modal-${Date.now()}`; UI.modal.open({ title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten', body: `
`, footer: ` `, }); document.getElementById(id)?.addEventListener('submit', async e => { e.preventDefault(); const payload = { label: document.getElementById(`${id}-label`).value.trim(), subject: document.getElementById(`${id}-subject`).value.trim(), body: document.getElementById(`${id}-body`).value.trim(), from_account: document.getElementById(`${id}-from`).value, }; if (!payload.label || !payload.subject || !payload.body) { UI.toast.warning('Alle Felder ausfüllen.'); return; } if (isNew) { const key = document.getElementById(`${id}-key`).value.trim(); if (!key) { UI.toast.warning('Interner Name fehlt.'); return; } await API.post('/outreach/templates', { ...payload, key }); } else { await API.put(`/outreach/templates/${tpl.id}`, payload); } UI.modal.close(); await _renderOutreach(el); }); } async function _renderAudit(el) { el.innerHTML = `
Lade…
`; el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list'))); await _loadAudit(el.querySelector('#adm-audit-list')); } async function _loadAudit(el) { el.innerHTML = `
Lade…
`; const rows = await API.get('/admin/audit?limit=50'); if (!rows.length) { el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.'); return; } el.innerHTML = `
${rows.map((r, i) => ` `).join('')}
Wann Admin Aktion Ziel
${_formatDateTime(r.created_at)} ${UI.escape(r.admin_name || '—')} ${UI.escape(r.action)} ${r.detail ? `
${UI.escape(r.detail)}
` : ''}
${UI.escape(r.target || '—')}
`; } // ------------------------------------------------------------------ // HELPERS // ------------------------------------------------------------------ function _prompt(msg) { return new Promise(resolve => { UI.modal.open({ title: 'Eingabe', body: `

${msg}

`, footer: ` `, }); document.getElementById('adm-prompt-ok')?.addEventListener('click', () => { const val = document.getElementById('adm-prompt-input')?.value || ''; UI.modal.close(); resolve(val); }); document.getElementById('adm-prompt-cancel')?.addEventListener('click', () => { UI.modal.close(); resolve(null); }); }); } function _emptyState(icon, title, text) { return `

${title}

${text ? `

${text}

` : ''}
`; } // ------------------------------------------------------------------ // BEWERBUNGEN — Social-Media-Job // ------------------------------------------------------------------ async function _renderBewerbungen(el) { let _statusFilter = 'pending'; async function _load() { el.innerHTML = `
${['pending','reviewing','accepted','rejected','alle'].map(s => ` `).join('')}
${UI.skeleton(3)}
`; el.querySelectorAll('.adm-bew-filter').forEach(btn => { btn.addEventListener('click', () => { _statusFilter = btn.dataset.s; _load(); }); }); try { const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`); const list = el.querySelector('#adm-bew-list'); if (!rows.length) { list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.'); return; } list.innerHTML = rows.map(r => `
${UI.escape(r.name)} ${r.username ? `(@${UI.escape(r.username)})` : ''}
${UI.escape(r.email)} · @${UI.escape(r.social_handle||'—')} ${r.dog_name ? ` · 🐕 ${UI.escape(r.dog_name)} (${UI.escape(r.dog_rasse||'')})` : ''}
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
${UI.escape((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
`).join(''); list.querySelectorAll('.adm-bew-status').forEach(sel => { sel.addEventListener('change', async () => { const id = sel.dataset.id; await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value }); UI.toast.success('Status aktualisiert.'); setTimeout(_load, 500); }); }); list.querySelectorAll('.adm-bew-view').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; const app = await API.get(`/jobs/admin/applications/${id}`); const docsHtml = app.docs?.length ? app.docs.map(d => ` 📎 ${UI.escape(d.filename)}`).join('') : 'Keine Anhänge'; UI.modal.open({ title: `Bewerbung — ${UI.escape(app.name)}`, body: `
E-Mail: ${UI.escape(app.email)}
Social: @${UI.escape(app.social_handle||'—')}
${app.dog_name ? `
Hund: ${UI.escape(app.dog_name)} (${UI.escape(app.dog_rasse||'')})
` : ''}
Motivation:
${UI.escape(app.motivation)}
Anhänge:
${docsHtml}
Admin-Notiz:
`, footer: ` `, }); document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => { const note = document.getElementById('adm-bew-note')?.value || ''; await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note }); UI.toast.success('Notiz gespeichert.'); UI.modal.close(); }); }); }); } catch (e) { el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message); } } await _load(); } // ------------------------------------------------------------------ // TAB: HILFE / FAQ async function _renderHilfe(el) { const KAT_LABEL = { installation: 'Installation & PWA', erste_schritte: 'Erste Schritte', standort: 'Standort & Wetter', account: 'Account & Passwort', features: 'Features erklärt', probleme: 'Technische Probleme', }; el.innerHTML = `

Hilfe / FAQ

Lade…
`; async function _load() { const listEl = el.querySelector('#adm-hilfe-list'); try { const articles = await API.get('/help?all=1'); if (!articles.length) { listEl.innerHTML = _emptyState('question', 'Noch keine FAQ-Artikel', ''); return; } // Gruppieren nach Kategorie const grouped = {}; for (const a of articles) { if (!grouped[a.kategorie]) grouped[a.kategorie] = []; grouped[a.kategorie].push(a); } let html = ''; for (const [kat, items] of Object.entries(grouped)) { const label = KAT_LABEL[kat] || kat; html += `
${UI.escape(label)}
`; for (const a of items) { html += `
${UI.escape(a.frage)} #${a.sort_order}
`; } html += `
`; } listEl.innerHTML = html; _bindListEvents(listEl); } catch (e) { listEl.innerHTML = _emptyState('warning', 'Fehler beim Laden', e.message || ''); } } function _bindListEvents(listEl) { // Edit-Button: Inline-Formular auf/zu klappen listEl.querySelectorAll('.adm-hilfe-edit-btn').forEach(btn => { btn.addEventListener('click', () => { const id = btn.dataset.id; const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`); if (form) form.style.display = form.style.display === 'none' ? '' : 'none'; }); }); // Edit-Cancel listEl.querySelectorAll('.adm-hilfe-edit-cancel').forEach(btn => { btn.addEventListener('click', () => { const id = btn.dataset.id; const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`); if (form) form.style.display = 'none'; }); }); // Edit-Save listEl.querySelectorAll('.adm-hilfe-edit-save').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; const row = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`); const payload = { kategorie: row.querySelector('.adm-hilfe-edit-kat').value, frage: row.querySelector('.adm-hilfe-edit-frage').value.trim(), antwort: row.querySelector('.adm-hilfe-edit-antwort').value.trim(), sort_order: parseInt(row.querySelector('.adm-hilfe-edit-sort').value, 10) || 0, }; if (!payload.frage || !payload.antwort) { UI.toast.error('Frage und Antwort sind Pflichtfelder.'); return; } try { await API.patch(`/help/${id}`, payload); UI.toast.success('Artikel gespeichert.'); _load(); } catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); } }); }); // Toggle aktiv/inaktiv listEl.querySelectorAll('.adm-hilfe-toggle-btn').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; const aktiv = parseInt(btn.dataset.aktiv, 10); try { await API.patch(`/help/${id}`, { aktiv: aktiv ? 0 : 1 }); UI.toast.success(aktiv ? 'Artikel ausgeblendet.' : 'Artikel eingeblendet.'); _load(); } catch (e) { UI.toast.error(e.message || 'Fehler.'); } }); }); // Delete listEl.querySelectorAll('.adm-hilfe-del-btn').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; const frage = btn.dataset.frage; if (!window.confirm(`Artikel wirklich löschen?\n\n"${frage}"`)) return; try { await API.del(`/help/${id}`); UI.toast.success('Artikel gelöscht.'); _load(); } catch (e) { UI.toast.error(e.message || 'Fehler beim Löschen.'); } }); }); } // Neuer-Artikel-Button el.querySelector('#adm-hilfe-neu').addEventListener('click', () => { const form = el.querySelector('#adm-hilfe-form'); form.style.display = form.style.display === 'none' ? '' : 'none'; }); // Formular abbrechen el.querySelector('#adm-hilfe-form-cancel').addEventListener('click', () => { el.querySelector('#adm-hilfe-form').style.display = 'none'; }); // Formular speichern el.querySelector('#adm-hilfe-form-save').addEventListener('click', async () => { const kat = el.querySelector('#adm-hilfe-kat').value; const frage = el.querySelector('#adm-hilfe-frage').value.trim(); const antwort= el.querySelector('#adm-hilfe-antwort').value.trim(); const sort = parseInt(el.querySelector('#adm-hilfe-sort').value, 10) || 0; if (!frage || !antwort) { UI.toast.error('Frage und Antwort sind Pflichtfelder.'); return; } try { await API.post('/help', { kategorie: kat, frage, antwort, sort_order: sort }); UI.toast.success('Artikel angelegt.'); el.querySelector('#adm-hilfe-form').style.display = 'none'; el.querySelector('#adm-hilfe-frage').value = ''; el.querySelector('#adm-hilfe-antwort').value = ''; el.querySelector('#adm-hilfe-sort').value = '0'; _load(); } catch (e) { UI.toast.error(e.message || 'Fehler beim Anlegen.'); } }); await _load(); } // ------------------------------------------------------------------ async function _renderUebungenAdmin(el) { el.innerHTML = `
Lade Übungen…
`; let byTab; try { byTab = await API.get('/training/exercises'); } catch (e) { el.innerHTML = `
Fehler: ${e.message}
`; return; } // Flatten to sorted list grouped by kategorie const allExercises = []; for (const [kat, list] of Object.entries(byTab)) { for (const ex of list) allExercises.push({ ...ex, _kat: kat }); } allExercises.sort((a, b) => a._kat.localeCompare(b._kat) || a.name.localeCompare(b.name)); // Group by kategorie const grouped = {}; for (const ex of allExercises) { grouped[ex._kat] = grouped[ex._kat] || []; grouped[ex._kat].push(ex); } const KAT_LABELS = { 'grundkommandos': 'Grundkommandos', 'tricks': 'Tricks', 'problemverhalten': 'Problemverhalten', 'mentale-auslastung': 'Mentale Auslastung', 'koerperpflege': 'Körperpflege', 'hundesport': 'Hundesport', 'welpe-basics': 'Welpe Basics', }; let html = `

Trainingsübungen bearbeiten

`; for (const [kat, list] of Object.entries(grouped)) { html += `
${KAT_LABELS[kat] || kat} (${list.length})
`; for (const ex of list) { const schritte = Array.isArray(ex.schritte) ? ex.schritte.join('\n') : ''; const exId = ex.exercise_id; html += `
${ex.name}
`; } html += `
`; } html += `
`; el.innerHTML = html; // Edit toggle el.querySelectorAll('.adm-ueb-edit-btn').forEach(btn => { btn.addEventListener('click', () => { const form = el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`); form.style.display = form.style.display === 'none' ? '' : 'none'; }); }); // Cancel el.querySelectorAll('.adm-ueb-cancel-btn').forEach(btn => { btn.addEventListener('click', () => { el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`).style.display = 'none'; }); }); // Save el.querySelectorAll('.adm-ueb-save-btn').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.exId; const form = el.querySelector(`.adm-ueb-form[data-ex-id="${id}"]`); const beschreibung = form.querySelector('.adm-ueb-beschreibung').value.trim(); const schritte = form.querySelector('.adm-ueb-schritte').value .split('\n').map(s => s.trim()).filter(Boolean); const tipp = form.querySelector('.adm-ueb-tipp').value.trim(); btn.disabled = true; btn.textContent = 'Speichert…'; try { await API.put(`/training/exercises/${id}`, { beschreibung, schritte: JSON.stringify(schritte), tipp, }); UI.toast.success('Übung gespeichert.'); form.style.display = 'none'; } catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); } finally { btn.disabled = false; btn.textContent = 'Speichern'; } }); }); } // ------------------------------------------------------------------ // ------------------------------------------------------------------ // TAB: REFERRALS // ------------------------------------------------------------------ async function _renderReferrals(el) { el.innerHTML = `
Lade…
`; let d; try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `
Fehler beim Laden.
`; return; } const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0; const topRows = d.top_referrers.map((r, i) => ` ${i + 1} ${UI.escape(r.name)} ${UI.escape(r.email)} ${r.invited_count} `).join(''); const recentRows = d.recent_invites.slice(0, 50).map(r => ` ${UI.escape(r.name)} ${UI.escape(r.referrer_name)} ${(r.created_at || '').slice(0, 10)} `).join(''); el.innerHTML = `
${d.total_referred}
Geworbene User
${pct}%
Anteil geworbener User
${d.viral_factor}
Virality Factor
Top Werber
${topRows || ''}
# Name E-Mail Eingeladen
Noch keine Empfehlungen
Zuletzt geworbene User
${recentRows || ''}
User Geworben von Datum
Noch keine Daten
`; } // ------------------------------------------------------------------ // TAB: UPGRADES // ------------------------------------------------------------------ async function _renderUpgrades(el) { const rows = await API.get('/admin/upgrade-requests'); const tierBadge = t => { const cfg = { pro: ['Pro', '#16a34a'], breeder: ['Züchter', '#C4843A'] }; const [label, color] = cfg[t] || [t, '#888']; return `${label}`; }; const pending = rows.filter(r => !r.fulfilled_at); const done = rows.filter(r => r.fulfilled_at); // Offene Anfragen als Cards (mobile-freundlich, Button immer sichtbar) const _pendingCard = r => `
${UI.escape(r.name)}
${UI.escape(r.email)}
${tierBadge(r.tier)} ${r.discount_pct > 0 ? ` ${r.discount_pct}% Rabatt` : ''} ${r.created_at?.slice(0,10) || ''}
${r.discount_reason === 'founder' ? `
Gründer — kostenfrei
` : ''} ${r.discount_reason === 'referred_by_founder' ? `
Von Gründer eingeladen
` : ''} ${r.discount_reason === 'referral' ? `
${r.referral_count} Freunde geworben
` : ''} ${r.message ? `
${UI.escape(r.message)}
` : ''}
${r.existing_invoice_id ? ` ` : ` `}
`; // Erledigte als kompakte Tabellenzeilen const _doneRow = r => ` ${UI.escape(r.name)}
${UI.escape(r.email)} ${tierBadge(r.tier)} ✓ ${r.fulfilled_at?.slice(0,10) || ''} `; el.innerHTML = `
Offene Anfragen (${pending.length})
${pending.length ? pending.map(_pendingCard).join('') : `
Keine offenen Anfragen
`}
${done.length ? `
Erledigt (${done.length})
${['Nutzer','Tarif','Freigegeben'].map(h => `` ).join('')} ${done.map(_doneRow).join('')}
${h}
` : ''}`; el.querySelectorAll('.adm-fulfill-btn').forEach(btn => { btn.addEventListener('click', async () => { const { id, name, tier } = btn.dataset; const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier; const ok = await UI.modal.confirm({ title: `${name} auf ${tierLabel} freischalten?`, message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`, confirmText: 'Freischalten', danger: false, }); if (!ok) return; btn.disabled = true; btn.textContent = '…'; try { const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`); if (res.invoice_number) { UI.toast.success( `${res.user} freigeschaltet · Entwurf ${res.invoice_number} unter Rechnungen versenden`, 6000 ); } else { UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); } _renderTab(); _renderActionItems(); } catch (e) { UI.toast.error(e.message); btn.disabled = false; btn.textContent = 'Freischalten'; } }); }); // "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten const TIER_ITEMS = { pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 }, breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 }, }; const _year = new Date().getFullYear(); const _now = new Date(); const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1); const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; const _period = `${_fmt(_now)} - ${_fmt(_end)}`; function _discountNote(reason, count, pct, tierLabel) { const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.'; if (reason === 'founder') return `Gründer-Sonderkonditionen: ${tierLabel} kostenfrei als Dankeschön für deine Unterstützung als Gründer! ${agb}`; if (reason === 'referred_by_founder') return `Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied ist dein Jahresabo dauerhaft kostenfrei. ${agb}`; if (reason === 'referral') return `Herzlichen Dank für deine Unterstützung! Für ${count} geworbene Freunde erhältst du ${pct}% Rabatt. ${agb}`; return agb; } el.querySelectorAll('.adm-invoice-btn').forEach(btn => { btn.addEventListener('click', () => { const { name, email, tier, address } = btn.dataset; const discountPct = Number(btn.dataset.discount) || 0; const discountReason = btn.dataset.discountReason || ''; const referralCount = Number(btn.dataset.referralCount) || 0; const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 }; _openNeueRechnungModal(() => { _tab = 'rechnungen'; _renderTab(); }, { recipient_name: name, recipient_email: email, recipient_address: address || '', service_period: _period, discount_pct: discountPct, notes: _discountNote(discountReason, referralCount, discountPct, tierItem.description), items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }], }); }); }); // "Rechnung bearbeiten" — lädt existierenden Entwurf/Sent-Rechnung im Edit-Modus el.querySelectorAll('.adm-invoice-edit-btn').forEach(btn => { btn.addEventListener('click', async () => { try { const inv = await API.get(`/admin/invoices/${btn.dataset.invoiceId}`); _openNeueRechnungModal(() => { // Nach Speichern/Stornieren: zurück auf Upgrades-Tab, damit der Button neu rendert _renderTab(); }, { recipient_name: inv.recipient_name, recipient_email: inv.recipient_email, recipient_address: inv.recipient_address || '', service_period: inv.service_period || '', discount_pct: inv.discount_pct || 0, notes: inv.notes || '', items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })), }, inv.id, inv.status, inv.invoice_number); } catch (e) { UI.toast.error(e.message || 'Rechnung konnte nicht geladen werden.'); } }); }); } // ------------------------------------------------------------------ // TAB: RECHNUNGEN // ------------------------------------------------------------------ async function _renderRechnungen(el) { let _subView = 'liste'; // 'liste' | 'cashflow' async function _load() { el.innerHTML = `
${_subView === 'liste' ? ` ` : ''}
Lade…
`; el.querySelectorAll('.adm-inv-nav').forEach(btn => { btn.addEventListener('click', () => { _subView = btn.dataset.v; _load(); }); }); el.querySelector('#adm-inv-new')?.addEventListener('click', () => _openNeueRechnungModal(_load)); const content = el.querySelector('#adm-inv-content'); if (_subView === 'liste') { await _loadInvoiceList(content, _load); } else { await _loadCashflow(content); } } await _load(); } async function _loadInvoiceList(el, reload) { let invoices; try { invoices = await API.get('/admin/invoices'); } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Rechnungen konnten nicht geladen werden.'); return; } if (!invoices.length) { el.innerHTML = _emptyState('receipt', 'Keine Rechnungen', 'Noch keine Rechnungen erstellt.'); return; } const _statusBadge = status => { const cfg = { draft: ['Entwurf', 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'], sent: ['Versendet', 'var(--c-primary)', 'var(--c-primary-subtle,#eff6ff)','var(--c-primary)'], paid: ['Bezahlt', 'var(--c-success,#16a34a)','#d1fae5', 'var(--c-success,#16a34a)'], cancelled: ['Storniert', 'var(--c-danger,#dc2626)', '#fee2e2', 'var(--c-danger,#dc2626)'], }; const [label, color, bg, border] = cfg[status] || [status, 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)']; return `${label}`; }; const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; const rows = invoices.map((inv, i) => { const actions = []; if (inv.status === 'draft') { actions.push(``); actions.push(``); } if (inv.status === 'sent') { actions.push(``); } if (inv.status === 'sent') { actions.push(``); actions.push(``); } if (inv.status === 'paid' || inv.status === 'cancelled') { actions.push(``); } return ` ${UI.escape(inv.invoice_number)}
${UI.escape(inv.recipient_name)}
${UI.escape(inv.recipient_email || '')}
${_fmtEur(inv.amount_gross)} ${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01 ? `
erhalten: ${_fmtEur(inv.paid_amount)} ${inv.paid_amount < inv.amount_gross ? `-${_fmtEur(inv.amount_gross - inv.paid_amount)}` : ''}
` : ''} ${_statusBadge(inv.status)} ${_fmtDate(inv.created_at)}
${actions.join('')}
`; }).join(''); el.innerHTML = `
${rows}
Nummer Empfänger Betrag Status Erstellt
`; // Senden el.querySelectorAll('.adm-inv-send').forEach(btn => { btn.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title: `Rechnung ${btn.dataset.num} versenden?`, message: 'Die Rechnung wird als PDF erzeugt und per E-Mail an den Empfänger versendet.', confirmText: 'Jetzt versenden', }); if (!ok) return; btn.disabled = true; try { await API.post(`/admin/invoices/${btn.dataset.id}/send`, {}); UI.toast.success('Rechnung versendet.'); reload(); } catch (e) { UI.toast.error(e.message || 'Fehler beim Versenden.'); btn.disabled = false; } }); }); // Entwurf bearbeiten el.querySelectorAll('.adm-inv-edit').forEach(btn => { btn.addEventListener('click', async () => { const inv = await API.get(`/admin/invoices/${btn.dataset.id}`); _openNeueRechnungModal(reload, { recipient_name: inv.recipient_name, recipient_email: inv.recipient_email, recipient_address: inv.recipient_address || '', service_period: inv.service_period || '', discount_pct: inv.discount_pct || 0, notes: inv.notes || '', items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })), }, inv.id, inv.status, inv.invoice_number); }); }); // Als bezahlt markieren el.querySelectorAll('.adm-inv-pay').forEach(btn => { btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload)); }); // Stornieren el.querySelectorAll('.adm-inv-cancel').forEach(btn => { btn.addEventListener('click', () => _openStornoModal(btn.dataset.id, btn.dataset.num, reload)); }); // Details el.querySelectorAll('.adm-inv-detail').forEach(btn => { btn.addEventListener('click', () => _openDetailModal(btn.dataset.id)); }); } function _openNeueRechnungModal(reload, prefill = null, invoiceId = null, status = null, invoiceNumber = null) { const id = `inv-new-${Date.now()}`; const p = prefill || {}; const isEdit = !!invoiceId; const isLocked = isEdit && status && status !== 'draft'; // sent / paid / cancelled → nicht änderbar const canCancel = isEdit && status !== 'cancelled'; // alles außer schon storniert const lockedBanner = isLocked ? (() => { const map = { sent: ['#fff8f0', '#f0a060', '#c05000', 'Diese Rechnung wurde bereits versendet — Inhalt nicht mehr änderbar. Korrekturen nur per Stornierung.'], paid: ['#f0fdf4', '#86efac', '#15803d', 'Diese Rechnung ist bezahlt — Inhalt nicht änderbar.'], cancelled: ['#fef2f2', '#fca5a5', '#b91c1c', 'Diese Rechnung wurde storniert — Inhalt nicht änderbar.'], }; const [bg, br, fg, msg] = map[status] || ['#f5f5f5', '#ccc', '#444', `Status: ${status}`]; return `
${msg}
`; })() : ''; UI.modal.open({ title: `${UI.icon('receipt')} ${isEdit ? (isLocked ? 'Rechnung ansehen' : 'Rechnung bearbeiten') : 'Neue Rechnung erstellen'}`, body: `
${lockedBanner} ${!isEdit && !p.recipient_name ? `
Diese Rechnung ist für sonstige Leistungen (Beratung, Einmalleistung etc.).
Für Abo-Verlängerungen bitte den Button „Rechnung erstellen" in der Upgrades-Liste verwenden.
` : ''}
Netto: —
`, footer: `
${isLocked ? `` : `
`} ${canCancel ? ` ` : ''}
`, }); // Bei gesperrten Rechnungen (sent/paid/cancelled) alle Eingaben & Action-Buttons readonly if (isLocked) { setTimeout(() => { const form = document.getElementById(id); if (!form) return; form.querySelectorAll('input, textarea').forEach(inp => { inp.disabled = true; }); // "+ Position hinzufügen" und Item-Lösch-Buttons verstecken const addBtn = document.getElementById(`${id}-add-item`); if (addBtn) addBtn.style.display = 'none'; form.querySelectorAll('.inv-item-remove').forEach(b => { b.style.display = 'none'; }); }, 0); } // Stornieren — schließt dieses Modal, öffnet den Storno-Dialog document.getElementById(`${id}-cancel-invoice`)?.addEventListener('click', () => { // _openStornoModal ruft intern UI.modal.open() → schließt dieses Modal automatisch _openStornoModal(invoiceId, invoiceNumber || `#${invoiceId}`, reload); }); // Items-Container und Hilfsfunktionen const itemsContainer = document.getElementById(`${id}-items`); const previewEl = document.getElementById(`${id}-preview`); const discountEl = document.getElementById(`${id}-discount`); function _addItem(desc = '', qty = 1, price = 0) { const itemEl = document.createElement('div'); itemEl.className = 'adm-inv-item-row'; itemEl.style.cssText = 'display:grid;grid-template-columns:1fr 60px 100px auto;gap:var(--space-2);align-items:center'; itemEl.innerHTML = ` `; itemEl.querySelector('.inv-item-remove').addEventListener('click', () => { if (itemsContainer.querySelectorAll('.adm-inv-item-row').length > 1) { itemEl.remove(); _updatePreview(); } }); itemEl.querySelectorAll('input').forEach(inp => inp.addEventListener('input', _updatePreview)); itemsContainer.appendChild(itemEl); _updatePreview(); } function _updatePreview() { let netto = 0; itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 0; const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; netto += qty * price; }); const disc = Math.min(100, Math.max(0, parseFloat(discountEl?.value) || 0)); const rabatt = netto * disc / 100; const brutto = netto - rabatt; previewEl.innerHTML = ` Netto: ${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} € ${disc > 0 ? ` · -${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)` : ''}  · Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} € `; } // Erste Position — aus Prefill oder Standard if (p.items && p.items.length) { p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0)); } else { _addItem('Ban Yaro Pro Jahresabo', 1, 29.00); } // Weitere Position document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem()); discountEl?.addEventListener('input', _updatePreview); // Form Submit document.getElementById(id)?.addEventListener('submit', async e => { e.preventDefault(); if (isLocked) return; // gesperrte Rechnung — Submit ignorieren (Button ist eh ausgeblendet) const fd = new FormData(e.target); const items = []; itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { const desc = row.querySelector('.inv-item-desc').value.trim(); const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 1; const price = parseFloat(row.querySelector('.inv-item-price').value) || 0; if (desc) items.push({ description: desc, quantity: qty, unit_price: price }); }); if (!items.length) { UI.toast.warning('Mindestens eine Position angeben.'); return; } const submitBtn = e.target.closest('.modal-content, [id]') ? document.querySelector(`button[form="${id}"]`) : null; if (submitBtn) submitBtn.disabled = true; try { const payload = { recipient_name: fd.get('recipient_name'), recipient_email: fd.get('recipient_email') || null, recipient_address: fd.get('recipient_address') || null, service_period: fd.get('service_period') || null, discount_pct: parseFloat(fd.get('discount_pct')) || 0, notes: fd.get('notes') || null, items, }; if (isEdit) { await API.patch(`/admin/invoices/${invoiceId}`, payload); } else { await API.post('/admin/invoices', payload); } UI.modal.close(); UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.'); reload(); } catch (err) { UI.toast.error(err.message || 'Fehler beim Erstellen.'); if (submitBtn) submitBtn.disabled = false; } }); } function _openBezahltModal(invoiceId, defaultAmount, reload) { const today = new Date().toISOString().slice(0, 10); const id = `inv-pay-${Date.now()}`; UI.modal.open({ title: `${UI.icon('check-circle')} Als bezahlt markieren`, body: `
`, footer: ` `, }); // Differenz live anzeigen const amtEl = document.getElementById(`${id}-amt`); const diffEl = document.getElementById(`${id}-diff`); const _checkDiff = () => { const entered = parseFloat(amtEl?.value) || 0; const diff = defaultAmount - entered; if (Math.abs(diff) < 0.01) { diffEl.style.display = 'none'; return; } diffEl.style.display = 'block'; if (diff > 0) { diffEl.innerHTML = `Differenz: -${diff.toFixed(2)} € weniger als fakturiert.
`; } else { diffEl.innerHTML = `Überzahlung: +${(-diff).toFixed(2)} € mehr eingegangen.`; diffEl.style.background = '#f0fff8'; diffEl.style.borderColor = '#34d399'; diffEl.style.color = '#065f46'; } }; amtEl?.addEventListener('input', _checkDiff); document.getElementById(id)?.addEventListener('submit', async e => { e.preventDefault(); const fd = new FormData(e.target); const paidAmount = parseFloat(fd.get('paid_amount')); const diff = defaultAmount - paidAmount; const kulanz = diff > 0.01 && document.getElementById(`${id}-kulanz`)?.checked; const submitBtn = document.querySelector(`button[form="${id}"]`); if (submitBtn) submitBtn.disabled = true; try { const kulanzNote = kulanz ? `Forderungsverlust/Kulanz: ${diff.toFixed(2)} EUR nicht eingegangen (${fd.get('paid_at')}). Als Kulanz abgeschrieben.` : null; await API.post(`/admin/invoices/${invoiceId}/pay`, { paid_at: fd.get('paid_at'), paid_amount: paidAmount, ...(kulanzNote ? { notes: kulanzNote } : {}), }); UI.modal.close(); UI.toast.success(kulanz ? `Bezahlt (${paidAmount.toFixed(2)} €) · ${diff.toFixed(2)} € als Kulanz notiert.` : 'Rechnung als bezahlt markiert.'); reload(); } catch (err) { UI.toast.error(err.message || 'Fehler.'); if (submitBtn) submitBtn.disabled = false; } }); } function _openStornoModal(invoiceId, invoiceNum, reload) { const id = `inv-cancel-${Date.now()}`; UI.modal.open({ title: `${UI.icon('x-circle')} Rechnung stornieren`, body: `

Rechnung ${UI.escape(invoiceNum)} stornieren.

`, footer: ` `, }); document.getElementById(id)?.addEventListener('submit', async e => { e.preventDefault(); const fd = new FormData(e.target); const reason = (fd.get('reason') || '').trim(); if (!reason) { UI.toast.warning('Bitte einen Grund angeben.'); return; } const submitBtn = document.querySelector(`button[form="${id}"]`); if (submitBtn) submitBtn.disabled = true; try { await API.post(`/admin/invoices/${invoiceId}/cancel`, { reason }); UI.modal.close(); UI.toast.success('Rechnung storniert.'); reload(); } catch (err) { UI.toast.error(err.message || 'Fehler.'); if (submitBtn) submitBtn.disabled = false; } }); } async function _openDetailModal(invoiceId) { let inv; try { inv = await API.get(`/admin/invoices/${invoiceId}`); } catch (e) { UI.toast.error(e.message || 'Detail konnte nicht geladen werden.'); return; } const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—'; const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)', }; const statusLabels = { draft: 'Entwurf', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; const itemsHtml = (inv.items || []).map(item => ` ${UI.escape(item.description)} ${item.quantity} ${_fmtEur(item.unit_price)} ${_fmtEur(item.total)} `).join(''); UI.modal.open({ title: `${UI.icon('receipt')} ${UI.escape(inv.invoice_number)}`, body: `
Empfänger
${UI.escape(inv.recipient_name)}
${inv.recipient_email ? `
${UI.escape(inv.recipient_email)}
` : ''} ${inv.recipient_address ? `
${UI.escape(inv.recipient_address)}
` : ''}
Status
${statusLabels[inv.status] || inv.status}
Erstellt: ${_fmtDate(inv.created_at)}
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}
` : ''} ${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}
` : ''}
${inv.service_period ? `
Leistungszeitraum
${UI.escape(inv.service_period)}
` : ''}
Positionen
${itemsHtml}
Beschreibung Menge Preis Gesamt
Gesamt (brutto) ${_fmtEur(inv.amount_gross)}
${inv.notes ? `
Notizen
${UI.escape(inv.notes)}
` : ''}
`, footer: ``, }); } async function _loadCashflow(el) { let cf; try { cf = await API.get('/admin/invoices/cashflow'); } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Cashflow konnte nicht geladen werden.'); return; } const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—'; const statusLabels = { draft: 'Entwürfe', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' }; const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)' }; const countKacheln = Object.entries(cf.counts || {}).map(([s, n]) => `
${n}
${statusLabels[s] || s}
`).join(''); const monthRows = (cf.monthly || []).map((m, i) => ` ${UI.escape(m.month)} ${m.count} ${_fmtEur(m.revenue)} `).join(''); // Quartalsbericht-Download const currentYear = new Date().getFullYear(); const years = [currentYear, currentYear - 1].map(y => ``).join(''); el.innerHTML = `
${_fmtEur(cf.total_paid)}
Einnahmen (bezahlt)
${_fmtEur(cf.total_outstanding)}
Offene Forderungen
${_fmtEur(cf.total_year)}
Jahresumsatz gesamt
${countKacheln}
Monatliche Übersicht
${monthRows || ``}
Monat Rechnungen Umsatz
Keine Daten
${UI.icon('file-csv')} Quartalsbericht herunterladen
`; // CSV Download el.querySelector('#adm-inv-csv')?.addEventListener('click', async () => { const year = el.querySelector('#adm-inv-year').value; const q = el.querySelector('#adm-inv-quarter').value; try { const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); if (!data.invoices?.length) { UI.toast.warning('Keine Rechnungen in diesem Quartal.'); return; } // CSV generieren const fmtEur = v => v != null ? Number(v).toFixed(2) : '0.00'; const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : ''; const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' }; const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag (eingegangen);Rechnungsbetrag;Status;Versendet am;Zahlungseingang\n'; const csvRows = data.invoices.map(inv => { const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross; return [ inv.invoice_number, inv.recipient_name, inv.recipient_email || '', fmtDate(inv.created_at), inv.service_period || '', fmtEur(effectiveAmt), fmtEur(inv.amount_gross), statusLabel[inv.status] || inv.status, fmtDate(inv.sent_at), fmtDate(inv.paid_at) ].map(escape).join(';'); }).join('\n'); const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `banyaro-rechnungen-${year}-Q${q}.csv`; a.click(); URL.revokeObjectURL(url); UI.toast.success(`CSV mit ${data.invoices.length} Rechnungen heruntergeladen.`); } catch (e) { UI.toast.error(e.message || 'Fehler beim Laden.'); } }); // Quartals-Vorschau el.querySelector('#adm-inv-preview-q')?.addEventListener('click', async () => { const year = el.querySelector('#adm-inv-year').value; const q = el.querySelector('#adm-inv-quarter').value; const resultEl = el.querySelector('#adm-inv-q-result'); resultEl.innerHTML = '
Lade…
'; try { const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`); if (!data.invoices?.length) { resultEl.innerHTML = `
Keine Rechnungen in ${data.period || `Q${q} ${year}`}.
`; return; } const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—'; const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—'; const sL = { draft:'Entwurf', sent:'Versendet', paid:'Bezahlt', cancelled:'Storniert (Orig.)', storno:'Stornorechnung' }; const rows2 = data.invoices.map((inv, i) => { const isStorno = inv.status === 'storno'; const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross; const amtColor = isStorno ? 'color:var(--c-danger)' : (effectiveAmt < 0 ? 'color:var(--c-danger)' : ''); const amtNote = (inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01) ? ` (RG: ${_fmtE(inv.amount_gross)})` : ''; return ` ${UI.escape(inv.invoice_number)} ${UI.escape(inv.recipient_name)} ${_fmtE(effectiveAmt)}${amtNote} ${sL[inv.status]||inv.status} ${_fmtD(inv.created_at)} `; }).join(''); resultEl.innerHTML = `
${UI.escape(data.period || `Q${q} ${year}`)} — ${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)}
${rows2}
NummerEmpfänger BetragStatus Erstellt
Gesamt ${_fmtE(data.total_gross)} Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)}
`; } catch (e) { resultEl.innerHTML = `
Fehler: ${UI.escape(e.message)}
`; } }); } return { init, refresh, onDogChange }; })();