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

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

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

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

4632 lines
231 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — 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 = `
<!-- Action Items -->
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
<!-- Sidebar + Content (Desktop: nebeneinander) -->
<div class="adm-shell">
<div class="by-tabs adm-tabs" id="adm-tabs">
${TABS.map(t => `
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
${UI.icon(t.icon)} ${t.label}
</button>
`).join('')}
</div>
<div id="adm-content"></div>
</div>
`;
_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' },
];
const open = items.filter(i => d[i.key] > 0);
const usersToday = d.users_today || 0;
el.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${open.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: open.map(i => `
<button data-action-tab="${i.tab}"
style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700;cursor:pointer">
${UI.icon(i.icon)} ${i.label}
<span style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">
${d[i.key]}
</span>
</button>`).join('')
}
<span style="margin-left:auto;font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
</span>
</div>`;
// 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 = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
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 => `
<tr>
<td style="font-weight:600">${_esc(m.name)}</td>
<td class="text-right">${m.published}</td>
<td class="text-right">${m.with_link}
${m.published > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">
(${Math.round(m.with_link/m.published*100)}%)</span>` : ''}</td>
<td class="text-right">${m.scheduled}</td>
<td class="text-right">${m.ideas}</td>
<td style="text-align:right;color:var(--c-text-muted)">${m.total}</td>
</tr>`).join('');
// Plattform-Balken
const maxPlat = Math.max(...d.by_platform.map(p => p.n), 1);
const platBars = d.by_platform.map(p => `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
<div style="width:120px;flex-shrink:0;font-size:var(--text-sm)">${_PL[p.platform]||p.platform}</div>
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
<div style="width:${Math.round(p.n/maxPlat*100)}%;height:100%;
background:var(--c-primary);border-radius:var(--radius-full)"></div>
</div>
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${p.n}</div>
</div>`).join('');
// Monats-Timeline
const maxMonth = Math.max(...d.by_month.map(m => m.n), 1);
const monthBars = [...d.by_month].reverse().map(m => `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
<div style="width:55px;flex-shrink:0;font-size:11px;color:var(--c-text-muted)">${m.monat}</div>
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
<div style="width:${Math.round(m.n/maxMonth*100)}%;height:100%;
background:var(--c-success);border-radius:var(--radius-full)"></div>
</div>
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${m.n}</div>
</div>`).join('');
// Veröffentlichte Posts (mit/ohne Link)
const postRows = d.recent_published.map(p => `
<tr>
<td style="color:var(--c-text-muted);white-space:nowrap">${_fmt(p.published_at)}</td>
<td style="font-weight:500;max-width:200px;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap">${_esc(p.topic)}</td>
<td>${_PL[p.platform]||p.platform||''}</td>
<td style="font-size:10px;color:var(--c-text-muted)">${_esc(p.category||'')}</td>
<td>${p.ai_score ? '⭐'.repeat(p.ai_score) : ''}</td>
<td style="font-weight:500">${_esc(p.manager||'')}</td>
<td>${p.post_url
? `<a href="${_esc(p.post_url)}" target="_blank" rel="noopener"
style="font-size:11px;color:var(--c-primary)">🔗 Link</a>`
: `<span style="font-size:11px;color:var(--c-text-muted)"></span>`}</td>
</tr>`).join('');
el.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);margin-bottom:var(--space-4)">
<!-- Plattform -->
<div class="card p-4">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
Veröffentlicht nach Plattform
</div>
${platBars || '<div class="text-sm-muted">Noch keine Posts</div>'}
</div>
<!-- Timeline -->
<div class="card p-4">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
Posts pro Monat
</div>
${monthBars || '<div class="text-sm-muted">Noch keine Posts</div>'}
</div>
</div>
<!-- Manager -->
${d.managers.length ? `
<div class="card adm-table-card mb-4">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
border-bottom:1px solid var(--c-border)">Manager-Übersicht</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr>
<th>Manager</th>
<th class="text-right">Veröffentlicht</th>
<th class="text-right">Mit Link</th>
<th class="text-right">Geplant</th>
<th class="text-right">Ideen</th>
<th class="text-right">Gesamt</th>
</tr></thead>
<tbody>${managerRows}</tbody>
</table>
</div>
</div>` : ''}
<!-- Alle veröffentlichten Posts -->
<div class="card adm-table-card">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
border-bottom:1px solid var(--c-border)">
Veröffentlichte Posts (letzte 50)
</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr>
<th>Datum</th><th>Thema</th><th>Plattform</th>
<th>Kategorie</th><th>Score</th><th>Manager</th><th>Link</th>
</tr></thead>
<tbody>${postRows || `<tr><td colspan="7" style="text-align:center;
color:var(--c-text-muted);padding:var(--space-6)">Noch keine Posts</td></tr>`}</tbody>
</table>
</div>
</div>`;
}
// ------------------------------------------------------------------
// TAB: ANALYTICS
async function _renderAnalytics(el) {
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade Analytics…</div>`;
let d;
try { d = await API.get('/admin/analytics'); }
catch (err) {
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger);font-size:var(--text-sm)">
${UI.icon('warning')} Fehler: ${_esc(err.message || String(err))}</div>`;
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 `<p style="color:var(--c-text-muted);font-size:var(--text-xs);margin:0">Keine Daten</p>`;
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 `<text x="${x.toFixed(0)}" y="${H+14}" text-anchor="middle"
font-size="10" fill="currentColor" class="text-muted">${date}</text>`;
}).join('');
return `<svg viewBox="0 0 ${W} ${H+18}" style="width:100%;height:100px;display:block;overflow:visible"
preserveAspectRatio="none">
<defs>
<linearGradient id="pvGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.25"/>
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
</linearGradient>
</defs>
<path d="${areaPath}" fill="url(#pvGrad)"/>
<polyline points="${pvLine}" fill="none" stroke="var(--c-primary)"
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<polyline points="${sesLine}" fill="none" stroke="var(--c-success)"
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="4,2"/>
${labels}
</svg>`;
}
// Balken-Chart für Top-Pages / Referrers
function _barChart(items, labelKey = 'x', valKey = 'y') {
if (!items?.length) return `<p class="text-sm-muted">Keine Daten</p>`;
const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1);
return `<div class="flex-col-gap-2">
${items.map(p => {
const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(0);
return `<div>
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);margin-bottom:3px">
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:78%">${_esc(p[labelKey] || '—')}</span>
<span style="color:var(--c-text-secondary);flex-shrink:0;margin-left:var(--space-2)">${fmt(p[valKey] ?? 0)}</span>
</div>
<div style="height:4px;border-radius:2px;background:var(--c-surface-2)">
<div style="height:100%;width:${pct}%;border-radius:2px;background:var(--c-primary);transition:width .4s"></div>
</div>
</div>`;
}).join('')}
</div>`;
}
el.innerHTML = `
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Stat-Karten -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:var(--space-3)">
${_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)')}
</div>
<!-- Verlaufschart 30 Tage -->
<div class="card p-4">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Verlauf — letzte 30 Tage</span>
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:2px;background:var(--c-primary);display:inline-block;border-radius:1px"></span> Aufrufe
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:2px;background:var(--c-success);display:inline-block;border-radius:1px;
border-bottom:2px dashed var(--c-success);background:none"></span> Besucher
</span>
</div>
</div>
${_dualChart(pv, ses)}
</div>
<!-- Jahres-Übersicht -->
<div class="card p-4">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Jahresübersicht — letzte 12 Monate</span>
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:10px;background:var(--c-primary);opacity:0.7;display:inline-block;border-radius:2px"></span> Seitenaufrufe
</span>
<span style="display:flex;align-items:center;gap:4px">
<span style="width:12px;height:10px;background:var(--c-success);opacity:0.7;display:inline-block;border-radius:2px"></span> Neuanmeldungen
</span>
</div>
</div>
${(() => {
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 `
<rect x="${x}" y="${H - 20 - pvH}" width="${barW}" height="${pvH}"
fill="var(--c-primary)" opacity="0.65" rx="2"/>
<rect x="${x + Math.floor(barW*0.55)}" y="${H - 20 - regH}" width="${Math.floor(barW*0.4)}" height="${regH}"
fill="var(--c-success)" opacity="0.8" rx="2"/>
<text x="${x + barW/2}" y="${H - 4}" text-anchor="middle"
font-size="9" fill="currentColor" class="text-muted">${label}</text>
`;
}).join('');
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:120px;display:block;overflow:visible"
preserveAspectRatio="none">${bars}</svg>`;
})()}
</div>
<!-- Top Seiten + Referrers nebeneinander -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4)">
<div class="card p-4">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-3)">Top Seiten 30 Tage</div>
${_barChart(d.top_pages)}
</div>
<div class="card p-4">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
margin-bottom:var(--space-3)">Referrers 30 Tage</div>
${_barChart(d.referrers)}
</div>
</div>
</div>`;
}
// ------------------------------------------------------------------
// 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 `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md)">
<span style="width:8px;height:8px;border-radius:50%;background:var(--c-text-muted);flex-shrink:0"></span>
<span class="text-xs-muted">KI-Modus: <strong>off</strong></span>
</div>`;
}
const dot = ki.local_reachable ? 'var(--c-success)' : 'var(--c-danger)';
const label = ki.local_reachable ? 'Lokal erreichbar' : 'Nicht erreichbar';
const model = ki.local_model_loaded || ki.local_model_config || '?';
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);flex-wrap:wrap">
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0;
box-shadow:0 0 4px ${dot}"></span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:600">${label}</span>
<span class="text-xs-muted">·</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-family:monospace">${_esc(model)}</span>
<span style="margin-left:auto;font-size:10px;padding:1px 6px;border-radius:10px;
background:var(--c-surface);color:var(--c-text-muted);border:1px solid var(--c-border)">
Modus: ${ki.local_reachable ? 'local' : 'cloud'}
</span>
</div>`;
};
el.innerHTML = `
<div class="adm-stats-grid" id="adm-overview-grid">
${_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')}
</div>
<div class="card p-4">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<p style="font-size:var(--text-sm);font-weight:600;margin:0;display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
KI-Nutzung
</p>
<span style="font-size:var(--text-xs);font-weight:700;padding:2px 10px;border-radius:100px;
background:var(--c-primary-subtle);color:var(--c-primary)">
${s.ki_today ?? 0} heute · ${s.ki_week ?? 0} (7 Tage)
</span>
</div>
<!-- KI-Status Badge -->
${_kiStatusBadge()}
<!-- Quellen-Zeile -->
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
${[
['☁️ 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]) => `
<span style="font-size:var(--text-xs);padding:2px 8px;border-radius:100px;
background:var(--c-surface-2);border:1px solid var(--c-border);white-space:nowrap">
<span class="text-muted">${label}</span>
<strong style="color:${color};margin-left:3px">${val ?? 0}</strong>
</span>`).join('')}
</div>
<!-- Sparkline (30 Tage) -->
${(() => {
const hist = kiH?.daily_history || [];
if (!hist.length) return '<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-3)">Noch keine Verlaufsdaten</p>';
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 `<div class="mb-3">
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:60px;display:block" preserveAspectRatio="none">
<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="1.5" stroke-linejoin="round"/>
</svg>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-text-muted);margin-top:2px">
<span>${first}</span><span>30 Tage</span><span>${last}</span>
</div>
</div>`;
})()}
<!-- Limit-Hinweis -->
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-3)">
Cloud-Limit: <strong>${s.ki_cloud_weekly_limit ?? 20} Anfragen / Woche</strong> pro User
</p>
<!-- Top-Nutzer -->
${(kiH?.top_users || []).length ? `
<p style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);margin:0 0 var(--space-2);text-transform:uppercase;letter-spacing:.04em">Aktivste Nutzer</p>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr>
<th>Name</th><th>E-Mail</th><th>☁️ Cloud</th><th>Gesamt</th><th>Zuletzt</th>
</tr></thead>
<tbody>
${(kiH.top_users).map(u => `<tr>
<td style="font-weight:600">${_esc(u.name)}</td>
<td class="text-muted">${_esc(u.email.length > 22 ? u.email.split('@')[1] : u.email)}</td>
<td style="color:var(--c-primary);font-weight:600">${u.cloud}</td>
<td>${u.total}</td>
<td class="text-muted">${u.last_date?.slice(5) || '—'}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : ''}
</div>
${(() => {
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]) => `
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
<span class="text-secondary">${label(type)}</span>
<span style="font-weight:600">${count.toLocaleString('de')}</span>
</div>`;
const userByType = s.user_poi_by_type || {};
const userTotal = s.user_poi_total ?? 0;
return `
<div class="card p-4">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
OSM-Cache nach Typ <span style="color:var(--c-text-muted);font-weight:400">— ${(s.osm_total || 0).toLocaleString('de')} gecacht</span>
</p>
<div class="flex-col-gap-2">
${Object.entries(s.osm_by_type).map(row).join('')}
</div>
</div>
<div class="card p-4">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
Nutzer-POIs nach Typ <span style="color:var(--c-text-muted);font-weight:400">— ${userTotal.toLocaleString('de')} gesamt</span>
</p>
<div class="flex-col-gap-2">
${Object.keys(userByType).length
? Object.entries(userByType).map(row).join('')
: '<div style="color:var(--c-text-muted);font-size:var(--text-sm);text-align:center;padding:var(--space-2)">Noch keine Nutzer-POIs</div>'}
</div>
</div>`;
})()}
<!-- Social Media Tracking -->
<div class="card p-4">
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
📱 Social Media Tracking</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);
margin-bottom:var(--space-3)">
${[
['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]) => `
<div style="background:var(--c-surface-2);border-radius:8px;padding:10px">
<div style="font-size:10px;color:var(--c-text-muted);margin-bottom:2px">${label}</div>
<div style="font-size:1.4em;font-weight:700;color:${color}">${val ?? 0}</div>
</div>`).join('')}
</div>
${Object.keys(s.social_by_cat||{}).length ? `
<div class="mb-3">
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
font-weight:600;text-transform:uppercase">Kategorien</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
${Object.entries(s.social_by_cat).map(([cat, n]) => `
<span style="background:var(--c-surface-2);border-radius:20px;
padding:2px 10px;font-size:11px">
${cat} <strong>${n}</strong></span>`).join('')}
</div>
</div>` : ''}
${s.social_recent?.length ? `
<div>
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
font-weight:600;text-transform:uppercase">Letzte 10 Posts</div>
<div style="display:flex;flex-direction:column;gap:6px">
${s.social_recent.map(p => `
<div style="display:flex;align-items:center;gap:8px;
font-size:var(--text-xs);padding:6px 0;
border-bottom:1px solid var(--c-border)">
<span style="padding:2px 7px;border-radius:4px;font-size:10px;
background:var(--c-surface-2);flex-shrink:0;
color:${{idea:'var(--c-text-muted)',draft:'var(--c-warning)',
scheduled:'var(--c-primary)',published:'var(--c-success)',
archived:'var(--c-text-muted)'}[p.status]||'inherit'}">
${p.status}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap">${p.topic}</span>
<span style="color:var(--c-text-muted);flex-shrink:0">
${(p.published_at||p.created_at||'').slice(0,10)}</span>
</div>`).join('')}
</div>
</div>` : ''}
</div>
<div class="card p-4">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
<svg class="ph-icon" aria-hidden="true" class="text-primary">
<use href="/icons/phosphor.svg#info"></use>
</svg>
Ersten Admin per SQL setzen:
<code style="background:var(--c-surface-2);padding:2px 6px;border-radius:4px;font-size:var(--text-xs);word-break:break-all;display:inline-block">
UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de';
</code>
</p>
</div>
`;
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 `
<div class="card" ${clickable}>
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div style="font-size:var(--text-2xl);font-weight:var(--weight-bold);color:var(--c-text)">
${value ?? '—'}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${label}</div>
</div>
`;
}
// ------------------------------------------------------------------
// TAB: NUTZER
// ------------------------------------------------------------------
async function _renderUsers(el) {
el.innerHTML = `
<div class="adm-filter-row">
<input id="adm-user-q" type="search" placeholder="Name oder E-Mail…"
class="adm-filter-input">
<select id="adm-user-rolle" class="adm-filter-select">
<option value="">Alle Rollen</option>
<option value="user">user</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
</div>
<div id="adm-user-list">Lade</div>
`;
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 = `
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted)">
${total} Nutzer gefunden
</div>
<div class="flex-col-gap-2">
${users.map(u => `
<div class="card" style="padding:var(--space-3) var(--space-4);
${u.is_banned ? 'opacity:0.6;border-left:3px solid var(--c-danger)' : ''}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar -->
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary)">
${_esc(u.name[0].toUpperCase())}
</div>
<!-- Info -->
<div class="flex-1-min">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(u.name)}
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;border-radius:3px;
background:var(--c-danger);color:#fff;margin-left:4px">
GESPERRT</span>` : ''}
</div>
<div class="text-xs-muted">
${_esc(u.email)} ·
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
${_esc(u.rolle)}
</span>
· <span style="color:${u.subscription_tier && u.subscription_tier !== 'standard' ? 'var(--c-primary)' : 'var(--c-text-muted)'}">
${_esc(u.subscription_tier || 'standard')}
</span>
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
🗺 ${u.route_count} Routen · ${u.total_km} km
· 📍 ${u.poi_count} POIs
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
${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'}
</div>
</div>
<!-- Aktionen -->
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
${u.is_banned
? `<button class="btn btn-sm btn-ghost adm-unban" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperre aufheben" class="text-success">
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock-open"></use></svg>
</button>`
: `<button class="btn btn-sm btn-ghost adm-ban" data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperren" class="text-danger">
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock"></use></svg>
</button>`
}
${isAdmin ? `
<button class="btn btn-sm btn-ghost adm-rolle" data-uid="${u.id}"
data-name="${_esc(u.name)}" data-rolle="${_esc(u.rolle)}"
title="Rolle ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-tier" data-uid="${u.id}"
data-name="${_esc(u.name)}" data-tier="${_esc(u.subscription_tier || 'standard')}"
title="Abo-Stufe ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#star"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-delete" data-uid="${u.id}"
data-name="${_esc(u.name)}" title="Löschen"
class="text-danger">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
` : ''}
</div>
</div>
</div>
`).join('')}
</div>
`;
// 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: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Aktuelle Rolle: <strong>${currentRolle}</strong>
</p>
<div class="flex-col-gap-2">
${rollen.map(r => `
<button class="btn btn-secondary adm-rolle-choice" data-rolle="${r}" form="">
${r === 'admin' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg>` : ''}
${r === 'moderator' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>` : ''}
${r === 'user' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>` : ''}
Als <strong>${r}</strong> setzen
</button>
`).join('')}
</div>
`,
});
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: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Aktuelle Stufe: <strong>${currentTier}</strong>
</p>
<div class="flex-col-gap-2">
${tiers.map(t => `
<button class="btn ${t === currentTier ? 'btn-primary' : 'btn-secondary'} adm-tier-choice"
data-tier="${t}" form="" ${t === currentTier ? 'disabled' : ''}>
${tierLabels[t]}${t === currentTier ? ' ✓' : ''}
</button>
`).join('')}
</div>
`,
});
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 = `
<!-- Unternavigation -->
<div class="adm-subnav">
<button class="btn btn-primary btn-sm adm-forum-nav" data-view="reports" id="adm-fn-reports">
Offene Meldungen
</button>
<button class="btn btn-ghost btn-sm adm-forum-nav" data-view="threads" id="adm-fn-threads">
Alle Threads
</button>
</div>
<div id="adm-forum-content">Lade</div>
`;
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 = '<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>';
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 = `
<div class="flex-col-gap-3">
${reports.map(r => `
<div class="card" style="padding:var(--space-4);
${r.resolved ? 'opacity:0.5' : 'border-left:3px solid var(--c-danger)'}">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div class="flex-1-min">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">
${r.resolved ? '✓ Erledigt · ' : ''}
${_esc(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${_esc(r.melder_name)}</strong>
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm)">
${_esc(r.content_preview)}
</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-sm ${r.resolved ? 'btn-ghost' : 'btn-primary'} adm-resolve-btn"
data-rid="${r.id}" data-resolved="${r.resolved}"
title="${r.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#${r.resolved ? 'arrow-square-out' : 'check'}"></use></svg>
</button>
${!r.resolved ? `
<button class="btn btn-sm btn-ghost adm-del-content"
data-type="${r.target_type}" data-id="${r.target_id}"
title="Inhalt löschen" class="text-danger">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>` : ''}
</div>
</div>
</div>
`).join('')}
</div>
`;
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 = `
<div class="adm-filter-row">
<input id="adm-thread-q" type="search" placeholder="Threads durchsuchen…"
class="adm-filter-input">
<label class="adm-filter-label">
<input type="checkbox" id="adm-show-deleted"> Gelöschte
</label>
</div>
<div id="adm-thread-list">Lade</div>
`;
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 = `
<div class="flex-col-gap-2">
${threads.map(t => `
<div class="card" style="padding:var(--space-3) var(--space-4);
${t.is_deleted ? 'opacity:0.5;border-left:3px solid var(--c-danger)' : ''}
${t.is_locked ? 'border-left:3px solid #f59e0b' : ''}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div class="flex-1-min">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${t.is_deleted ? '<s>' : ''}${_esc(t.titel)}${t.is_deleted ? '</s>' : ''}
</div>
<div class="text-xs-muted">
von ${_esc(t.autor_name)} ·
${t.antworten} Antworten ·
${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''}
</div>
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
${!t.is_deleted ? `
<button class="btn btn-sm btn-ghost adm-pin" data-tid="${t.id}"
data-pinned="${t.is_pinned}" title="${t.is_pinned ? 'Entpinnen' : 'Anpinnen'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#push-pin"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-lock" data-tid="${t.id}"
data-locked="${t.is_locked}" title="${t.is_locked ? 'Entsperren' : 'Sperren'}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#${t.is_locked ? 'lock-open' : 'lock'}"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-del-thread" data-tid="${t.id}"
title="Löschen" class="text-danger">
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
` : `
<button class="btn btn-sm btn-ghost adm-restore-thread" data-tid="${t.id}"
title="Wiederherstellen" class="text-success">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg>
</button>
`}
</div>
</div>
</div>
`).join('')}
</div>
`;
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 = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-sys-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-ors-card"></div>
<div id="adm-sys-cards">Lade</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Medien</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
<button class="btn btn-secondary btn-sm" id="adm-generate-previews">
${UI.icon('images')} Previews generieren (Bestand)
</button>
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Wiki-Daten</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
<button class="btn btn-secondary btn-sm" id="adm-enrichment-status">
${UI.icon('arrows-clockwise')} Enrichment-Status
</button>
<button class="btn btn-secondary btn-sm" id="adm-evaluate-breeds">
${UI.icon('chart-bar')} Qualitätsbewertung (20 Rassen)
</button>
<button class="btn btn-secondary btn-sm" id="adm-fetch-photos">
${UI.icon('image')} Fotos nachladen
</button>
</div>
<div id="adm-maint-result" style="margin-top:var(--space-2);font-size:var(--text-xs);
color:var(--c-text-secondary)"></div>
<div id="adm-eval-result" style="margin-top:var(--space-3);display:none"></div>
</div>
<div style="margin-top:var(--space-5)">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<span style="font-size:var(--text-sm);font-weight:600">Server-Logs</span>
<select id="adm-log-level" class="input" style="width:auto;padding:2px 8px;font-size:var(--text-xs)">
<option value="">Alle</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<button class="btn btn-ghost btn-sm" id="adm-log-refresh">${UI.icon('arrows-clockwise')}</button>
</div>
<div id="adm-log-box" style="
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);font-family:monospace;font-size:11px;
max-height:420px;overflow-y:auto;line-height:1.6">Lade</div>
</div>
`;
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 `<div style="border-bottom:1px solid var(--c-border);padding:2px 0">` +
`<span class="text-muted">${r.t}</span> ` +
`<span style="color:${color};font-weight:600">${r.l}</span> ` +
`<span class="text-secondary">${_esc(r.n)}</span> ` +
`<span>${_esc(r.m)}</span></div>`;
}).join('') || '<span class="text-muted">Keine Einträge</span>';
};
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 12 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 3060s 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 => `<span style="color:${scoreColor(v)};font-weight:600">${v.toFixed(1)}</span>`;
const rows = d.results.filter(r => !r.error).map(r =>
`<tr>
<td style="padding:2px 6px">${_esc(r.name)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.vollstaendigkeit)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.korrektheit)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.sprachqualitaet)}</td>
<td style="text-align:center;padding:2px 6px">${scoreBar(r.konsistenz)}</td>
<td style="text-align:center;padding:2px 6px;font-weight:700">${scoreBar(r.gesamt)}</td>
<td style="padding:2px 6px;color:var(--c-text-muted);font-size:0.9em">${_esc(r.hinweis || '')}</td>
</tr>`
).join('');
box.style.display = 'block';
box.innerHTML = `
<div style="font-size:var(--text-xs);font-weight:600;margin-bottom:var(--space-2)">
Ø-Scores (${d.evaluated}/${d.sample_size} Rassen bewertet)
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-3)">
${['vollstaendigkeit','korrektheit','sprachqualitaet','konsistenz','gesamt'].map(k =>
`<div class="text-center">
<div style="font-size:1.4em;font-weight:700;color:${scoreColor(avg[k])}">${avg[k]?.toFixed(1) ?? ''}</div>
<div style="font-size:0.75em;color:var(--c-text-muted)">${{vollstaendigkeit:'Vollst.',korrektheit:'Korrekt.',sprachqualitaet:'Sprache',konsistenz:'Konsistenz',gesamt:'Gesamt'}[k]}</div>
</div>`
).join('')}
</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead><tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:2px 6px">Rasse</th>
<th style="padding:2px 6px">Vollst.</th><th style="padding:2px 6px">Korrekt.</th>
<th style="padding:2px 6px">Sprache</th><th style="padding:2px 6px">Konsis.</th>
<th style="padding:2px 6px">Ges.</th><th style="text-align:left;padding:2px 6px">Hinweis</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
res.textContent = `✓ Bewertung abgeschlossen`;
} 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 = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
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 = `
<div class="adm-stats-grid">
${_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)"')}
</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Disk-Auslastung</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<div style="flex:1;height:8px;background:var(--c-surface-2);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${diskPct}%;background:${diskPct > 85 ? 'var(--c-danger)' : diskPct > 65 ? '#f59e0b' : 'var(--c-success)'};border-radius:4px;transition:width .3s"></div>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
</div>
</div>
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:4px">
<span>Python ${_esc(s.python_version)}</span>
<span style="font-family:monospace;font-size:var(--text-xs);background:var(--c-surface-2);padding:2px 8px;border-radius:4px;color:var(--c-text-secondary)">
APP v${typeof APP_VER !== 'undefined' ? APP_VER : '—'} · ${_esc(s.sw_version || '?')}
</span>
</div>
</div>
`;
}
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 = `<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
} else {
sparkline = `<polyline points="0,55 ${W},55" fill="none" stroke="var(--c-primary)" stroke-width="1.5"/>`;
}
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]
: _esc(u.email || '');
return `<tr>
<td style="padding:5px 8px;font-weight:500">${_esc(u.name || '')}</td>
<td style="padding:5px 8px;color:var(--c-text-muted);font-size:var(--text-xs)">${emailDisplay}</td>
<td style="padding:5px 8px;text-align:right;font-weight:600">${u.total ?? 0}</td>
<td style="padding:5px 8px;text-align:right;color:var(--c-text-muted);font-size:var(--text-xs)">${u.last_week || ''}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4);padding:var(--space-4)">
<!-- Header -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#path"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text);flex:1">
OpenRouteService
</span>
<div style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
<span style="font-size:var(--text-xs);font-weight:700;padding:3px 10px;border-radius:999px;
background:${barColor}22;color:${barColor};border:1px solid ${barColor}44">
${count.toLocaleString('de')} / ${limit.toLocaleString('de')} heute
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
${weekTotal} diese Woche · ${totalAll} gesamt (30 Tage)
</span>
</div>
</div>
<!-- Fortschrittsbalken -->
<div style="height:6px;border-radius:3px;background:var(--c-border);overflow:hidden;margin-bottom:var(--space-4)">
<div style="height:100%;width:${pct}%;background:${barColor};border-radius:3px;transition:width 0.6s ease"></div>
</div>
<!-- Sparkline -->
<div style="margin-bottom:var(--space-1)">
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:60px;display:block">
${sparkline}
</svg>
</div>
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);
color:var(--c-text-muted);margin-bottom:var(--space-4)">
<span>30 Tage</span>
<span>${_esc(lastDate)}</span>
</div>
${topUsers.length ? `
<!-- Top-Nutzer -->
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.05em;
color:var(--c-text-secondary);margin-bottom:var(--space-2)">Aktivste Nutzer</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Name</th>
<th style="text-align:left;padding:4px 8px;color:var(--c-text-muted);font-weight:600">E-Mail</th>
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Gesamt</th>
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Letzte Woche</th>
</tr>
</thead>
<tbody>${userRows}</tbody>
</table>
</div>` : ''}
</div>`;
}
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 `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
margin-left:6px;${overdue
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
${overdue ? '⚠️ ' : ''}${label}
</span>`;
}
function _historySection(label, items, renderItem) {
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
return `
<details class="mb-4">
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;padding:var(--space-2) 0;
border-top:1px solid var(--c-border)">
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
<svg class="ph-icon" style="margin-left:auto;transition:transform .2s" aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</summary>
<div style="margin-top:var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)">
${items.map(item => `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${renderItem(item)}
</div>`).join('')}
</div>
</details>`;
}
async function _renderModeration(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-mod-refresh">${UI.icon('arrows-clockwise')} Aktualisieren</button>
</div>
<div id="adm-mod-content">Lade…</div>
`;
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 = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
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 = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${modItems.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: modItems.map(i => `
<span style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700">
${UI.icon(i.icon)} ${i.label}
<strong style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">${i.count}</strong>
</span>`).join('')
}
</div>`;
// --- Züchter-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin-bottom:var(--space-3)">
Züchter-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
</h3>`;
if (!zuchterPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
} else {
html += `<div class="card adm-table-card mb-3"><div class="adm-table-scroll"><table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
</tr></thead><tbody>
${zuchterPending.map((z, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(z.rasse_slug)}</td>
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
<td class="adm-td">${_ageLabel(z.created_at)}</td>
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
<button class="btn btn-sm btn-ghost adm-zuchter-delete" data-id="${z.id}" class="text-danger"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
</td>
</tr>`).join('')}
</tbody></table></div></div>`;
}
// Züchter-History
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
z => `<span style="font-weight:600">${_esc(z.name)}</span> · ${_esc(z.rasse_slug)} ·
${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`);
// --- Wiki-Foto-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin-bottom:var(--space-3)">
Wiki-Foto-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
</h3>`;
if (!fotosPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
} else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
${fotosPending.map(f => `
<div class="card p-4">
<img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${_esc(f.user_name)}</div>
<div class="mb-3">${_ageLabel(f.created_at)}</div>
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
opacity:.5;margin-bottom:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">↑ aktuelles Foto</div>` : ''}
<div class="flex-gap-2">
<button class="btn btn-sm btn-primary adm-foto-approve" data-id="${f.id}" class="flex-1">✓</button>
<button class="btn btn-sm btn-ghost adm-foto-reject" data-id="${f.id}" class="text-danger">✗</button>
</div>
</div>`).join('')}
</div>`;
}
// Fotos-History
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
f => `<img src="${_esc(f.foto_url)}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px">
<span style="font-weight:600">${_esc(f.rasse_name||'?')}</span> · von ${_esc(f.user_name||'?')} ·
${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`}
${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`);
// --- Forum-Meldungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-4) 0 var(--space-3)">
Forum-Meldungen
<span style="background:${reportsPending.length ? 'var(--c-danger)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${reportsPending.length}
</span>
</h3>`;
if (!reportsPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine offenen Meldungen.</p>`;
} else {
html += `<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-3)">
${reportsPending.map(r => `
<div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div class="flex-1-min">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong>
${_ageLabel(r.created_at)}
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm)">${_esc(r.content_preview)}</div>` : ''}
</div>
<button class="btn btn-sm btn-primary adm-mod-resolve" data-rid="${r.id}" title="Als erledigt markieren">
${UI.icon('check')}
</button>
</div>
</div>`).join('')}
</div>`;
}
// Meldungen-History
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} ·
${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`);
// --- POI-Korrekturen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-2) 0 var(--space-3)">
POI-Korrekturen
<span style="background:${poiPending.length ? 'var(--c-warning,#e65100)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${poiPending.length}
</span>
</h3>`;
if (!poiPending.length) {
html += `<p class="text-sm-muted">Keine ausstehenden POI-Korrekturen.</p>`;
} else {
html += `<div class="card adm-table-card"><div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Ort</th>
<th class="adm-th">Feld</th>
<th class="adm-th">Alt</th>
<th class="adm-th">Neu</th>
<th class="adm-th">Von</th>
<th class="adm-th">Alter</th>
<th class="adm-th"></th>
</tr></thead>
<tbody>
${poiPending.map((e, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(e.poi_name || `OSM #${e.osm_id}`)}</td>
<td class="adm-td"><code class="text-xs">${_esc(e.field)}</code></td>
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td>
<td class="adm-td text-xs">${_esc(e.new_value || '—')}</td>
<td class="adm-td text-muted">${_esc(e.einreicher_name || '?')}</td>
<td class="adm-td">${_ageLabel(e.created_at)}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
${UI.icon('check')}
</button>
<button class="btn btn-sm btn-ghost adm-poi-reject" data-id="${e.id}" class="text-danger">
${UI.icon('x')}
</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div></div>`;
}
// POI-History
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
e => `<span style="font-weight:600">${_esc(e.poi_name||`OSM #${e.osm_id}`)}</span> ·
<code class="text-xs">${_esc(e.field)}</code>:
<span style="text-decoration:line-through;color:var(--c-text-muted)">${_esc(e.old_value||'—')}</span> →
${_esc(e.new_value||'—')} ·
${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`}
${e.mod_name ? ` von ${_esc(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 = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-zuchter-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-zuchter-antraege">Lade…</div>
<div id="adm-zuchter-liste" class="mt-4">Lade…</div>
`;
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 = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
let antraege;
try {
antraege = await API.breeder.pendingList();
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Anträge konnten nicht geladen werden.');
return;
}
if (!antraege.length) {
el.innerHTML = `<div class="card p-4">
<div class="by-card-section-header">Offene Anträge</div>
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-sm);color:var(--c-text-muted)">
${UI.icon('check-circle')} Keine offenen Anträge
</div>
</div>`;
return;
}
el.innerHTML = `
<div class="card" style="margin-bottom:0">
<div class="by-card-section-header">Offene Anträge (${antraege.length})</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-3)">
${antraege.map(a => `
<div class="card p-4">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
<!-- Infos -->
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);
color:var(--c-text);margin-bottom:var(--space-1)">
${_esc(a.name)}
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:400;margin-left:6px">
${_esc(a.email)}
</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-3);font-size:var(--text-xs);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
<span>${UI.icon('paw-print')} ${_esc(a.rasse_text || '')}</span>
<span>${UI.icon('house-line')} ${_esc(a.zwingername || '')}</span>
<span>${UI.icon('users')} ${_esc(a.verein || '')}</span>
<span>${UI.icon('map-pin')} ${_esc(a.stadt || '')}</span>
<span style="color:${a.vdh_mitglied ? 'var(--c-success)' : 'var(--c-text-muted)'}">
${UI.icon('certificate')} VDH: ${a.vdh_mitglied ? 'ja' : 'nein'}
</span>
${a.created_at ? `<span class="text-muted">${UI.icon('clock')} ${new Date(a.created_at).toLocaleDateString('de-DE')}</span>` : ''}
</div>
${a.beschreibung ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm);margin-top:var(--space-1)">
${_esc(a.beschreibung)}
</div>` : ''}
</div>
<!-- Aktionen -->
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-sm btn-secondary adm-breeder-docs"
data-uid="${a.user_id || a.id}">
${UI.icon('file-text')} Dokumente
</button>
<button class="btn btn-sm btn-primary adm-breeder-approve"
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}">
${UI.icon('check')} Freischalten
</button>
<button class="btn btn-sm btn-ghost adm-breeder-reject"
data-uid="${a.user_id || a.id}" data-name="${_esc(a.name)}"
class="text-danger">
${UI.icon('x')} Ablehnen
</button>
</div>
</div>
</div>
`).join('')}
</div>
`;
// Dokumente anzeigen
el.querySelectorAll('.adm-breeder-docs').forEach(btn => {
btn.addEventListener('click', async () => {
const uid = btn.dataset.uid;
let docs;
try {
docs = await API.breeder.documents(uid);
} catch (e) {
UI.toast.error(e.message || 'Dokumente konnten nicht geladen werden.');
return;
}
UI.modal.open({
title: `${UI.icon('file-text')} Hochgeladene Dokumente`,
body: docs.length
? `<div class="flex-col-gap-3">
${docs.map(d => `
<a href="${_esc(API.breeder.documentUrl(uid, d.id))}"
target="_blank" rel="noopener"
class="btn btn-secondary"
style="text-align:left;word-break:break-all">
${UI.icon('file')} ${_esc(d.filename || d.name || 'Dokument ' + d.id)}
</a>`).join('')}
</div>`
: `<p class="text-sm-muted">Keine Dokumente hochgeladen.</p>`,
});
});
});
// Freischalten
el.querySelectorAll('.adm-breeder-approve').forEach(btn => {
btn.addEventListener('click', async () => {
const ok = window.confirm(`${btn.dataset.name} als Züchter freischalten?`);
if (!ok) return;
btn.disabled = true;
try {
const res = await API.breeder.approve(btn.dataset.uid);
UI.toast.success(res.message || `${btn.dataset.name} freigeschaltet.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Freischaltung fehlgeschlagen.');
btn.disabled = false;
}
});
});
// Ablehnen
el.querySelectorAll('.adm-breeder-reject').forEach(btn => {
btn.addEventListener('click', () => {
const uid = btn.dataset.uid;
const name = btn.dataset.name;
UI.modal.open({
title: `${UI.icon('x-circle')} Antrag ablehnen: ${name}`,
body: `
<form id="breeder-reject-form" class="flex-col-gap-3">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Bitte gib einen Ablehnungsgrund an. Dieser wird dem Antragsteller mitgeteilt.
</p>
<textarea id="breeder-reject-grund" name="grund" rows="4" required
placeholder="z. B. Dokumente unvollständig, Rasse nicht unterstützt…"
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text);resize:vertical"></textarea>
</form>
`,
footer: `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" id="breeder-reject-submit"
form="breeder-reject-form" style="width:100%;background:var(--c-danger);border-color:var(--c-danger)">
Antrag ablehnen
</button>
<button class="btn btn-ghost" data-modal-close>Abbrechen</button>
</div>
`,
});
document.getElementById('breeder-reject-form')?.addEventListener('submit', async ev => {
ev.preventDefault();
const grund = document.getElementById('breeder-reject-grund')?.value?.trim();
if (!grund) {
UI.toast.warning('Bitte einen Ablehnungsgrund angeben.');
return;
}
const submitBtn = document.getElementById('breeder-reject-submit');
if (submitBtn) submitBtn.disabled = true;
try {
const res = await API.breeder.reject(uid, grund);
UI.modal.close?.();
UI.toast.success(res.message || `Antrag von ${name} abgelehnt.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Ablehnung fehlgeschlagen.');
if (submitBtn) submitBtn.disabled = false;
}
});
});
});
}
async function _loadZuechterListe(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
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 `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#C4843A;color:#fff">Züchter-Abo</span>`;
if (t === 'breeder_test') return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#aaa;color:#fff">Test</span>`;
return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#eee;color:#666">Standard</span>`;
};
const rows = breeders.map(b => `
<tr>
<td style="padding:var(--space-2) var(--space-3)">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(b.name)}</div>
<div class="text-xs-muted">${_esc(b.email)}</div>
</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm)">${_esc(b.zwingername || '—')}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(b.rasse_text || '—')}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(b.stadt || '—')}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-size:var(--text-xs)">
${b.wuerfe_count || 0} Würfe<br>
<span class="text-muted">${b.hunde_count || 0} Hunde</span>
</td>
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(b.subscription_tier)}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'}
</td>
<td style="padding:var(--space-2) var(--space-3)">
<button class="btn btn-sm btn-ghost adm-breeder-tier-btn"
data-uid="${b.id}" data-name="${_esc(b.name)}" data-tier="${_esc(b.subscription_tier || 'standard')}"
class="text-xs">
Abo
</button>
</td>
</tr>`).join('');
el.innerHTML = `
<div class="card adm-table-card">
<div class="by-card-section-header">Alle Züchter (${breeders.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
<thead><tr>
${['Nutzer','Zwingername','Rasse','Stadt','Aktivität','Abo','Seit',''].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;
font-size:var(--text-xs);color:var(--c-text-muted);font-weight:600;
border-bottom:1px solid var(--c-border);white-space:nowrap">${h}</th>`
).join('')}
</tr></thead>
<tbody>
${rows || `<tr><td colspan="8" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Züchter</td></tr>`}
</tbody>
</table>
</div>
</div>`;
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 = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-jobs-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-jobs-list">Lade…</div>
`;
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 = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
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 = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Job</th>
<th class="adm-th">Nächster Lauf</th>
<th class="adm-th">Trigger</th>
<th class="adm-th"></th>
</tr>
</thead>
<tbody>
${jobs.map((j, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(j.name)}
<div class="adm-job-id">${_esc(j.id)}</div>
</td>
<td class="adm-td" style="color:var(--c-text-secondary);white-space:nowrap">
${j.next_run_time ? _formatDateTime(j.next_run_time) : '<span class="text-muted">—</span>'}
</td>
<td class="adm-td adm-td-trigger">
${_esc(j.trigger)}
</td>
<td class="adm-td text-right">
<button class="btn btn-sm btn-ghost adm-job-trigger adm-icon-btn" data-id="${_esc(j.id)}" data-name="${_esc(j.name)}"
title="Jetzt ausführen" class="text-primary">
${UI.icon('play')}
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
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')) || [];
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Anleitung -->
<div class="by-card" style="padding:var(--space-4);background:var(--c-surface-2);border-left:3px solid var(--c-primary)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-sm);font-weight:700">So funktioniert das Partner-System</h3>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);display:flex;flex-direction:column;gap:var(--space-2)">
<p style="margin:0"><strong>1. Partner-Code erstellen</strong> — Erstelle einen Code (z. B. <code>HUNDEBLOG</code>) für einen Influencer oder Partner. Der Code wird an die Person weitergegeben.</p>
<p style="margin:0"><strong>2. Registrierung mit Code</strong> — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als <em>Gründer</em> markiert (Platz #1100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.</p>
<p style="margin:0"><strong>3. Partner-Status vergeben</strong> — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt <em>Partner-Badge</em> (blaues Badge im Profil) und <em>Gründer-Lizenz</em>. So ist auch er als Gründer #X sichtbar.</p>
<p style="margin:0"><strong>Max. 100 Gründer</strong> — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.</p>
<p style="margin:0"><strong>Freunde werben</strong> — 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 % — lebenslang, sobald Bezahlfunktionen aktiv sind.</p>
</div>
</div>
<!-- Neuen Code anlegen -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuen Partner-Code erstellen</h3>
<form id="adm-partner-create" class="flex-col-gap-3">
<div class="grid-2">
<div>
<label class="form-label text-xs">Code</label>
<input class="form-control" name="code" placeholder="z. B. HUNDEBLOG"
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em" required>
</div>
<div>
<label class="form-label text-xs">Bezeichnung</label>
<input class="form-control" name="label" placeholder="z. B. Max Musterhund (Instagram)" required>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
<div>
<label class="form-label text-xs">Max. Einlösungen <span class="text-muted">(leer = unbegrenzt)</span></label>
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
style="width:16px;height:16px;accent-color:var(--c-primary)">
<label for="adm-grants-founder" style="font-size:var(--text-sm);cursor:pointer">
Gründer-Lizenz (lebenslang kostenlos)
</label>
</div>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start">
${UI.icon('plus')} Code erstellen
</button>
</form>
</div>
<!-- Aktive Codes -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Aktive Codes</h3>
<div id="adm-partner-codes-list">
${codes.length === 0
? `<p class="text-sm-muted">Noch keine Partner-Codes angelegt.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Bezeichnung</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Nutzungen</th>
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Gründer</th>
<th style="padding:var(--space-2) var(--space-3)"></th>
</tr>
</thead>
<tbody>
${codes.map(c => `
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
<td style="padding:var(--space-2) var(--space-3)">
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
</td>
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">${c.label}</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
</td>
<td style="padding:var(--space-2) var(--space-3);text-align:center">
${c.grants_founder ? '✓' : '—'}
</td>
<td style="padding:var(--space-2) var(--space-3)">
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
${UI.icon('trash')} Löschen
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>`
}
</div>
</div>
<!-- User-Status vergeben -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
<form id="adm-partner-grant" class="flex-col-gap-3">
<div>
<label class="form-label text-xs">User-ID oder Benutzername</label>
<input class="form-control" name="user_search" id="adm-grant-search"
placeholder="Name eingeben…" autocomplete="off">
<div id="adm-grant-result" class="mt-2"></div>
</div>
<div style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_founder" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Gründer-Lizenz
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
<input type="checkbox" name="is_partner" value="1"
style="width:16px;height:16px;accent-color:var(--c-primary)">
Partner-Badge (Creator)
</label>
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
${UI.icon('check')} Status setzen
</button>
</form>
</div>
</div>
`;
// 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 = `<p class="text-xs-muted">Kein User gefunden.</p>`;
return;
}
grantResult.innerHTML = users.map(u => `
<div class="adm-grant-user" data-id="${u.id}" data-name="${u.name}"
data-founder="${u.is_founder||0}" data-partner="${u.is_partner||0}"
style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);
cursor:pointer;background:var(--c-surface-2);margin-bottom:2px;
font-size:var(--text-sm);display:flex;justify-content:space-between">
<span><strong>${u.name}</strong></span>
<span style="color:var(--c-text-muted);font-size:var(--text-xs)">
${u.rolle}${u.is_founder ? ' · ⭐' : ''}${u.is_partner ? ' · 🤝' : ''}
</span>
</div>
`).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';
}
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}</p>`;
});
});
} catch(e) {
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-danger)">${e.message || 'Suchfehler'}</p>`;
}
}, 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;
await UI.asyncButton(btn, async () => {
const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, {
is_founder: isFounder,
is_partner: isPartner,
});
if (!result) throw new Error('Keine Antwort vom Server.');
UI.toast.success(`Status für ${result.name} gesetzt.`);
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}</p>`;
}).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'
? `<span style="font-size:10px;background:var(--c-warning-bg,#FEF3C7);color:var(--c-warning,#D97706);padding:1px 6px;border-radius:999px">support@</span>`
: `<span style="font-size:10px;background:var(--c-primary-bg,#EFF6FF);color:var(--c-primary,#2563EB);padding:1px 6px;border-radius:999px">partner@</span>`;
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Vorlagen-Manager -->
<div class="by-card p-4">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-base)">Vorlagen</h3>
<button class="btn btn-sm btn-secondary" id="adm-tpl-new">
${UI.icon('plus')} Neue Vorlage
</button>
</div>
${templates.length === 0
? `<p class="text-sm-muted">Noch keine Vorlagen.</p>`
: `<div class="flex-col-gap-2">
${templates.map(t => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);
background:var(--c-bg-elevated);border-radius:var(--radius-md);border:1px solid var(--c-border)">
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-sm);font-weight:600">${_esc(t.label)}</span>
${accountBadge(t.from_account)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(t.subject)}
</div>
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-xs btn-secondary adm-tpl-load" data-id="${t.id}" title="In Compose laden">
${UI.icon('arrow-bend-up-left')}
</button>
<button class="btn btn-xs btn-secondary adm-tpl-edit" data-id="${t.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-xs btn-danger adm-tpl-del" data-id="${t.id}" title="Löschen">
${UI.icon('trash')}
</button>
</div>
</div>`).join('')}
</div>`}
</div>
<!-- Compose -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">E-Mail senden</h3>
<form id="adm-outreach-form" class="flex-col-gap-3">
<!-- Absender -->
<div class="grid-2">
<div>
<label class="form-label text-xs">Absender</label>
<select id="adm-outreach-from" class="form-control">
<option value="partner">partner@banyaro.app (Influencer/Partner)</option>
<option value="support">support@banyaro.app (Support/Moderation)</option>
</select>
</div>
<div>
<label class="form-label text-xs">
Empfänger <span class="text-muted">(Komma-getrennt)</span>
</label>
<input class="form-control" id="adm-outreach-to" type="text"
placeholder="name@example.com, andere@example.com">
</div>
</div>
<!-- Betreff -->
<div>
<label class="form-label text-xs">Betreff</label>
<input class="form-control" id="adm-outreach-subject" type="text">
</div>
<!-- Text -->
<div>
<label class="form-label text-xs">Text</label>
<textarea id="adm-outreach-body" class="form-control" rows="14"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
</div>
<div style="display:flex;gap:var(--space-3);align-items:center">
<button type="submit" class="btn btn-primary">
${UI.icon('paper-plane-tilt')} Senden
</button>
<span class="text-xs-muted">
{name} wird nicht automatisch ersetzt — bitte manuell anpassen.
</span>
</div>
</form>
</div>
<!-- Versand-Log -->
<div class="by-card p-4">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Versand-Log</h3>
${log.length === 0
? `<p class="text-sm-muted">Noch keine E-Mails gesendet.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Von</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Empfänger</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Betreff</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wer</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wann</th>
</tr>
</thead>
<tbody>
${log.map((l, i) => `
<tr data-log-idx="${i}" style="border-bottom:1px solid var(--c-border);cursor:pointer"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<td class="p-2">${accountBadge(l.from_account)}</td>
<td class="p-2">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${_esc(l.sent_by_name || '')}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${(l.sent_at||'').slice(0,16).replace('T',' ')}</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
</div>
`;
// 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: _esc(l.subject),
body: `
<div style="margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-muted)">
<strong>An:</strong> ${_esc(l.recipient)} &nbsp;·&nbsp;
<strong>Von:</strong> ${_esc(l.from_account)}@banyaro.app &nbsp;·&nbsp;
${(l.sent_at||'').slice(0,16).replace('T',' ')}
</div>
<pre style="white-space:pre-wrap;font-family:inherit;font-size:var(--text-sm);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);max-height:60vh;overflow-y:auto;
color:var(--c-text)">${_esc(l.body || '(kein Text gespeichert)')}</pre>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
});
});
// 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: `
<form id="${id}" class="flex-col-gap-3">
<div class="grid-2">
<div>
<label class="form-label text-xs">Name (intern)</label>
<input class="form-control" id="${id}-key" type="text" placeholder="z.B. willkommen_neu"
value="${_esc(tpl?.key || '')}" ${isNew ? '' : 'readonly'}>
</div>
<div>
<label class="form-label text-xs">Absender</label>
<select id="${id}-from" class="form-control">
<option value="partner" ${(tpl?.from_account||'partner')==='partner'?'selected':''}>partner@banyaro.app</option>
<option value="support" ${tpl?.from_account==='support'?'selected':''}>support@banyaro.app</option>
</select>
</div>
</div>
<div>
<label class="form-label text-xs">Bezeichnung (sichtbar)</label>
<input class="form-control" id="${id}-label" type="text" placeholder="z.B. Willkommensnachricht"
value="${_esc(tpl?.label || '')}">
</div>
<div>
<label class="form-label text-xs">Betreff</label>
<input class="form-control" id="${id}-subject" type="text"
value="${_esc(tpl?.subject || '')}">
</div>
<div>
<label class="form-label text-xs">Text</label>
<textarea id="${id}-body" class="form-control" rows="12"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical">${_esc(tpl?.body || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
});
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 = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-ghost btn-sm" id="adm-audit-refresh">
${UI.icon('arrows-clockwise')} Aktualisieren
</button>
</div>
<div id="adm-audit-list">Lade…</div>
`;
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 = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
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 = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Wann</th>
<th class="adm-th">Admin</th>
<th class="adm-th">Aktion</th>
<th class="adm-th">Ziel</th>
</tr>
</thead>
<tbody>
${rows.map((r, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="color:var(--c-text-muted);white-space:nowrap;font-size:var(--text-xs)">
${_formatDateTime(r.created_at)}
</td>
<td class="adm-td" style="color:var(--c-text);white-space:nowrap">
${_esc(r.admin_name || '—')}
</td>
<td class="adm-td">
<span class="adm-badge-mono">${_esc(r.action)}</span>
${r.detail ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(r.detail)}</div>` : ''}
</td>
<td class="adm-td" style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:nowrap">
${_esc(r.target || '—')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------
function _prompt(msg) {
return new Promise(resolve => {
UI.modal.open({
title: 'Eingabe',
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">${msg}</p>
<input id="adm-prompt-input" type="text"
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)">
`,
footer: `
<button class="btn btn-primary" id="adm-prompt-ok" form="">OK</button>
<button class="btn btn-ghost" id="adm-prompt-cancel" form="">Abbrechen</button>
`,
});
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 `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
margin-bottom:var(--space-3)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<p style="font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">${title}</p>
${text ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">${text}</p>` : ''}
</div>
`;
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
// BEWERBUNGEN — Social-Media-Job
// ------------------------------------------------------------------
async function _renderBewerbungen(el) {
let _statusFilter = 'pending';
async function _load() {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
${['pending','reviewing','accepted','rejected','alle'].map(s => `
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
${s==='pending' ? `${UI.icon('clock')} Neu`
: s==='reviewing' ? `${UI.icon('magnifying-glass')} In Prüfung`
: s==='accepted' ? `${UI.icon('check-circle')} Angenommen`
: s==='rejected' ? `${UI.icon('x')} Abgelehnt`
: 'Alle'}
</button>`).join('')}
</div>
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
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 => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
<div class="flex-1-min">
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
</div>
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
<select class="form-control adm-bew-status" data-id="${r.id}"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
<option value="pending" ${r.status==='pending' ?'selected':''}>⏳ Neu</option>
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
<option value="accepted" ${r.status==='accepted' ?'selected':''}>✅ Angenommen</option>
<option value="rejected" ${r.status==='rejected' ?'selected':''}>❌ Abgelehnt</option>
</select>
</div>
</div>
</div>`).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 => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
📎 ${_esc(d.filename)}</a>`).join('')
: '<span class="text-sm-muted">Keine Anhänge</span>';
UI.modal.open({
title: `Bewerbung — ${_esc(app.name)}`,
body: `
<div style="display:grid;gap:var(--space-3)">
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
<div><b>Social:</b> @${_esc(app.social_handle||'—')}</div>
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
<div><b>Motivation:</b><br>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
</div>
<div><b>Anhänge:</b><br>${docsHtml}</div>
<div>
<b>Admin-Notiz:</b>
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
</div>
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
});
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 = `
<div class="p-4">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-4)">
<h2 style="margin:0;font-size:var(--text-lg)">Hilfe / FAQ</h2>
<button class="btn btn-primary btn-sm" id="adm-hilfe-neu">
${UI.icon('plus')} Neuer Artikel
</button>
</div>
<!-- Neuer-Artikel-Formular (versteckt) -->
<div id="adm-hilfe-form" style="display:none;background:var(--c-surface-2);
border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-4);margin-bottom:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuer Artikel</h3>
<div style="display:grid;gap:var(--space-3)">
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Kategorie</label>
<select id="adm-hilfe-kat" style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
${Object.entries(KAT_LABEL).map(([k,v]) =>
`<option value="${k}">${_esc(v)}</option>`
).join('')}
</select>
</div>
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Frage</label>
<input id="adm-hilfe-frage" type="text" placeholder="Frage eingeben…"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="font-size:var(--text-sm);font-weight:500;display:block;
margin-bottom:var(--space-1)">Antwort</label>
<textarea id="adm-hilfe-antwort" rows="4" placeholder="Antwort eingeben…"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box;
resize:vertical;font-family:inherit"></textarea>
</div>
<div class="flex-gap-2">
<label style="font-size:var(--text-sm);font-weight:500;margin-right:var(--space-2)">
Reihenfolge
</label>
<input id="adm-hilfe-sort" type="number" value="0" min="0"
style="width:80px;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
</div>
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary btn-sm" id="adm-hilfe-form-cancel">Abbrechen</button>
<button class="btn btn-primary btn-sm" id="adm-hilfe-form-save">Speichern</button>
</div>
</div>
</div>
<!-- Artikel-Liste -->
<div id="adm-hilfe-list">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
Lade…
</div>
</div>
</div>
`;
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 += `
<div style="margin-bottom:var(--space-5)">
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:0.05em;
padding:var(--space-2) 0;margin-bottom:var(--space-2);
border-bottom:1px solid var(--c-border)">
${_esc(label)}
</div>
`;
for (const a of items) {
html += `
<div class="adm-hilfe-row" data-id="${a.id}"
style="border:1px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);margin-bottom:var(--space-2)">
<!-- Zusammenfassung -->
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-3) var(--space-4)">
<span style="flex:1;font-size:var(--text-sm);font-weight:500;
${a.aktiv ? '' : 'opacity:0.45;text-decoration:line-through'}">
${_esc(a.frage)}
</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted);
white-space:nowrap">
#${a.sort_order}
</span>
<button class="btn btn-sm adm-hilfe-edit-btn"
style="padding:2px 8px;font-size:var(--text-xs)"
data-id="${a.id}">
${UI.icon('pencil-simple')} Bearbeiten
</button>
<button class="btn btn-sm adm-hilfe-toggle-btn"
style="padding:2px 8px;font-size:var(--text-xs);
background:${a.aktiv ? 'var(--c-warning-bg,#fef3c7)' : 'var(--c-success-bg,#d1fae5)'};
color:${a.aktiv ? 'var(--c-warning,#92400e)' : 'var(--c-success,#065f46)'}"
data-id="${a.id}" data-aktiv="${a.aktiv}">
${a.aktiv ? UI.icon('eye-slash') + ' Ausblenden' : UI.icon('eye') + ' Einblenden'}
</button>
<button class="btn btn-sm adm-hilfe-del-btn"
style="padding:2px 8px;font-size:var(--text-xs);
background:var(--c-danger-bg,#fee2e2);color:var(--c-danger,#991b1b)"
data-id="${a.id}" data-frage="${_esc(a.frage)}">
${UI.icon('trash')}
</button>
</div>
<!-- Edit-Formular (versteckt) -->
<div class="adm-hilfe-edit-form" data-id="${a.id}"
style="display:none;padding:0 var(--space-4) var(--space-4);
border-top:1px solid var(--c-border)">
<div style="display:grid;gap:var(--space-3);padding-top:var(--space-3)">
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Kategorie</label>
<select class="adm-hilfe-edit-kat"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
${Object.entries(KAT_LABEL).map(([k,v]) =>
`<option value="${k}" ${k === a.kategorie ? 'selected' : ''}>${_esc(v)}</option>`
).join('')}
</select>
</div>
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Frage</label>
<input type="text" class="adm-hilfe-edit-frage"
value="${_esc(a.frage)}"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box">
</div>
<div>
<label style="font-size:var(--text-xs);font-weight:600;display:block;
margin-bottom:4px;color:var(--c-text-secondary)">Antwort</label>
<textarea class="adm-hilfe-edit-antwort" rows="5"
style="width:100%;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);
font-size:var(--text-sm);box-sizing:border-box;
resize:vertical;font-family:inherit">${_esc(a.antwort)}</textarea>
</div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
<label style="font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary)">Reihenfolge</label>
<input type="number" class="adm-hilfe-edit-sort"
value="${a.sort_order}" min="0"
style="width:70px;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
<div class="flex-1"></div>
<button class="btn btn-secondary btn-sm adm-hilfe-edit-cancel" data-id="${a.id}"
class="text-xs">Abbrechen</button>
<button class="btn btn-primary btn-sm adm-hilfe-edit-save" data-id="${a.id}"
class="text-xs">Speichern</button>
</div>
</div>
</div>
</div>
`;
}
html += `</div>`;
}
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 = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade Übungen…</div>`;
let byTab;
try {
byTab = await API.get('/training/exercises');
} catch (e) {
el.innerHTML = `<div style="padding:var(--space-6);color:var(--c-danger)">Fehler: ${e.message}</div>`;
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 = `<div class="p-4">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4)">
Trainingsübungen bearbeiten
</h2>`;
for (const [kat, list] of Object.entries(grouped)) {
html += `<div style="margin-bottom:var(--space-6)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-secondary);
padding:var(--space-2) 0 var(--space-2);
border-bottom:1px solid var(--c-border);margin-bottom:var(--space-2)">
${KAT_LABELS[kat] || kat} (${list.length})
</div>`;
for (const ex of list) {
const schritte = Array.isArray(ex.schritte) ? ex.schritte.join('\n') : '';
const exId = ex.exercise_id;
html += `<div class="adm-ueb-row" data-ex-id="${exId}"
style="padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="flex:1;font-size:var(--text-sm);font-weight:500">${ex.name}</span>
<button class="adm-ueb-edit-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:2px 10px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-surface);
cursor:pointer;color:var(--c-text-secondary)">Bearbeiten</button>
</div>
<div class="adm-ueb-form" data-ex-id="${exId}"
style="display:none;margin-top:var(--space-3);
background:var(--c-surface-2);border-radius:8px;padding:var(--space-3)">
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Beschreibung
</label>
<textarea class="adm-ueb-beschreibung" rows="2"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);
color:var(--c-text);resize:vertical">${(ex.beschreibung || '').replace(/</g, '&lt;')}</textarea>
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Schritte (eine Zeile = ein Schritt)
</label>
<textarea class="adm-ueb-schritte" rows="6"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);
color:var(--c-text);resize:vertical">${schritte.replace(/</g, '&lt;')}</textarea>
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
Tipp
</label>
<input class="adm-ueb-tipp" type="text"
value="${(ex.tipp || '').replace(/"/g, '&quot;')}"
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-3);
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-bg);color:var(--c-text)">
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="adm-ueb-cancel-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
border:1px solid var(--c-border);background:var(--c-surface);
cursor:pointer;color:var(--c-text-secondary)">Abbrechen</button>
<button class="adm-ueb-save-btn" data-ex-id="${exId}"
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
border:none;background:var(--c-primary);
cursor:pointer;color:#fff;font-weight:600">Speichern</button>
</div>
</div>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
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 = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade…</div>`;
let d;
try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger)">Fehler beim Laden.</div>`; 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) => `
<tr>
<td style="padding:8px 10px;color:var(--c-text-muted);font-weight:600">${i + 1}</td>
<td style="padding:8px 10px;font-weight:600">${_esc(r.name)}</td>
<td style="padding:8px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.email)}</td>
<td style="padding:8px 10px;text-align:right">
<span style="font-size:var(--text-lg);font-weight:800;color:var(--c-primary)">${r.invited_count}</span>
</td>
</tr>`).join('');
const recentRows = d.recent_invites.slice(0, 50).map(r => `
<tr>
<td style="padding:6px 10px;font-weight:500">${_esc(r.name)}</td>
<td style="padding:6px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.referrer_name)}</td>
<td style="padding:6px 10px;color:var(--c-text-muted);font-size:var(--text-xs)">${(r.created_at || '').slice(0, 10)}</td>
</tr>`).join('');
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-4)">
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${d.total_referred}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Geworbene User</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success)">${pct}%</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Anteil geworbener User</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-warning)">${d.viral_factor}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Virality Factor</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4);overflow:hidden">
<div class="by-card-section-header">Top Werber</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
<th style="padding:8px 10px;text-align:left">#</th>
<th style="padding:8px 10px;text-align:left">Name</th>
<th style="padding:8px 10px;text-align:left">E-Mail</th>
<th style="padding:8px 10px;text-align:right">Eingeladen</th>
</tr></thead>
<tbody>${topRows || '<tr><td colspan="4" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Empfehlungen</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="card" style="overflow:hidden">
<div class="by-card-section-header">Zuletzt geworbene User</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
<th style="padding:6px 10px;text-align:left">User</th>
<th style="padding:6px 10px;text-align:left">Geworben von</th>
<th style="padding:6px 10px;text-align:left">Datum</th>
</tr></thead>
<tbody>${recentRows || '<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Daten</td></tr>'}</tbody>
</table>
</div>
</div>`;
}
// ------------------------------------------------------------------
// 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 `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
font-size:11px;font-weight:700;background:${color};color:#fff">${label}</span>`;
};
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 => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap">
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(r.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${_esc(r.email)}</div>
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
${tierBadge(r.tier)}
${r.discount_pct > 0 ? `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
font-size:11px;font-weight:700;background:#e67e22;color:#fff;margin-left:4px">
${r.discount_pct}% Rabatt</span>` : ''}
<span class="text-xs-muted">${r.created_at?.slice(0,10) || ''}</span>
</div>
${r.discount_reason === 'founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Gründer — kostenfrei</div>` : ''}
${r.discount_reason === 'referred_by_founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Von Gründer eingeladen</div>` : ''}
${r.discount_reason === 'referral' ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">${r.referral_count} Freunde geworben</div>` : ''}
${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04))">
${_esc(r.message)}
</div>` : ''}
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)">
${r.existing_invoice_id ? `
<button class="btn adm-invoice-edit-btn"
data-invoice-id="${r.existing_invoice_id}"
title="Rechnung ${_esc(r.existing_invoice_number)} (${_esc(r.existing_invoice_status)}) bearbeiten"
style="background:#eab308;color:#1a1a1a;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
${UI.icon('receipt')} Rechnung bearbeiten
</button>` : `
<button class="btn adm-invoice-btn"
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}"
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}"
data-discount="${r.discount_pct || 0}"
data-discount-reason="${r.discount_reason || ''}"
data-referral-count="${r.referral_count || 0}"
style="background:#e67e22;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
${UI.icon('receipt')} Rechnung erstellen
</button>`}
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
✓ Freischalten
</button>
</div>
</div>`;
// Erledigte als kompakte Tabellenzeilen
const _doneRow = r => `
<tr>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm)">${_esc(r.name)}<br>
<span class="text-xs-muted">${_esc(r.email)}</span></td>
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(r.tier)}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-success)">
${r.fulfilled_at?.slice(0,10) || ''}</td>
</tr>`;
el.innerHTML = `
<div class="mb-4">
<div class="by-card-section-header mb-3">
Offene Anfragen (${pending.length})
</div>
${pending.length
? pending.map(_pendingCard).join('')
: `<div class="card" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted);font-size:var(--text-sm)">
Keine offenen Anfragen
</div>`}
</div>
${done.length ? `
<div class="card adm-table-card">
<div class="by-card-section-header">Erledigt (${done.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
<thead><tr>
${['Nutzer','Tarif','Freigegeben'].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
).join('')}
</tr></thead>
<tbody>${done.map(_doneRow).join('')}</tbody>
</table>
</div>
</div>` : ''}`;
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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
<div class="flex-gap-2">
<button class="btn btn-sm ${_subView === 'liste' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="liste">
${UI.icon('list-bullets')} Rechnungen
</button>
<button class="btn btn-sm ${_subView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="cashflow">
${UI.icon('chart-bar')} Cashflow
</button>
</div>
${_subView === 'liste' ? `
<button class="btn btn-sm btn-secondary" id="adm-inv-new">
${UI.icon('plus')} Neue Rechnung
</button>` : ''}
</div>
<div id="adm-inv-content">
<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade…</div>
</div>
`;
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 `<span style="display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;
background:${bg};color:${color};border:1px solid ${border}">${label}</span>`;
};
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(`<button class="btn btn-sm btn-ghost adm-inv-edit" data-id="${inv.id}" title="Bearbeiten">
${UI.icon('pencil')} Bearbeiten
</button>`);
actions.push(`<button class="btn btn-sm btn-primary adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Senden">
${UI.icon('paper-plane-tilt')} Senden
</button>`);
}
if (inv.status === 'sent') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-send" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}" title="Erneut senden"
class="text-muted">
${UI.icon('paper-plane-tilt')} Erneut senden
</button>`);
}
if (inv.status === 'sent') {
actions.push(`<button class="btn btn-sm btn-secondary adm-inv-pay" data-id="${inv.id}" data-amount="${inv.amount_gross}" title="Als bezahlt markieren">
${UI.icon('check-circle')} Bezahlt
</button>`);
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-cancel" data-id="${inv.id}" data-num="${_esc(inv.invoice_number)}"
class="text-danger" title="Stornieren">
${UI.icon('x-circle')} Storno
</button>`);
}
if (inv.status === 'paid' || inv.status === 'cancelled') {
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-detail" data-id="${inv.id}" title="Details">
${UI.icon('eye')} Details
</button>`);
}
return `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td" style="font-weight:600;font-family:monospace;font-size:var(--text-xs)">
${_esc(inv.invoice_number)}
</td>
<td class="adm-td">
<div style="font-weight:500">${_esc(inv.recipient_name)}</div>
<div class="text-xs-muted">${_esc(inv.recipient_email || '')}</div>
</td>
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
${_fmtEur(inv.amount_gross)}
${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01
? `<div style="font-size:10px;color:var(--c-warning,#d97706);font-weight:500">
erhalten: ${_fmtEur(inv.paid_amount)}
${inv.paid_amount < inv.amount_gross
? `<span class="text-danger">-${_fmtEur(inv.amount_gross - inv.paid_amount)}</span>`
: ''}
</div>`
: ''}
</td>
<td class="adm-td">${_statusBadge(inv.status)}</td>
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
${_fmtDate(inv.created_at)}
</td>
<td class="adm-td" style="white-space:nowrap">
<div style="display:flex;gap:var(--space-1);justify-content:flex-end">${actions.join('')}</div>
</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card adm-table-card">
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Nummer</th>
<th class="adm-th">Empfänger</th>
<th class="adm-th text-right">Betrag</th>
<th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
<th class="adm-th"></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
`;
// 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 `<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:${bg};border:1px solid ${br};
font-size:var(--text-xs);color:${fg};line-height:1.6">${msg}</div>`;
})() : '';
UI.modal.open({
title: `${UI.icon('receipt')} ${isEdit ? (isLocked ? 'Rechnung ansehen' : 'Rechnung bearbeiten') : 'Neue Rechnung erstellen'}`,
body: `
<form id="${id}" class="flex-col-gap-3">
${lockedBanner}
${!isEdit && !p.recipient_name ? `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6">
Diese Rechnung ist für <strong>sonstige Leistungen</strong> (Beratung, Einmalleistung etc.).<br>
Für Abo-Verlängerungen bitte den Button <strong>„Rechnung erstellen"</strong> in der Upgrades-Liste verwenden.
</div>` : ''}
<!-- Empfänger -->
<div class="grid-2">
<div>
<label class="form-label text-xs">Empfänger Name *</label>
<input class="form-control" name="recipient_name" type="text" required
placeholder="Max Muster" value="${_esc(p.recipient_name || '')}">
</div>
<div>
<label class="form-label text-xs">E-Mail</label>
<input class="form-control" name="recipient_email" type="email"
placeholder="max@example.com" value="${_esc(p.recipient_email || '')}">
</div>
</div>
<div>
<label class="form-label text-xs">Adresse
${p.recipient_name && !p.recipient_address
? `<span style="color:var(--c-warning);font-size:10px"> ⚠ Nutzer hat keine Rechnungsadresse hinterlegt</span>`
: '<span class="text-muted">(optional)</span>'}
</label>
<textarea class="form-control" name="recipient_address" rows="2"
placeholder="Musterstr. 1&#10;12345 Berlin"
style="resize:vertical;font-family:inherit">${_esc(p.recipient_address || '')}</textarea>
</div>
<div>
<label class="form-label text-xs">Leistungszeitraum <span class="text-muted">(optional)</span></label>
<input class="form-control" name="service_period" type="text"
placeholder="z.B. 15.05.2026 oder einmalige Leistung"
value="${_esc(p.service_period || '')}">
</div>
<!-- Positionen -->
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0">Positionen *</label>
<button type="button" id="${id}-add-item"
style="font-size:var(--text-xs);color:var(--c-primary);background:none;border:none;cursor:pointer;padding:0;font-weight:600">
+ Position hinzufügen
</button>
</div>
<div id="${id}-items" class="flex-col-gap-2">
<!-- Items werden dynamisch eingefügt -->
</div>
</div>
<!-- Rabatt -->
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="${p.discount_pct ?? 0}"
style="width:80px" id="${id}-discount">
</div>
<!-- Live-Vorschau -->
<div id="${id}-preview" style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-size:var(--text-xs);text-align:right">
<span class="text-muted">Netto: —</span>
</div>
</div>
<div>
<label class="form-label text-xs">Notizen <span class="text-muted">(optional)</span></label>
<textarea class="form-control" name="notes" rows="2"
style="resize:vertical;font-family:inherit"
placeholder="Interne Notiz / Zahlungshinweis">${_esc(p.notes || (!isEdit && !p.recipient_name ? 'Zahlbar innerhalb von 14 Tagen ab Rechnungsdatum.' : ''))}</textarea>
</div>
</form>
`,
footer: `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
${isLocked
? `<button class="btn btn-secondary" data-modal-close class="w-full">Schließen</button>`
: `<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit" style="min-width:0">
${UI.icon('receipt')} ${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>`}
${canCancel ? `
<button class="btn btn-ghost" id="${id}-cancel-invoice"
style="width:100%;color:var(--c-danger);border:1px solid var(--c-danger)">
${UI.icon('x-circle')} Rechnung stornieren
</button>` : ''}
</div>
`,
});
// 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 = `
<input class="form-control inv-item-desc" type="text" placeholder="Beschreibung *"
value="${_esc(desc)}" class="text-sm">
<input class="form-control inv-item-qty" type="number" min="1" value="${qty}"
style="font-size:var(--text-sm);text-align:right" title="Menge">
<input class="form-control inv-item-price" type="number" min="0" step="0.01" value="${price.toFixed(2)}"
style="font-size:var(--text-sm);text-align:right" title="Einzelpreis €">
<button type="button" class="btn btn-sm btn-ghost inv-item-remove"
style="color:var(--c-danger);padding:4px 8px;flex-shrink:0" title="Entfernen">
${UI.icon('x')}
</button>
`;
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 = `
<span class="text-muted">Netto: </span>
<strong>${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} €</strong>
${disc > 0 ? `&nbsp;·&nbsp;<span class="text-danger">-${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)</span>` : ''}
&nbsp;·&nbsp;<span style="color:var(--c-success);font-weight:700">Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} €</span>
`;
}
// 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: `
<form id="${id}" class="flex-col-gap-3">
<div>
<label class="form-label text-xs">Zahlungsdatum *</label>
<input class="form-control" name="paid_at" type="date" value="${today}" required>
</div>
<div>
<label class="form-label text-xs">Eingegangener Betrag (€) *</label>
<input class="form-control" name="paid_amount" id="${id}-amt" type="number" min="0" step="0.01"
value="${defaultAmount.toFixed(2)}" required>
</div>
<div id="${id}-diff" style="display:none;padding:var(--space-2) var(--space-3);
border-radius:var(--radius-md);background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6"></div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('check-circle')} Als bezahlt markieren</button>
`,
});
// 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: <strong>-${diff.toFixed(2)} €</strong> weniger als fakturiert.<br>
<label style="display:flex;align-items:center;gap:6px;margin-top:4px;cursor:pointer">
<input type="checkbox" id="${id}-kulanz">
<span>Als Kulanz/Forderungsverlust abschreiben (Notiz wird automatisch eingetragen)</span>
</label>`;
} else {
diffEl.innerHTML = `Überzahlung: <strong>+${(-diff).toFixed(2)} €</strong> 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: `
<form id="${id}" class="flex-col-gap-3">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
Rechnung <strong>${_esc(invoiceNum)}</strong> stornieren.
</p>
<div>
<label class="form-label text-xs">Stornierungsgrund *</label>
<input class="form-control" name="reason" type="text" required
placeholder="z. B. Kundenwunsch, Doppelabrechnung…">
</div>
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit"
style="background:var(--c-danger);border-color:var(--c-danger)">
${UI.icon('x-circle')} Rechnung stornieren
</button>
`,
});
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 => `
<tr>
<td style="padding:6px 8px">${_esc(item.description)}</td>
<td style="padding:6px 8px;text-align:right">${item.quantity}</td>
<td style="padding:6px 8px;text-align:right">${_fmtEur(item.unit_price)}</td>
<td style="padding:6px 8px;text-align:right;font-weight:600">${_fmtEur(item.total)}</td>
</tr>
`).join('');
UI.modal.open({
title: `${UI.icon('receipt')} ${_esc(inv.invoice_number)}`,
body: `
<div style="display:flex;flex-direction:column;gap:var(--space-3);font-size:var(--text-sm)">
<div class="grid-2">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Empfänger</div>
<div style="font-weight:600">${_esc(inv.recipient_name)}</div>
${inv.recipient_email ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(inv.recipient_email)}</div>` : ''}
${inv.recipient_address ? `<div style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:pre-line;margin-top:2px">${_esc(inv.recipient_address)}</div>` : ''}
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Status</div>
<div style="font-weight:700;color:${statusColors[inv.status] || 'inherit'}">${statusLabels[inv.status] || inv.status}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Erstellt: ${_fmtDate(inv.created_at)}<br>
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}<br>` : ''}
${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}<br>` : ''}
</div>
</div>
</div>
${inv.service_period ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Leistungszeitraum</div>
<div>${_esc(inv.service_period)}</div>
</div>` : ''}
<!-- Positionen -->
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Positionen</div>
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border);color:var(--c-text-muted)">
<th style="text-align:left;padding:4px 8px">Beschreibung</th>
<th style="text-align:right;padding:4px 8px">Menge</th>
<th style="text-align:right;padding:4px 8px">Preis</th>
<th style="text-align:right;padding:4px 8px">Gesamt</th>
</tr>
</thead>
<tbody>${itemsHtml}</tbody>
<tfoot>
<tr style="border-top:2px solid var(--c-border)">
<td colspan="3" style="padding:6px 8px;text-align:right;font-weight:600">Gesamt (brutto)</td>
<td style="padding:6px 8px;text-align:right;font-weight:700;color:var(--c-primary)">${_fmtEur(inv.amount_gross)}</td>
</tr>
</tfoot>
</table>
</div>
${inv.notes ? `
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Notizen</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-xs);white-space:pre-wrap">${_esc(inv.notes)}</div>
</div>` : ''}
</div>
`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
}
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]) => `
<div class="card" style="padding:var(--space-3);text-align:center">
<div style="font-size:var(--text-xl);font-weight:800;color:${statusColors[s] || 'var(--c-text)'}">${n}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${statusLabels[s] || s}</div>
</div>`).join('');
const monthRows = (cf.monthly || []).map((m, i) => `
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
<td class="adm-td">${_esc(m.month)}</td>
<td class="adm-td text-right">${m.count}</td>
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtEur(m.revenue)}</td>
</tr>`).join('');
// Quartalsbericht-Download
const currentYear = new Date().getFullYear();
const years = [currentYear, currentYear - 1].map(y => `<option value="${y}">${y}</option>`).join('');
el.innerHTML = `
<!-- Übersichtskacheln -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)">
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success,#16a34a)">${_fmtEur(cf.total_paid)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Einnahmen (bezahlt)</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${_fmtEur(cf.total_outstanding)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Offene Forderungen</div>
</div>
<div class="card" style="padding:var(--space-4);text-align:center">
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-text)">${_fmtEur(cf.total_year)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Jahresumsatz gesamt</div>
</div>
${countKacheln}
</div>
<!-- Monatliche Tabelle -->
<div class="card adm-table-card mb-4">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
border-bottom:1px solid var(--c-border)">Monatliche Übersicht</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead>
<tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Monat</th>
<th class="adm-th text-right">Rechnungen</th>
<th class="adm-th text-right">Umsatz</th>
</tr>
</thead>
<tbody>
${monthRows || `<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Keine Daten</td></tr>`}
</tbody>
</table>
</div>
</div>
<!-- Quartalsbericht -->
<div class="card p-4">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
${UI.icon('file-csv')} Quartalsbericht herunterladen
</div>
<div style="display:flex;gap:var(--space-3);align-items:flex-end;flex-wrap:wrap">
<div>
<label class="form-label text-xs">Jahr</label>
<select id="adm-inv-year" class="form-control" style="width:auto">${years}</select>
</div>
<div>
<label class="form-label text-xs">Quartal</label>
<select id="adm-inv-quarter" class="form-control" style="width:auto">
<option value="1">Q1 (JanMär)</option>
<option value="2">Q2 (AprJun)</option>
<option value="3">Q3 (JulSep)</option>
<option value="4">Q4 (OktDez)</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" id="adm-inv-csv">
${UI.icon('download-simple')} CSV herunterladen
</button>
<button class="btn btn-ghost btn-sm" id="adm-inv-preview-q">
${UI.icon('eye')} Vorschau
</button>
</div>
<div id="adm-inv-q-result" class="mt-3"></div>
</div>
`;
// 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 = '<div style="color:var(--c-text-muted);font-size:var(--text-xs)">Lade…</div>';
try {
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
if (!data.invoices?.length) {
resultEl.innerHTML = `<div class="text-xs-muted">Keine Rechnungen in ${data.period || `Q${q} ${year}`}.</div>`;
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)
? ` <span class="text-xs-muted">(RG: ${_fmtE(inv.amount_gross)})</span>` : '';
return `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-family:monospace;font-size:var(--text-xs);${isStorno?'color:var(--c-danger)':''}">${_esc(inv.invoice_number)}</td>
<td class="adm-td">${_esc(inv.recipient_name)}</td>
<td class="adm-td" style="text-align:right;font-weight:600;${amtColor}">${_fmtE(effectiveAmt)}${amtNote}</td>
<td class="adm-td" style="${isStorno?'color:var(--c-danger)':''}">${sL[inv.status]||inv.status}</td>
<td class="adm-td text-xs-muted">${_fmtD(inv.created_at)}</td>
</tr>`;
}).join('');
resultEl.innerHTML = `
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);margin-bottom:var(--space-2)">
${_esc(data.period || `Q${q} ${year}`)}${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)}
</div>
<div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2)">
<th class="adm-th">Nummer</th><th class="adm-th">Empfänger</th>
<th class="adm-th text-right">Betrag</th><th class="adm-th">Status</th>
<th class="adm-th">Erstellt</th>
</tr></thead>
<tbody>${rows2}</tbody>
<tfoot><tr style="border-top:2px solid var(--c-border)">
<td colspan="2" class="adm-td" style="font-weight:600">Gesamt</td>
<td class="adm-td" style="text-align:right;font-weight:700;color:var(--c-primary)">${_fmtE(data.total_gross)}</td>
<td colspan="2" class="adm-td text-xs-muted">
Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)}
</td>
</tr></tfoot>
</table>
</div>`;
} catch (e) {
resultEl.innerHTML = `<div style="color:var(--c-danger);font-size:var(--text-xs)">Fehler: ${_esc(e.message)}</div>`;
}
});
}
return { init, refresh, onDogChange };
})();