/* ============================================================
BAN YARO — Admin-Bereich
Nur für Admins und Moderatoren.
============================================================ */
window.Page_admin = (() => {
let _container = null;
let _appState = null;
let _tab = 'uebersicht';
let _delegationAttached = null; // WeakRef-Ersatz: zuletzt mit Delegation versehener Container
const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
{ id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
{ id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
{ id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' },
];
// ------------------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
const u = appState.user;
const isMod = u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
if (!isMod) {
container.innerHTML = _emptyState('shield', 'Kein Zugriff', 'Dieser Bereich ist nur für Admins und Moderatoren.');
return;
}
_render();
}
function refresh() { _renderTab(); }
function onDogChange() {}
// ------------------------------------------------------------------
// SHELL
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
${TABS.map(t => `
${UI.icon(t.icon)} ${t.label}
`).join('')}
`;
_container.querySelector('#adm-tabs')
?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2));
// Event-Delegation: Listener EINMAL pro Container-Instanz setzen
// (innerHTML überschreibt zwar das DOM, aber bei jedem init() würden ohne
// Flag erneut N=18 Tab-Listener akkumuliert werden)
if (_delegationAttached !== _container) {
_container.addEventListener('click', _onContainerClick);
_delegationAttached = _container;
}
_renderActionItems();
_renderTab();
}
// Delegation-Handler für Tab-Buttons + Action-Item-Buttons
function _onContainerClick(e) {
// Tab-Button (#adm-tabs .by-tab)
const tabBtn = e.target.closest('#adm-tabs .by-tab');
if (tabBtn && _container.contains(tabBtn)) {
_tab = tabBtn.dataset.tab;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
return;
}
// Action-Item-Button ([data-action-tab])
const actionBtn = e.target.closest('[data-action-tab]');
if (actionBtn && _container.contains(actionBtn)) {
_tab = actionBtn.dataset.actionTab;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
return;
}
}
async function _renderActionItems() {
const el = _container.querySelector('#adm-action-items');
if (!el) return;
let d;
try { d = await API.get('/admin/action-items'); } catch { return; }
const items = [
{ key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' },
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
{ key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' },
{ key: 'partner_profiles_pending', label: 'Partner-Profile', tab: 'partner', icon: 'handshake' },
];
const open = items.filter(i => d[i.key] > 0);
const usersToday = d.users_today || 0;
el.innerHTML = `
${UI.icon('check-square')} Zu erledigen
${open.length === 0
? `
${UI.icon('check-circle')} Alles erledigt
`
: open.map(i => `
${UI.icon(i.icon)} ${i.label}
${d[i.key]}
`).join('')
}
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
`;
// Klicks auf [data-action-tab] werden zentral via _onContainerClick (Delegation) behandelt
}
async function _renderTab() {
const el = _container.querySelector('#adm-content');
if (!el) return;
el.innerHTML = `Lade…
`;
try {
switch (_tab) {
case 'uebersicht': await _renderStats(el); break;
case 'nutzer': await _renderUsers(el); break;
case 'moderation': await _renderModeration(el); break;
case 'zuchter': await _renderZuechter(el); break;
case 'forum': await _renderForum(el); break;
case 'social': await _renderSocial(el); break;
case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break;
case 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
case 'hilfe': await _renderHilfe(el); break;
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
case 'referrals': await _renderReferrals(el); break;
case 'upgrades': await _renderUpgrades(el); break;
case 'rechnungen': await _renderRechnungen(el); break;
}
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
}
}
// ------------------------------------------------------------------
// TAB: SOCIAL MEDIA
async function _renderSocial(el) {
const d = await API.get('/admin/social');
const _PL = { instagram: '📸 Instagram', tiktok: '🎵 TikTok', both: '📱 Beide' };
const _fmt = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '–';
// Manager-Tabelle
const managerRows = d.managers.map(m => `
${UI.escape(m.name)}
${m.published}
${m.with_link}
${m.published > 0 ? `
(${Math.round(m.with_link/m.published*100)}%) ` : ''}
${m.scheduled}
${m.ideas}
${m.total}
`).join('');
// Plattform-Balken
const maxPlat = Math.max(...d.by_platform.map(p => p.n), 1);
const platBars = d.by_platform.map(p => `
${_PL[p.platform]||p.platform}
${p.n}
`).join('');
// Monats-Timeline
const maxMonth = Math.max(...d.by_month.map(m => m.n), 1);
const monthBars = [...d.by_month].reverse().map(m => `
`).join('');
// Veröffentlichte Posts (mit/ohne Link)
const postRows = d.recent_published.map(p => `
${_fmt(p.published_at)}
${UI.escape(p.topic)}
${_PL[p.platform]||p.platform||'–'}
${UI.escape(p.category||'–')}
${p.ai_score ? '⭐'.repeat(p.ai_score) : '–'}
${UI.escape(p.manager||'–')}
${p.post_url
? `🔗 Link `
: `– `}
`).join('');
el.innerHTML = `
Veröffentlicht nach Plattform
${platBars || '
Noch keine Posts
'}
Posts pro Monat
${monthBars || '
Noch keine Posts
'}
${d.managers.length ? `
` : ''}
Veröffentlichte Posts (letzte 50)
`;
}
// ------------------------------------------------------------------
// TAB: ANALYTICS
async function _renderAnalytics(el) {
el.innerHTML = `Lade Analytics…
`;
let d;
try { d = await API.get('/admin/analytics'); }
catch (err) {
el.innerHTML = `
${UI.icon('warning')} Fehler: ${UI.escape(err.message || String(err))}
`;
return;
}
const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0);
const fmt = v => Number(v).toLocaleString('de');
const pv = d.pageviews?.pageviews ?? [];
const ses = d.pageviews?.sessions ?? [];
// Bounce + Verweildauer
const _bounces = tv(d.today?.bounces), _vis = tv(d.today?.visits);
const bounceToday = _vis > 0 ? ((_bounces / _vis) * 100).toFixed(0) + ' %' : '—';
const _tt = tv(d.week?.totaltime), _vw = tv(d.week?.visits);
const timeWeek = _tt > 0 && _vw > 0 ? Math.round(_tt / _vw) + ' s' : '—';
// Dual-Series Area+Line Chart (SVG)
function _dualChart(pvData, sesData) {
if (!pvData.length) return `Keine Daten
`;
const W = 800, H = 120, padX = 0, padY = 8;
const pvVals = pvData.map(p => p.y ?? 0);
const sesVals = sesData.map(p => p.y ?? 0);
const maxVal = Math.max(...pvVals, ...sesVals, 1);
const n = pvData.length;
function toXY(vals) {
return vals.map((v, i) => {
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
const y = H - padY - (v / maxVal) * (H - 2*padY);
return [x.toFixed(1), y.toFixed(1)];
});
}
const pvPts = toXY(pvVals);
const sesPts = toXY(sesVals);
const pvLine = pvPts.map(p => p.join(',')).join(' ');
const sesLine = sesPts.map(p => p.join(',')).join(' ');
// Filled area unter pageviews
const areaPath = `M ${pvPts[0][0]},${H} ` +
pvPts.map(p => `L ${p[0]},${p[1]}`).join(' ') +
` L ${pvPts[pvPts.length-1][0]},${H} Z`;
// X-Achsen-Labels: Anfang, Mitte, Ende
const labelIdx = [0, Math.floor(n/2), n-1].filter((v,i,a) => a.indexOf(v)===i);
const labels = labelIdx.map(i => {
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
const date = pvData[i]?.x ? new Date(pvData[i].x).toLocaleDateString('de',{day:'2-digit',month:'2-digit'}) : '';
return `${date} `;
}).join('');
return `
${labels}
`;
}
// Balken-Chart für Top-Pages / Referrers
function _barChart(items, labelKey = 'x', valKey = 'y') {
if (!items?.length) return `Keine Daten
`;
const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1);
return `
${items.map(p => {
const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(0);
return `
${UI.escape(p[labelKey] || '—')}
${fmt(p[valKey] ?? 0)}
`;
}).join('')}
`;
}
el.innerHTML = `
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
${_statCard('users', 'Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
${_statCard('users', 'Besucher 30 Tage', fmt(tv(d.month?.visitors)), 'var(--c-text-secondary)')}
${_statCard('eye', 'Aufrufe 30 Tage', fmt(tv(d.month?.pageviews)), 'var(--c-text-secondary)')}
${_statCard('arrow-u-up-left', 'Bounce heute', bounceToday, 'var(--c-text-secondary)')}
${_statCard('timer', 'Ø Verweildauer 7d', timeWeek, 'var(--c-text-secondary)')}
Verlauf — letzte 30 Tage
Aufrufe
Besucher
${_dualChart(pv, ses)}
Jahresübersicht — letzte 12 Monate
Seitenaufrufe
Neuanmeldungen
${(() => {
const pvYear = d.pageviews_year?.pageviews ?? [];
const regs = d.monthly_registrations ?? [];
// Alle Monate der letzten 12 sammeln
const months = [];
const now = new Date();
for (let i = 11; i >= 0; i--) {
const d2 = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(d2.getFullYear() + '-' + String(d2.getMonth()+1).padStart(2,'0'));
}
// Umami liefert x als ISO-Datum-String
const pvByMonth = {};
pvYear.forEach(p => {
const key = (p.x || '').slice(0, 7);
pvByMonth[key] = (pvByMonth[key] || 0) + (p.y ?? 0);
});
const regByMonth = {};
regs.forEach(r => { regByMonth[r.month] = r.count; });
const pvVals = months.map(m => pvByMonth[m] || 0);
const regVals = months.map(m => regByMonth[m] || 0);
const maxPv = Math.max(...pvVals, 1);
const maxReg = Math.max(...regVals, 1);
const W = 800, H = 120, n = months.length;
const barW = Math.floor((W - (n-1)*4) / n);
const bars = months.map((m, i) => {
const x = i * (barW + 4);
const pvH = Math.round((pvVals[i] / maxPv) * (H - 20));
const regH = Math.round((regVals[i] / maxReg) * (H - 20));
const label = m.slice(5); // MM
return `
${label}
`;
}).join('');
return `
${bars} `;
})()}
Top Seiten — 30 Tage
${_barChart(d.top_pages)}
Referrers — 30 Tage
${_barChart(d.referrers)}
`;
}
// ------------------------------------------------------------------
// TAB: ÜBERSICHT
// ------------------------------------------------------------------
async function _renderStats(el) {
const [s, ki, kiH] = await Promise.all([
API.get('/admin/stats'),
API.get('/admin/ki/status').catch(() => null),
API.get('/admin/ki/history').catch(() => null),
]);
const _kiStatusBadge = () => {
if (!ki) return '';
if (ki.mode === 'off') {
return `
KI-Modus: off
`;
}
const dot = ki.local_reachable ? 'var(--c-success)' : 'var(--c-danger)';
const label = ki.local_reachable ? 'Lokal erreichbar' : 'Nicht erreichbar';
const model = ki.local_model_loaded || ki.local_model_config || '?';
return `
${label}
·
${UI.escape(model)}
Modus: ${ki.local_reachable ? 'local' : 'cloud'}
`;
};
el.innerHTML = `
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')}
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')}
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')}
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')}
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')}
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')}
${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')}
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')}
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')}
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')}
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')}
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
KI-Nutzung
${s.ki_today ?? 0} heute · ${s.ki_week ?? 0} (7 Tage)
${_kiStatusBadge()}
${[
['☁️ Claude', s.ki_cloud_week, 'var(--c-primary)'],
['🖥️ Lokal', s.ki_local_week, 'var(--c-success)'],
['🌙 Luna', s.ki_luna_week, 'var(--c-warning)'],
['📅 Monat', s.ki_month, 'var(--c-text-secondary)'],
['👥 User heute',s.ki_users_today, 'var(--c-text-secondary)'],
].map(([label, val, color]) => `
${label}
${val ?? 0}
`).join('')}
${(() => {
const hist = kiH?.daily_history || [];
if (!hist.length) return '
Noch keine Verlaufsdaten
';
const max = Math.max(...hist.map(d => d.total), 1);
const W = 400, H = 60, n = hist.length;
const pts = hist.map((d, i) => {
const x = n === 1 ? W/2 : (i / (n - 1)) * W;
const y = H - 5 - (d.total / max) * (H - 10);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const first = hist[0]?.date?.slice(5) || '';
const last = hist[hist.length-1]?.date?.slice(5) || '';
return `
`;
})()}
Cloud-Limit: ${s.ki_cloud_weekly_limit ?? 20} Anfragen / Woche pro User
${(kiH?.top_users || []).length ? `
Aktivste Nutzer
` : ''}
${(() => {
const POI_LABELS = {
waste_basket: 'Mülleimer', dog_park: 'Hundewiese', drinking_water: 'Wasserstelle',
tierarzt: 'Tierarzt', hundesalon: 'Hundesalon', hundeschule: 'Hundeschule',
shop: 'Shop', restaurant: 'Café / Restaurant', bank: 'Sitzbank',
hotel: 'Hotel', freilauf: 'Freilauf', kotbeutel: 'Kotbeutel',
parkplatz: 'Parkplatz', treffpunkt: 'Treffpunkt', sonstiges: 'Sonstiges',
giftkoeder: 'Giftköder', gefahr: 'Gefahr',
};
const label = t => POI_LABELS[t] || t;
const row = ([type, count]) => `
${label(type)}
${count.toLocaleString('de')}
`;
const userByType = s.user_poi_by_type || {};
const userTotal = s.user_poi_total ?? 0;
return `
OSM-Cache nach Typ — ${(s.osm_total || 0).toLocaleString('de')} gecacht
${Object.entries(s.osm_by_type).map(row).join('')}
Nutzer-POIs nach Typ — ${userTotal.toLocaleString('de')} gesamt
${Object.keys(userByType).length
? Object.entries(userByType).map(row).join('')
: '
Noch keine Nutzer-POIs
'}
`;
})()}
📱 Social Media Tracking
${[
['Gesamt generiert', s.social_total, 'var(--c-text-secondary)'],
['Veröffentlicht', s.social_published, 'var(--c-success)'],
['Geplant', s.social_scheduled, 'var(--c-primary)'],
['Ideen offen', s.social_ideas, 'var(--c-warning)'],
['Diese Woche live', s.social_this_week, 'var(--c-success)'],
].map(([label, val, color]) => `
`).join('')}
${Object.keys(s.social_by_cat||{}).length ? `
Kategorien
${Object.entries(s.social_by_cat).map(([cat, n]) => `
${cat} ${n} `).join('')}
` : ''}
${s.social_recent?.length ? `
Letzte 10 Posts
${s.social_recent.map(p => `
${p.status}
${p.topic}
${(p.published_at||p.created_at||'').slice(0,10)}
`).join('')}
` : ''}
Ersten Admin per SQL setzen:
UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de';
`;
el.querySelector('#adm-overview-grid')?.addEventListener('click', e => {
const card = e.target.closest('[data-adm-tab]');
if (!card) return;
const tab = card.dataset.admTab;
_container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click();
});
}
function _statCard(icon, label, value, color, tab = null) {
const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
return `
`;
}
// ------------------------------------------------------------------
// TAB: NUTZER
// ------------------------------------------------------------------
async function _renderUsers(el) {
el.innerHTML = `
Alle Rollen
user
moderator
admin
Lade…
`;
const load = async () => {
const q = el.querySelector('#adm-user-q').value;
const rolle = el.querySelector('#adm-user-rolle').value;
const data = await API.get(`/admin/users?q=${encodeURIComponent(q)}&rolle=${rolle}`);
_renderUserList(el.querySelector('#adm-user-list'), data.users, data.total);
};
let timer;
el.querySelector('#adm-user-q').addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(load, 350);
});
el.querySelector('#adm-user-rolle').addEventListener('change', load);
await load();
}
function _renderUserList(el, users, total) {
if (!users.length) {
el.innerHTML = _emptyState('users', 'Keine Nutzer gefunden', '');
return;
}
const isAdmin = _appState.user?.rolle === 'admin';
el.innerHTML = `
${total} Nutzer gefunden
${users.map(u => `
${UI.escape(u.name[0].toUpperCase())}
${UI.escape(u.name)}
${u.is_banned ? `
GESPERRT ` : ''}
${UI.escape(u.email)} ·
${UI.escape(u.rolle)}
·
${UI.escape(u.subscription_tier || 'standard')}
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
🗺 ${u.route_count} Routen · ${u.total_km} km
· 📍 ${u.poi_count} POIs
${u.last_seen
? '🟢 zuletzt aktiv ' + new Date(u.last_seen).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: u.last_login
? '🔵 zuletzt eingeloggt ' + new Date(u.last_login).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
: '⚪ nie aktiv'}
${u.is_banned
? `
`
: `
`
}
${isAdmin ? `
` : ''}
`).join('')}
`;
// Events
el.querySelectorAll('.adm-ban').forEach(btn => {
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, true));
});
el.querySelectorAll('.adm-unban').forEach(btn => {
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, false));
});
el.querySelectorAll('.adm-rolle').forEach(btn => {
btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle));
});
el.querySelectorAll('.adm-tier').forEach(btn => {
btn.addEventListener('click', () => _changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier));
});
el.querySelectorAll('.adm-delete').forEach(btn => {
btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name));
});
}
async function _banUser(uid, name, ban) {
if (ban) {
const reason = await _prompt(`${name} sperren — Grund (optional):`);
if (reason === null) return; // abgebrochen
try {
await API.patch(`/admin/users/${uid}`, { is_banned: 1, ban_reason: reason || 'Kein Grund angegeben.' });
UI.toast.success(`${name} gesperrt.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
} else {
try {
await API.patch(`/admin/users/${uid}`, { is_banned: 0, ban_reason: null });
UI.toast.success(`Sperre für ${name} aufgehoben.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
}
}
async function _changeRolle(uid, name, currentRolle) {
const rollen = ['user', 'moderator', 'admin'].filter(r => r !== currentRolle);
UI.modal.open({
title: `Rolle ändern: ${name}`,
body: `
Aktuelle Rolle: ${currentRolle}
${rollen.map(r => `
${r === 'admin' ? ` ` : ''}
${r === 'moderator' ? ` ` : ''}
${r === 'user' ? ` ` : ''}
Als ${r} setzen
`).join('')}
`,
});
document.querySelectorAll('.adm-rolle-choice').forEach(btn => {
btn.addEventListener('click', async () => {
UI.modal.close();
try {
await API.patch(`/admin/users/${uid}`, { rolle: btn.dataset.rolle });
UI.toast.success(`${name} ist jetzt ${btn.dataset.rolle}.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
});
});
}
async function _changeTier(uid, name, currentTier) {
const tiers = ['standard', 'pro', 'breeder', 'standard_test', 'pro_test', 'breeder_test'];
const tierLabels = {
standard: 'Standard (kostenlos)',
pro: 'Pro (bezahlt)',
breeder: 'Breeder (Züchter)',
standard_test: 'Standard Test (intern)',
pro_test: 'Pro Test (intern)',
breeder_test: 'Breeder Test (intern)',
};
UI.modal.open({
title: `Abo-Stufe ändern: ${name}`,
body: `
Aktuelle Stufe: ${currentTier}
${tiers.map(t => `
${tierLabels[t]}${t === currentTier ? ' ✓' : ''}
`).join('')}
`,
});
document.querySelectorAll('.adm-tier-choice:not([disabled])').forEach(btn => {
btn.addEventListener('click', async () => {
UI.modal.close();
try {
await API.patch(`/admin/users/${uid}`, { subscription_tier: btn.dataset.tier });
// Eigenes Tier geändert → User-State neu laden + Welten neu rendern
if (String(uid) === String(_appState?.user?.id)) {
_appState.user.subscription_tier = btn.dataset.tier;
if (window.Worlds) {
window.Worlds.init(_appState);
}
UI.toast.success(`Dein Tier ist jetzt ${btn.dataset.tier} — Ansicht aktualisiert.`);
} else {
UI.toast.success(`${name}: Abo-Stufe ist jetzt ${btn.dataset.tier}.`);
}
_renderTab();
} catch (e) { UI.toast.error(e.message); }
});
});
}
async function _deleteUser(uid, name) {
const ok = await UI.modal.confirm({
title: `${name} löschen?`,
message: 'Alle Daten dieses Accounts werden unwiderruflich gelöscht — Hunde, Tagebuch, Beiträge.',
confirmText: 'Endgültig löschen',
});
if (!ok) return;
try {
await API.del(`/admin/users/${uid}`);
UI.toast.success(`${name} gelöscht.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
}
// ------------------------------------------------------------------
// TAB: FORUM & MELDUNGEN
// ------------------------------------------------------------------
async function _renderForum(el) {
el.innerHTML = `
Offene Meldungen
Alle Threads
Lade…
`;
el.querySelectorAll('.adm-forum-nav').forEach(btn => {
btn.addEventListener('click', async () => {
el.querySelectorAll('.adm-forum-nav').forEach(b => {
b.className = b === btn ? 'btn btn-primary btn-sm adm-forum-nav' : 'btn btn-ghost btn-sm adm-forum-nav';
});
await _renderForumView(el.querySelector('#adm-forum-content'), btn.dataset.view);
});
});
await _renderForumView(el.querySelector('#adm-forum-content'), 'reports');
}
async function _renderForumView(el, view) {
el.innerHTML = 'Lade…
';
if (view === 'reports') {
const reports = await API.get('/admin/reports');
if (!reports.length) {
el.innerHTML = _emptyState('check', 'Keine offenen Meldungen', 'Alles sauber.');
return;
}
el.innerHTML = `
${reports.map(r => `
${r.resolved ? '✓ Erledigt · ' : ''}
${UI.escape(r.target_type)} #${r.target_id} ·
Gemeldet von ${UI.escape(r.melder_name)}
Grund: ${UI.escape(r.grund)}
${r.content_preview ? `
${UI.escape(r.content_preview)}
` : ''}
${!r.resolved ? `
` : ''}
`).join('')}
`;
el.querySelectorAll('.adm-resolve-btn').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await API.patch(`/admin/reports/${btn.dataset.rid}`, {});
_renderForumView(el, 'reports');
} catch (e) { UI.toast.error(e.message); }
});
});
el.querySelectorAll('.adm-del-content').forEach(btn => {
btn.addEventListener('click', () => _deleteContent(btn.dataset.type, btn.dataset.id, el, 'reports'));
});
} else {
// Threads
el.innerHTML = `
Gelöschte
Lade…
`;
const loadThreads = async () => {
const q = el.querySelector('#adm-thread-q').value;
const deleted = el.querySelector('#adm-show-deleted').checked ? 1 : 0;
const data = await API.get(`/admin/forum/threads?q=${encodeURIComponent(q)}&deleted=${deleted}`);
_renderThreadList(el.querySelector('#adm-thread-list'), data.threads, el);
};
let t2;
el.querySelector('#adm-thread-q').addEventListener('input', () => { clearTimeout(t2); t2 = setTimeout(loadThreads, 350); });
el.querySelector('#adm-show-deleted').addEventListener('change', loadThreads);
await loadThreads();
}
}
function _renderThreadList(el, threads, parentEl) {
if (!threads.length) {
el.innerHTML = _emptyState('chat-circle-dots', 'Keine Threads', '');
return;
}
el.innerHTML = `
${threads.map(t => `
${t.is_deleted ? '' : ''}${UI.escape(t.titel)}${t.is_deleted ? ' ' : ''}
von ${UI.escape(t.autor_name)} ·
${t.antworten} Antworten ·
${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''}
${!t.is_deleted ? `
` : `
`}
`).join('')}
`;
el.querySelectorAll('.adm-pin').forEach(btn => {
btn.addEventListener('click', async () => {
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_pinned: btn.dataset.pinned === '1' ? 0 : 1 });
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
});
});
el.querySelectorAll('.adm-lock').forEach(btn => {
btn.addEventListener('click', async () => {
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_locked: btn.dataset.locked === '1' ? 0 : 1 });
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
});
});
el.querySelectorAll('.adm-del-thread').forEach(btn => {
btn.addEventListener('click', () => _deleteContent('thread', btn.dataset.tid, parentEl, 'threads'));
});
el.querySelectorAll('.adm-restore-thread').forEach(btn => {
btn.addEventListener('click', async () => {
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_deleted: 0 });
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
});
});
}
async function _deleteContent(type, id, parentEl, view) {
const ok = await UI.modal.confirm({
title: `${type === 'thread' ? 'Thread' : 'Beitrag'} löschen?`,
message: 'Der Inhalt wird als gelöscht markiert.',
confirmText: 'Löschen',
});
if (!ok) return;
try {
await API.del(`/admin/forum/${type === 'thread' ? 'threads' : 'posts'}/${id}`);
UI.toast.success('Gelöscht.');
_renderForumView(parentEl, view);
} catch (e) { UI.toast.error(e.message); }
}
// ------------------------------------------------------------------
// TAB: SYSTEM
// ------------------------------------------------------------------
async function _renderSystem(el) {
el.innerHTML = `
${UI.icon('arrows-clockwise')} Aktualisieren
Lade…
Medien
${UI.icon('images')} Previews generieren (Bestand)
Wiki-Daten
${UI.icon('arrows-clockwise')} Enrichment-Status
${UI.icon('chart-bar')} Qualitätsbewertung (20 Rassen)
${UI.icon('image')} Fotos nachladen
Server-Logs
Alle
INFO
WARNING
ERROR
${UI.icon('arrows-clockwise')}
Lade…
`;
const loadLogs = async () => {
const level = el.querySelector('#adm-log-level').value;
const box = el.querySelector('#adm-log-box');
box.textContent = 'Lade…';
const rows = await API.get(`/admin/logs?lines=200${level ? '&level=' + level : ''}`);
const COLORS = { ERROR: '#ef4444', WARNING: '#f59e0b', INFO: '#6b7280', DEBUG: '#94a3b8' };
box.innerHTML = rows.reverse().map(r => {
const color = COLORS[r.l] || '#6b7280';
return `` +
`${r.t} ` +
`${r.l} ` +
`${UI.escape(r.n)} ` +
`${UI.escape(r.m)}
`;
}).join('') || 'Keine Einträge ';
};
el.querySelector('#adm-sys-refresh').addEventListener('click', () => {
_loadSystemCards(el.querySelector('#adm-sys-cards'));
loadLogs();
});
el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs);
el.querySelector('#adm-log-level').addEventListener('change', loadLogs);
el.querySelector('#adm-generate-previews').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
btn.disabled = true;
res.textContent = 'Generiere Previews… (kann 1–2 Minuten dauern)';
try {
const d = await API.post('/admin/media/generate-previews', {});
res.textContent = `✓ ${d.generated} neu generiert · ${d.skipped} bereits vorhanden · ${d.errors} Fehler`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
el.querySelector('#adm-enrichment-status').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
btn.disabled = true;
res.textContent = 'Lade…';
try {
const d = await API.get('/admin/wiki/enrichment-status');
const modelList = Object.entries(d.by_model)
.map(([m, n]) => `${m}: ${n}`).join(', ');
res.textContent = `Gesamt: ${d.total} | Angereichert: ${d.enriched} | Kein Wiki: ${d.no_wiki} | Ausstehend: ${d.pending} | Mit Foto: ${d.with_photo} | Modelle: ${modelList || '–'}`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
el.querySelector('#adm-fetch-photos').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
btn.disabled = true;
res.textContent = 'Fotos werden geladen… (kann 30–60s dauern)';
try {
const d = await API.post('/admin/wiki/fetch-photos?limit=50', {});
res.textContent = `✓ ${d.found} Foto(s) gespeichert`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
el.querySelector('#adm-evaluate-breeds').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
const box = el.querySelector('#adm-eval-result');
btn.disabled = true;
res.textContent = 'Bewertung läuft… (ca. 30s)';
box.style.display = 'none';
try {
const d = await API.get('/admin/wiki/evaluate?sample=20');
if (d.error) { res.textContent = '✗ ' + d.error; return; }
const avg = d.averages;
const scoreColor = v => v >= 4 ? 'var(--c-success)' : v >= 3 ? 'var(--c-warning)' : 'var(--c-danger)';
const scoreBar = v => `${v.toFixed(1)} `;
const rows = d.results.filter(r => !r.error).map(r =>
`
${UI.escape(r.name)}
${scoreBar(r.vollstaendigkeit)}
${scoreBar(r.korrektheit)}
${scoreBar(r.sprachqualitaet)}
${scoreBar(r.konsistenz)}
${scoreBar(r.gesamt)}
${UI.escape(r.hinweis || '')}
`
).join('');
box.style.display = 'block';
box.innerHTML = `
Ø-Scores (${d.evaluated}/${d.sample_size} Rassen bewertet)
${['vollstaendigkeit','korrektheit','sprachqualitaet','konsistenz','gesamt'].map(k =>
`
${avg[k]?.toFixed(1) ?? '–'}
${{vollstaendigkeit:'Vollst.',korrektheit:'Korrekt.',sprachqualitaet:'Sprache',konsistenz:'Konsistenz',gesamt:'Gesamt'}[k]}
`
).join('')}
Rasse
Vollst. Korrekt.
Sprache Konsis.
Ges. Hinweis
${rows}
`;
const judge = d.judge_source === 'cloud' ? 'Claude (Cloud)'
: d.judge_source === 'local' ? 'lokales Modell ⚠︎'
: (d.judge_source || '–');
res.textContent = `✓ Bewertung abgeschlossen — Prüfer: ${judge}`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
const [, orsStats] = await Promise.all([
_loadSystemCards(el.querySelector('#adm-sys-cards')),
API.get('/admin/ors/stats').catch(() => null),
]);
_renderOrsCard(el.querySelector('#adm-ors-card'), orsStats);
await loadLogs();
}
async function _loadSystemCards(el) {
el.innerHTML = `Lade…
`;
const s = await API.get('/admin/system');
const diskUsedGb = s.disk_total_gb - s.disk_free_gb;
const diskPct = s.disk_total_gb > 0 ? Math.round(diskUsedGb / s.disk_total_gb * 100) : 0;
const uptime = _formatUptime(s.uptime_seconds);
el.innerHTML = `
${_statCard('database', 'Datenbank', s.db_size_mb.toFixed(1) + ' MB', 'var(--c-primary)')}
${_statCard('image', 'Media-Ordner', s.media_size_mb.toFixed(1) + ' MB','var(--c-text-secondary)')}
${_statCard('timer', 'Uptime', uptime, 'var(--c-success)')}
${_statCard('hard-drive','Disk frei', s.disk_free_gb.toFixed(1) + ' GB','diskPct > 85 ? "var(--c-danger)" : "var(--c-text-secondary)"')}
Disk-Auslastung
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
Python ${UI.escape(s.python_version)}
APP v${typeof APP_VER !== 'undefined' ? APP_VER : '—'} · ${UI.escape(s.sw_version || '?')}
`;
}
function _renderOrsCard(el, d) {
if (!el) return;
if (!d || d.today_count == null) { el.innerHTML = ''; return; }
const limit = d.today_limit ?? 2000;
const count = d.today_count ?? 0;
const pct = Math.min(Math.round(count / limit * 100), 100);
const barColor = pct < 50 ? '#4ade80' : pct <= 80 ? '#facc15' : '#f87171';
// Wochensumme + Gesamtsumme aus daily_history
const hist7 = (d.daily_history || []).slice(-7);
const weekTotal = hist7.reduce((s, h) => s + (h.count ?? 0), 0);
const totalAll = (d.daily_history || []).reduce((s, h) => s + (h.count ?? 0), 0);
// Sparkline aus daily_history (letzte 30 Tage)
const hist = Array.isArray(d.daily_history) ? d.daily_history : [];
const W = 400, H = 60, padY = 5;
let sparkline = '';
if (hist.length >= 2) {
const maxC = Math.max(...hist.map(h => h.count), 1);
const pts = hist.map((h, i) => {
const x = (i / (hist.length - 1)) * W;
const y = H - padY - (h.count / maxC) * (H - 2 * padY);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
sparkline = ` `;
} else {
sparkline = ` `;
}
const lastDate = hist.length ? hist[hist.length - 1].date : '';
// Top-Nutzer-Tabelle
const topUsers = Array.isArray(d.top_users) ? d.top_users.slice(0, 10) : [];
const userRows = topUsers.map(u => {
const emailDisplay = (u.email || '').length > 20
? '@' + (u.email || '').split('@')[1]
: UI.escape(u.email || '');
return `
${UI.escape(u.name || '–')}
${emailDisplay}
${u.total ?? 0}
${u.last_week || '–'}
`;
}).join('');
el.innerHTML = `
OpenRouteService
${count.toLocaleString('de')} / ${limit.toLocaleString('de')} heute
${weekTotal} diese Woche · ${totalAll} gesamt (30 Tage)
${sparkline}
30 Tage
${UI.escape(lastDate)}
${topUsers.length ? `
Aktivste Nutzer
Name
E-Mail
Gesamt
Letzte Woche
${userRows}
` : ''}
`;
}
function _formatUptime(secs) {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}min`;
return `${m}min`;
}
// ------------------------------------------------------------------
// TAB: JOBS
// ------------------------------------------------------------------
// TAB: MODERATION
// ------------------------------------------------------------------
function _ageLabel(createdAt) {
if (!createdAt) return '';
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
const overdue = h >= 24;
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
return `
${overdue ? '⚠️ ' : ''}${label}
`;
}
function _historySection(label, items, renderItem) {
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
return `
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
${items.map(item => `
${renderItem(item)}
`).join('')}
`;
}
async function _renderModeration(el) {
el.innerHTML = `
${UI.icon('arrows-clockwise')} Aktualisieren
Lade…
`;
el.querySelector('#adm-mod-refresh').addEventListener('click', () => _loadModeration(el.querySelector('#adm-mod-content')));
await _loadModeration(el.querySelector('#adm-mod-content'));
}
async function _loadModeration(el) {
el.innerHTML = `Lade…
`;
const [zuchter, fotos, reports, poiEdits] = await Promise.all([
API.get('/wiki/zuchter/pending').catch(() => []),
API.get('/wiki/foto-submissions').catch(() => []),
API.get('/moderation/reports').catch(() => []),
API.get('/moderation/poi-edits').catch(() => []),
]);
const zuchterPending = zuchter.filter(z => !z.verified);
const zuchterDone = zuchter.filter(z => z.verified);
const fotosPending = fotos.filter(f => f.status === 'pending');
const fotosDone = fotos.filter(f => f.status !== 'pending');
const reportsPending = reports.filter(r => !r.resolved);
const reportsDone = reports.filter(r => r.resolved);
const poiPending = poiEdits.filter(e => e.status === 'pending');
const poiDone = poiEdits.filter(e => e.status !== 'pending');
const modItems = [
{ label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' },
{ label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' },
{ label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' },
{ label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' },
].filter(i => i.count > 0);
let html = `
${UI.icon('check-square')} Zu erledigen
${modItems.length === 0
? `
${UI.icon('check-circle')} Alles erledigt
`
: modItems.map(i => `
${UI.icon(i.icon)} ${i.label}
${i.count}
`).join('')
}
`;
// --- Züchter-Einreichungen ---
html += `
Züchter-Einreichungen
${zuchterPending.length}
`;
if (!zuchterPending.length) {
html += `Keine ausstehenden Einreichungen.
`;
} else {
html += ``;
}
// Züchter-History
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
z => `${UI.escape(z.name)} · ${UI.escape(z.rasse_slug)} ·
${UI.icon('check-circle')} ${UI.escape(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`);
// --- Wiki-Foto-Einreichungen ---
html += `
Wiki-Foto-Einreichungen
${fotosPending.length}
`;
if (!fotosPending.length) {
html += `Keine ausstehenden Foto-Einreichungen.
`;
} else {
html += `
${fotosPending.map(f => `
${UI.escape(f.rasse_name)}
von ${UI.escape(f.user_name)}
${_ageLabel(f.created_at)}
${f.aktuell_foto ? `
↑ aktuelles Foto
` : ''}
✓
✗
`).join('')}
`;
}
// Fotos-History
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
f => `
${UI.escape(f.rasse_name||'?')} · von ${UI.escape(f.user_name||'?')} ·
${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`}
${f.reviewed_by_name ? ` von ${UI.escape(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`);
// --- Forum-Meldungen ---
html += `
Forum-Meldungen
${reportsPending.length}
`;
if (!reportsPending.length) {
html += `Keine offenen Meldungen.
`;
} else {
html += `
${reportsPending.map(r => `
${UI.escape(r.target_type)} #${r.target_id} · Gemeldet von ${UI.escape(r.melder_name || '?')}
${_ageLabel(r.created_at)}
Grund: ${UI.escape(r.grund)}
${r.content_preview ? `
${UI.escape(r.content_preview)}
` : ''}
${UI.icon('check')}
`).join('')}
`;
}
// Meldungen-History
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
r => `${UI.escape(r.target_type)} #${r.target_id} · ${UI.escape(r.grund)} · Gemeldet von ${UI.escape(r.melder_name||'?')} ·
${UI.icon('check-circle')} ${UI.escape(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`);
// --- POI-Korrekturen ---
html += `
POI-Korrekturen
${poiPending.length}
`;
if (!poiPending.length) {
html += `Keine ausstehenden POI-Korrekturen.
`;
} else {
html += ``;
}
// POI-History
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
e => `${UI.escape(e.poi_name||`OSM #${e.osm_id}`)} ·
${UI.escape(e.field)}:
${UI.escape(e.old_value||'—')} →
${UI.escape(e.new_value||'—')} ·
${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`}
${e.mod_name ? ` von ${UI.escape(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`);
el.innerHTML = html;
// Züchter freigeben
el.querySelectorAll('.adm-zuchter-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await API.patch(`/wiki/zuchter/${btn.dataset.id}/verify`, {});
await _loadModeration(el);
});
});
// Züchter löschen
el.querySelectorAll('.adm-zuchter-delete').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Eintrag löschen?')) return;
btn.disabled = true;
await API.delete(`/admin/wiki/zuchter/${btn.dataset.id}`);
await _loadModeration(el);
});
});
// Foto freigeben
el.querySelectorAll('.adm-foto-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await API.patch(`/wiki/foto-submissions/${btn.dataset.id}`, {action: 'approve'});
await _loadModeration(el);
});
});
// Foto ablehnen
el.querySelectorAll('.adm-foto-reject').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
await API.patch(`/wiki/foto-submissions/${btn.dataset.id}`, {action: 'reject', reject_reason: 'Nicht geeignet.'});
await _loadModeration(el);
});
});
// Forum-Meldung erledigen
el.querySelectorAll('.adm-mod-resolve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur freigeben
el.querySelectorAll('.adm-poi-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' });
UI.toast.success('Korrektur übernommen.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur ablehnen
el.querySelectorAll('.adm-poi-reject').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' });
UI.toast.success('Korrektur abgelehnt.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
}
// ------------------------------------------------------------------
// TAB: ZÜCHTER-ANTRÄGE
// ------------------------------------------------------------------
async function _renderZuechter(el) {
el.innerHTML = `
${UI.icon('arrows-clockwise')} Aktualisieren
Lade…
Lade…
`;
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => {
_loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege'));
_loadZuechterListe(el.querySelector('#adm-zuchter-liste'));
});
await Promise.all([
_loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')),
_loadZuechterListe(el.querySelector('#adm-zuchter-liste')),
]);
}
async function _loadZuechterAntraege(el) {
el.innerHTML = `Lade…
`;
let antraege;
try {
antraege = await API.breeder.pendingList();
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Anträge konnten nicht geladen werden.');
return;
}
if (!antraege.length) {
el.innerHTML = `
${UI.icon('check-circle')} Keine offenen Anträge
`;
return;
}
el.innerHTML = `
${antraege.map(a => `
${UI.escape(a.name)}
${UI.escape(a.email)}
${UI.icon('paw-print')} ${UI.escape(a.rasse_text || '–')}
${UI.icon('house-line')} ${UI.escape(a.zwingername || '–')}
${UI.icon('users')} ${UI.escape(a.verein || '–')}
${UI.icon('map-pin')} ${UI.escape(a.stadt || '–')}
${UI.icon('certificate')} VDH: ${a.vdh_mitglied ? 'ja' : 'nein'}
${a.created_at ? `${UI.icon('clock')} ${new Date(a.created_at).toLocaleDateString('de-DE')} ` : ''}
${a.beschreibung ? `
${UI.escape(a.beschreibung)}
` : ''}
${UI.icon('file-text')} Dokumente
${UI.icon('check')} Freischalten
${UI.icon('x')} Ablehnen
`).join('')}
`;
// Dokumente anzeigen
el.querySelectorAll('.adm-breeder-docs').forEach(btn => {
btn.addEventListener('click', async () => {
const uid = btn.dataset.uid;
let docs;
try {
docs = await API.breeder.documents(uid);
} catch (e) {
UI.toast.error(e.message || 'Dokumente konnten nicht geladen werden.');
return;
}
UI.modal.open({
title: `${UI.icon('file-text')} Hochgeladene Dokumente`,
body: docs.length
? ``
: `Keine Dokumente hochgeladen.
`,
});
});
});
// Freischalten
el.querySelectorAll('.adm-breeder-approve').forEach(btn => {
btn.addEventListener('click', async () => {
const ok = window.confirm(`${btn.dataset.name} als Züchter freischalten?`);
if (!ok) return;
btn.disabled = true;
try {
const res = await API.breeder.approve(btn.dataset.uid);
UI.toast.success(res.message || `${btn.dataset.name} freigeschaltet.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Freischaltung fehlgeschlagen.');
btn.disabled = false;
}
});
});
// Ablehnen
el.querySelectorAll('.adm-breeder-reject').forEach(btn => {
btn.addEventListener('click', () => {
const uid = btn.dataset.uid;
const name = btn.dataset.name;
UI.modal.open({
title: `${UI.icon('x-circle')} Antrag ablehnen: ${name}`,
body: `
`,
footer: `
Antrag ablehnen
Abbrechen
`,
});
document.getElementById('breeder-reject-form')?.addEventListener('submit', async ev => {
ev.preventDefault();
const grund = document.getElementById('breeder-reject-grund')?.value?.trim();
if (!grund) {
UI.toast.warning('Bitte einen Ablehnungsgrund angeben.');
return;
}
const submitBtn = document.getElementById('breeder-reject-submit');
if (submitBtn) submitBtn.disabled = true;
try {
const res = await API.breeder.reject(uid, grund);
UI.modal.close?.();
UI.toast.success(res.message || `Antrag von ${name} abgelehnt.`);
await _loadZuechterAntraege(el);
} catch (e) {
UI.toast.error(e.message || 'Ablehnung fehlgeschlagen.');
if (submitBtn) submitBtn.disabled = false;
}
});
});
});
}
async function _loadZuechterListe(el) {
el.innerHTML = `Lade…
`;
let breeders;
try {
breeders = await API.breeder.allList();
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message);
return;
}
const tierBadge = t => {
if (t === 'breeder') return `Züchter-Abo `;
if (t === 'breeder_test') return `Test `;
return `Standard `;
};
const rows = breeders.map(b => `
${UI.escape(b.name)}
${UI.escape(b.email)}
${UI.escape(b.zwingername || '—')}
${UI.escape(b.rasse_text || '—')}
${UI.escape(b.stadt || '—')}
${b.wuerfe_count || 0} Würfe
${b.hunde_count || 0} Hunde
${tierBadge(b.subscription_tier)}
${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'}
Abo
`).join('');
el.innerHTML = `
`;
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 = `
${UI.icon('arrows-clockwise')} Aktualisieren
Lade…
`;
el.querySelector('#adm-jobs-refresh').addEventListener('click', () => _loadJobs(el.querySelector('#adm-jobs-list')));
await _loadJobs(el.querySelector('#adm-jobs-list'));
}
async function _loadJobs(el) {
el.innerHTML = `Lade…
`;
const jobs = await API.get('/admin/scheduler/jobs');
if (!jobs.length) {
el.innerHTML = _emptyState('timer', 'Keine Jobs', 'Der Scheduler hat keine registrierten Jobs.');
return;
}
el.innerHTML = `
`;
el.querySelectorAll('.adm-job-trigger').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.post(`/admin/scheduler/trigger/${encodeURIComponent(btn.dataset.id)}`, {});
UI.toast.success(`Job "${btn.dataset.name}" wird ausgeführt.`);
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Auslösen des Jobs.');
} finally {
btn.disabled = false;
}
});
});
}
function _formatDateTime(iso) {
try {
const d = new Date(iso);
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
} catch { return iso; }
}
// ------------------------------------------------------------------
// TAB: AUDIT-LOG
// ------------------------------------------------------------------
async function _renderPartner(el) {
const codes = (await API.get('/admin/partner/codes')) || [];
const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || [];
const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || [];
el.innerHTML = `
So funktioniert das Partner-System
1. Partner-Code erstellen — Erstelle einen Code (z. B. HUNDEBLOG) für einen Influencer oder Partner. Der Code wird an die Person weitergegeben.
2. Registrierung mit Code — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als Gründer markiert (Platz #1–100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.
3. Partner-Status vergeben — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt Partner-Badge (blaues Badge im Profil) und Gründer-Lizenz . So ist auch er als Gründer #X sichtbar.
Max. 100 Gründer — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.
Freunde werben — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — dauerhaft auf Ban Yaro Pro. Gründer können zusätzlich ihren Geworbenen 50 % schenken (begrenzt durch ihre Gründer-Tickets, Standard 25).
Neuen Partner-Code erstellen
${UI.icon('plus')} Code erstellen
Aktive Codes
${codes.length === 0
? `
Noch keine Partner-Codes angelegt.
`
: `
Code
Bezeichnung
Nutzungen
Gründer
${codes.map(c => `
${c.code}
${c.active ? '' : `⏸ pausiert
`}
${c.label}
${c.owner_name
? `👤 ${UI.escape(c.owner_name)}`
: `👤 Besitzer zuordnen `}
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
${c.grants_founder ? '✓' : '—'}
${c.uses > 0 ? `
${UI.icon('users')}
` : ''}
${c.active ? '⏸ Pausieren' : '▶ Aktivieren'}
${UI.icon('trash')} Löschen
Lädt…
`).join('')}
`
}
QR-Kontingente
Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln
rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt.
${UI.icon('qr-code')} Kontingent erstellen
${qrBatches.length === 0
? `
Noch keine Kontingente bestellt.
`
: `
Code
Kontingent
Stk.
Scans
Registr.
Versuche
${qrBatches.map(b => `
${UI.escape(b.code)}
${UI.escape(b.label)}${(b.created_at || '').slice(0, 10)}
${b.quantity}
${b.scans}
${b.registrations}
${b.attempts}
${b.registrations + b.attempts > 0 ? `
${UI.icon('users')}
` : ''}
${UI.icon('file-pdf')} PDF
${UI.icon('trash')}
Lädt…
`).join('')}
`}
Profil-Freigaben
${profiles.filter(p => p.submitted_at && p.approved === 0).length
? `${profiles.filter(p => p.submitted_at && p.approved === 0).length} offen ` : ''}
${profiles.length === 0
? `
Noch keine Partner-Profile angelegt.
`
: profiles.map(p => `
${p.logo_url
? `
`
: `
`}
${UI.escape(p.display_name || p.name)}
${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}
${p.approved === 1
? `
✓ Frei `
: p.approved === -1
? `
✗ Abgelehnt `
: p.submitted_at
? `
⏳ Prüfen `
: `
Entwurf `}
${UI.icon('eye')} Vorschau
${p.approved !== 1 ? `
✓ Freigeben ` : ''}
${p.approved !== -1 ? `
✗ ` : ''}
${UI.icon('eye')} So erscheint die Karte auf der Partner-Seite:
${p.logo_url
? `
`
: `
${UI.escape((p.display_name || p.name || '?')[0].toUpperCase())}
`}
${UI.escape(p.display_name || p.name)}
${p.tagline ? `
${UI.escape(p.tagline)}
` : ''}
${p.bio ? `
${UI.escape(p.bio)}
` : ''}
${p.photos?.length ? `
${p.photos.slice(0, 3).map(url => {
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
return isVid
? `
`
: `
`;
}).join('')}
` : ''}
`).join('')}
`;
// Alle Einlösungen eines Codes (lazy, .hidden via classList) — mit Kanal-Spalte
el.querySelectorAll('.adm-code-regs').forEach(btn => {
btn.addEventListener('click', async () => {
const row = el.querySelector(`#adm-code-regs-${btn.dataset.id}`);
if (!row) return;
row.classList.toggle('hidden');
if (row.classList.contains('hidden') || row.dataset.loaded === '1') return;
try {
const regs = await API.get(`/admin/partner/codes/${btn.dataset.id}/registrations`);
row.dataset.loaded = '1';
const cell = row.querySelector('td');
cell.innerHTML = !regs.length
? `Keine Accounts.
`
: regs.map(u => `
${UI.escape(u.name)}
· ${UI.escape(u.email)}
${u.qr_seq ? `QR #${u.qr_seq}` : 'Link/manuell'}
${(u.created_at || '').slice(0, 16).replace(' ', ' · ')}
${u.email_verified
? `
✓ bestätigt `
: `
⏳ unbestätigt `}
`).join('');
} catch (err) { UI.toast.error(err.message); }
});
});
// Code pausieren/aktivieren (Notbremse bei geleakten Codes)
el.querySelectorAll('.adm-toggle-code').forEach(btn => {
btn.addEventListener('click', async () => {
try {
const r = await API.post(`/admin/partner/codes/${btn.dataset.id}/toggle`, {});
UI.toast.success(r.active ? 'Code wieder aktiv.' : 'Code pausiert — Einlösungen sind gesperrt.');
await _renderPartner(el);
} catch (err) { UI.toast.error(err.message); }
});
});
// Code-Besitzer zuordnen (Self-Service-QR-Zugriff für den Partner)
el.querySelectorAll('.adm-code-owner').forEach(btn => {
btn.addEventListener('click', async () => {
const q = window.prompt('Benutzername des Partners (exakt):');
if (!q) return;
try {
const hits = await API.get(`/admin/users/search?q=${encodeURIComponent(q.trim())}`);
const hit = (hits || []).find(u => u.name.toLowerCase() === q.trim().toLowerCase()) || (hits || [])[0];
if (!hit) { UI.toast.warning('Kein User gefunden.'); return; }
await API.post(`/admin/partner/codes/${btn.dataset.id}/owner`, { user_id: hit.id });
UI.toast.success(`Code gehört jetzt ${hit.name} — er sieht seine QR-Kontingente im Partner-Profil.`);
await _renderPartner(el);
} catch (err) { UI.toast.error(err.message); }
});
});
// QR-Kontingent anlegen
el.querySelector('#adm-qr-create')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const b = await API.post(`/admin/partner/codes/${fd.code_id}/qr-batches`, {
label: fd.label,
quantity: parseInt(fd.quantity),
});
UI.toast.success(`Kontingent "${b.label}" mit ${b.quantity} QR-Codes erstellt.`);
await _renderPartner(el);
});
});
// QR-Detail: Accounts hinter einem Kontingent (lazy laden, .hidden via classList)
el.querySelectorAll('.adm-qr-detail').forEach(btn => {
btn.addEventListener('click', async () => {
const row = el.querySelector(`#adm-qr-detail-${btn.dataset.id}`);
if (!row) return;
row.classList.toggle('hidden');
if (row.classList.contains('hidden') || row.dataset.loaded === '1') return;
try {
const regs = await API.get(`/admin/partner/qr-batches/${btn.dataset.id}/registrations`);
row.dataset.loaded = '1';
const cell = row.querySelector('td');
cell.innerHTML = !regs.length
? `Keine Accounts.
`
: regs.map(u => `
${UI.escape(u.name)}
· ${UI.escape(u.email)}
#${u.seq}
${(u.created_at || '').slice(0, 16).replace(' ', ' · ')}
${u.email_verified
? `
✓ bestätigt `
: `
⏳ Versuch `}
`).join('');
} catch (err) { UI.toast.error(err.message); }
});
});
// QR-Kontingent löschen (Zweiklick-Pattern statt confirm — Memory: kein Modal-in-Modal)
el.querySelectorAll('.adm-qr-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (btn.dataset.armed !== '1') {
btn.dataset.armed = '1';
btn.textContent = 'Wirklich löschen?';
setTimeout(() => { btn.dataset.armed = '0'; btn.innerHTML = UI.icon('trash'); }, 3000);
return;
}
try {
await API.del(`/admin/partner/qr-batches/${btn.dataset.id}`);
UI.toast.success(`Kontingent "${btn.dataset.label}" gelöscht.`);
await _renderPartner(el);
} catch (err) { UI.toast.error(err.message); }
});
});
// Partner-Profil-Vorschau auf-/zuklappen (.hidden hat !important → classList)
el.querySelectorAll('.adm-pp-preview').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelector(`#adm-pp-preview-${btn.dataset.uid}`)?.classList.toggle('hidden');
});
});
// Partner-Profil freigeben / ablehnen
el.querySelectorAll('.adm-pp-review').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await API.post(`/admin/partner/profiles/${btn.dataset.uid}/review`,
{ approved: parseInt(btn.dataset.val) });
UI.toast.success(btn.dataset.val === '1' ? 'Profil freigegeben.' : 'Profil abgelehnt.');
await _renderPartner(el);
} catch (err) { UI.toast.error(err.message); }
});
});
// Code erstellen
el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const code = (fd.code || '').trim().toUpperCase();
if (!code) return;
await UI.asyncButton(btn, async () => {
await API.post('/admin/partner/codes', {
code,
label: fd.label || code,
grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0,
max_uses: fd.max_uses ? parseInt(fd.max_uses) : null,
});
UI.toast.success(`Code "${code}" erstellt.`);
await _renderPartner(el);
});
});
// Code löschen
el.querySelectorAll('.adm-del-code').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm(`Code wirklich löschen?`)) return;
const id = btn.dataset.id;
await UI.asyncButton(btn, async () => {
await API.del(`/admin/partner/codes/${id}`);
UI.toast.success('Code gelöscht.');
await _renderPartner(el);
});
});
});
// User suchen für Status-Vergabe
let _grantUserId = null;
const searchInput = el.querySelector('#adm-grant-search');
const grantResult = el.querySelector('#adm-grant-result');
let _searchTimeout = null;
searchInput?.addEventListener('input', () => {
clearTimeout(_searchTimeout);
_grantUserId = null;
const q = searchInput.value.trim();
if (q.length < 1) { grantResult.innerHTML = ''; return; }
_searchTimeout = setTimeout(async () => {
try {
const res = await API.get(`/admin/users?q=${encodeURIComponent(q)}&limit=10`);
const users = res?.users || res || [];
if (!users || !users.length) {
grantResult.innerHTML = `Kein User gefunden.
`;
return;
}
grantResult.innerHTML = users.map(u => `
${u.name}
${u.rolle}${u.is_founder ? ' · ⭐' : ''}${u.is_partner ? ' · 🤝' : ''}
`).join('');
grantResult.querySelectorAll('.adm-grant-user').forEach(div => {
div.addEventListener('click', () => {
_grantUserId = parseInt(div.dataset.id);
searchInput.value = div.dataset.name;
// Aktuellen Status in Checkboxen setzen
const form = el.querySelector('#adm-partner-grant');
if (form) {
form.querySelector('[name="is_founder"]').checked = div.dataset.founder === '1';
form.querySelector('[name="is_partner"]').checked = div.dataset.partner === '1';
form.querySelector('[name="founder_tickets"]').value = div.dataset.tickets ?? 25;
}
grantResult.innerHTML = `✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}
`;
});
});
} catch(e) {
grantResult.innerHTML = `${e.message || 'Suchfehler'}
`;
}
}, 400);
});
el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => {
e.preventDefault();
if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; }
const btn = e.target.querySelector('[type="submit"]');
const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0;
const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0;
const ticketsRaw = e.target.querySelector('[name="founder_tickets"]').value.trim();
await UI.asyncButton(btn, async () => {
const body = { is_founder: isFounder, is_partner: isPartner };
if (ticketsRaw !== '') body.founder_tickets = parseInt(ticketsRaw);
const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, body);
if (!result) throw new Error('Keine Antwort vom Server.');
UI.toast.success(`Status für ${result.name} gesetzt.`);
grantResult.innerHTML = `✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'} | 🎟 ${result.founder_referral_tickets ?? 25} Tickets
`;
}).catch(e => UI.toast.error(e.message || 'Fehler beim Speichern.'));
});
}
async function _renderOutreach(el) {
const [templates, log] = await Promise.all([
API.get('/outreach/templates').catch(() => []),
API.get('/outreach/log').catch(() => []),
]);
const accountBadge = a => a === 'support'
? `support@ `
: `partner@ `;
el.innerHTML = `
Vorlagen
${UI.icon('plus')} Neue Vorlage
${templates.length === 0
? `
Noch keine Vorlagen.
`
: `
${templates.map(t => `
${UI.escape(t.label)}
${accountBadge(t.from_account)}
${UI.escape(t.subject)}
${UI.icon('arrow-bend-up-left')}
${UI.icon('pencil-simple')}
${UI.icon('trash')}
`).join('')}
`}
E-Mail senden
Betreff
Text
${UI.icon('paper-plane-tilt')} Senden
{name} wird nicht automatisch ersetzt — bitte manuell anpassen.
Versand-Log
${log.length === 0
? `
Noch keine E-Mails gesendet.
`
: `
Von
Empfänger
Betreff
Wer
Wann
${log.map((l, i) => `
${accountBadge(l.from_account)}
${UI.escape(l.recipient)}
${UI.escape(l.subject)}
${UI.escape(l.sent_by_name || '')}
${(l.sent_at||'').slice(0,16).replace('T',' ')}
`).join('')}
`}
`;
// Log-Zeile: Mail-Inhalt anzeigen
el.querySelectorAll('tr[data-log-idx]').forEach(row => {
row.addEventListener('click', () => {
const l = log[Number(row.dataset.logIdx)];
if (!l) return;
UI.modal.open({
title: UI.escape(l.subject),
body: `
An: ${UI.escape(l.recipient)} ·
Von: ${UI.escape(l.from_account)}@banyaro.app ·
${(l.sent_at||'').slice(0,16).replace('T',' ')}
${UI.escape(l.body || '(kein Text gespeichert)')} `,
footer: `Schließen `,
});
});
});
// 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: `
Bezeichnung (sichtbar)
Betreff
Text
${UI.escape(tpl?.body || '')}
`,
footer: `
Abbrechen
Speichern `,
});
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 = `
${UI.icon('arrows-clockwise')} Aktualisieren
Lade…
`;
el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list')));
await _loadAudit(el.querySelector('#adm-audit-list'));
}
async function _loadAudit(el) {
el.innerHTML = `Lade…
`;
const rows = await API.get('/admin/audit?limit=50');
if (!rows.length) {
el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.');
return;
}
el.innerHTML = `
`;
}
// ------------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------------
function _prompt(msg) {
return new Promise(resolve => {
UI.modal.open({
title: 'Eingabe',
body: `
${msg}
`,
footer: `
OK
Abbrechen
`,
});
document.getElementById('adm-prompt-ok')?.addEventListener('click', () => {
const val = document.getElementById('adm-prompt-input')?.value || '';
UI.modal.close();
resolve(val);
});
document.getElementById('adm-prompt-cancel')?.addEventListener('click', () => {
UI.modal.close();
resolve(null);
});
});
}
function _emptyState(icon, title, text) {
return `
${title}
${text ? `
${text}
` : ''}
`;
}
// ------------------------------------------------------------------
// BEWERBUNGEN — Social-Media-Job
// ------------------------------------------------------------------
async function _renderBewerbungen(el) {
let _statusFilter = 'pending';
async function _load() {
el.innerHTML = `
${['pending','reviewing','accepted','rejected','alle'].map(s => `
${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'}
`).join('')}
${UI.skeleton(3)}
`;
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
btn.addEventListener('click', () => {
_statusFilter = btn.dataset.s;
_load();
});
});
try {
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
const list = el.querySelector('#adm-bew-list');
if (!rows.length) {
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
return;
}
list.innerHTML = rows.map(r => `
${UI.escape(r.name)}
${r.username ? `(@${UI.escape(r.username)}) ` : ''}
${UI.escape(r.email)} · @${UI.escape(r.social_handle||'—')}
${r.dog_name ? ` · 🐕 ${UI.escape(r.dog_name)} (${UI.escape(r.dog_rasse||'')})` : ''}
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
${UI.escape((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
Details
⏳ Neu
🔍 Prüfung
✅ Angenommen
❌ Abgelehnt
`).join('');
list.querySelectorAll('.adm-bew-status').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.id;
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
setTimeout(_load, 500);
});
});
list.querySelectorAll('.adm-bew-view').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const app = await API.get(`/jobs/admin/applications/${id}`);
const docsHtml = app.docs?.length
? app.docs.map(d => `
📎 ${UI.escape(d.filename)} `).join('')
: 'Keine Anhänge ';
UI.modal.open({
title: `Bewerbung — ${UI.escape(app.name)}`,
body: `
E-Mail: ${UI.escape(app.email)}
Social: @${UI.escape(app.social_handle||'—')}
${app.dog_name ? `
Hund: ${UI.escape(app.dog_name)} (${UI.escape(app.dog_rasse||'')})
` : ''}
Motivation:
${UI.escape(app.motivation)}
Anhänge: ${docsHtml}
Admin-Notiz:
${UI.escape(app.admin_note||'')}
`,
footer: `
Schließen
Notiz speichern `,
});
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
const note = document.getElementById('adm-bew-note')?.value || '';
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
UI.toast.success('Notiz gespeichert.');
UI.modal.close();
});
});
});
} catch (e) {
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
}
}
await _load();
}
// ------------------------------------------------------------------
// TAB: HILFE / FAQ
async function _renderHilfe(el) {
const KAT_LABEL = {
installation: 'Installation & PWA',
erste_schritte: 'Erste Schritte',
standort: 'Standort & Wetter',
account: 'Account & Passwort',
features: 'Features erklärt',
probleme: 'Technische Probleme',
};
el.innerHTML = `
Hilfe / FAQ
${UI.icon('plus')} Neuer Artikel
`;
async function _load() {
const listEl = el.querySelector('#adm-hilfe-list');
try {
const articles = await API.get('/help?all=1');
if (!articles.length) {
listEl.innerHTML = _emptyState('question', 'Noch keine FAQ-Artikel', '');
return;
}
// Gruppieren nach Kategorie
const grouped = {};
for (const a of articles) {
if (!grouped[a.kategorie]) grouped[a.kategorie] = [];
grouped[a.kategorie].push(a);
}
let html = '';
for (const [kat, items] of Object.entries(grouped)) {
const label = KAT_LABEL[kat] || kat;
html += `
${UI.escape(label)}
`;
for (const a of items) {
html += `
${UI.escape(a.frage)}
#${a.sort_order}
${UI.icon('pencil-simple')} Bearbeiten
${a.aktiv ? UI.icon('eye-slash') + ' Ausblenden' : UI.icon('eye') + ' Einblenden'}
${UI.icon('trash')}
`;
}
html += `
`;
}
listEl.innerHTML = html;
_bindListEvents(listEl);
} catch (e) {
listEl.innerHTML = _emptyState('warning', 'Fehler beim Laden', e.message || '');
}
}
function _bindListEvents(listEl) {
// Edit-Button: Inline-Formular auf/zu klappen
listEl.querySelectorAll('.adm-hilfe-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
if (form) form.style.display = form.style.display === 'none' ? '' : 'none';
});
});
// Edit-Cancel
listEl.querySelectorAll('.adm-hilfe-edit-cancel').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
if (form) form.style.display = 'none';
});
});
// Edit-Save
listEl.querySelectorAll('.adm-hilfe-edit-save').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const row = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
const payload = {
kategorie: row.querySelector('.adm-hilfe-edit-kat').value,
frage: row.querySelector('.adm-hilfe-edit-frage').value.trim(),
antwort: row.querySelector('.adm-hilfe-edit-antwort').value.trim(),
sort_order: parseInt(row.querySelector('.adm-hilfe-edit-sort').value, 10) || 0,
};
if (!payload.frage || !payload.antwort) {
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
return;
}
try {
await API.patch(`/help/${id}`, payload);
UI.toast.success('Artikel gespeichert.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
});
});
// Toggle aktiv/inaktiv
listEl.querySelectorAll('.adm-hilfe-toggle-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const aktiv = parseInt(btn.dataset.aktiv, 10);
try {
await API.patch(`/help/${id}`, { aktiv: aktiv ? 0 : 1 });
UI.toast.success(aktiv ? 'Artikel ausgeblendet.' : 'Artikel eingeblendet.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
});
});
// Delete
listEl.querySelectorAll('.adm-hilfe-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const frage = btn.dataset.frage;
if (!window.confirm(`Artikel wirklich löschen?\n\n"${frage}"`)) return;
try {
await API.del(`/help/${id}`);
UI.toast.success('Artikel gelöscht.');
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Löschen.'); }
});
});
}
// Neuer-Artikel-Button
el.querySelector('#adm-hilfe-neu').addEventListener('click', () => {
const form = el.querySelector('#adm-hilfe-form');
form.style.display = form.style.display === 'none' ? '' : 'none';
});
// Formular abbrechen
el.querySelector('#adm-hilfe-form-cancel').addEventListener('click', () => {
el.querySelector('#adm-hilfe-form').style.display = 'none';
});
// Formular speichern
el.querySelector('#adm-hilfe-form-save').addEventListener('click', async () => {
const kat = el.querySelector('#adm-hilfe-kat').value;
const frage = el.querySelector('#adm-hilfe-frage').value.trim();
const antwort= el.querySelector('#adm-hilfe-antwort').value.trim();
const sort = parseInt(el.querySelector('#adm-hilfe-sort').value, 10) || 0;
if (!frage || !antwort) {
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
return;
}
try {
await API.post('/help', { kategorie: kat, frage, antwort, sort_order: sort });
UI.toast.success('Artikel angelegt.');
el.querySelector('#adm-hilfe-form').style.display = 'none';
el.querySelector('#adm-hilfe-frage').value = '';
el.querySelector('#adm-hilfe-antwort').value = '';
el.querySelector('#adm-hilfe-sort').value = '0';
_load();
} catch (e) { UI.toast.error(e.message || 'Fehler beim Anlegen.'); }
});
await _load();
}
// ------------------------------------------------------------------
async function _renderUebungenAdmin(el) {
el.innerHTML = `Lade Übungen…
`;
let byTab;
try {
byTab = await API.get('/training/exercises');
} catch (e) {
el.innerHTML = `Fehler: ${e.message}
`;
return;
}
// Flatten to sorted list grouped by kategorie
const allExercises = [];
for (const [kat, list] of Object.entries(byTab)) {
for (const ex of list) allExercises.push({ ...ex, _kat: kat });
}
allExercises.sort((a, b) => a._kat.localeCompare(b._kat) || a.name.localeCompare(b.name));
// Group by kategorie
const grouped = {};
for (const ex of allExercises) {
grouped[ex._kat] = grouped[ex._kat] || [];
grouped[ex._kat].push(ex);
}
const KAT_LABELS = {
'grundkommandos': 'Grundkommandos', 'tricks': 'Tricks',
'problemverhalten': 'Problemverhalten', 'mentale-auslastung': 'Mentale Auslastung',
'koerperpflege': 'Körperpflege', 'hundesport': 'Hundesport', 'welpe-basics': 'Welpe Basics',
};
let html = `
Trainingsübungen bearbeiten
`;
for (const [kat, list] of Object.entries(grouped)) {
html += `
${KAT_LABELS[kat] || kat} (${list.length})
`;
for (const ex of list) {
const schritte = Array.isArray(ex.schritte) ? ex.schritte.join('\n') : '';
const exId = ex.exercise_id;
html += `
`;
}
html += `
`;
}
html += `
`;
el.innerHTML = html;
// Edit toggle
el.querySelectorAll('.adm-ueb-edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`);
form.style.display = form.style.display === 'none' ? '' : 'none';
});
});
// Cancel
el.querySelectorAll('.adm-ueb-cancel-btn').forEach(btn => {
btn.addEventListener('click', () => {
el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`).style.display = 'none';
});
});
// Save
el.querySelectorAll('.adm-ueb-save-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.exId;
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${id}"]`);
const beschreibung = form.querySelector('.adm-ueb-beschreibung').value.trim();
const schritte = form.querySelector('.adm-ueb-schritte').value
.split('\n').map(s => s.trim()).filter(Boolean);
const tipp = form.querySelector('.adm-ueb-tipp').value.trim();
btn.disabled = true;
btn.textContent = 'Speichert…';
try {
await API.put(`/training/exercises/${id}`, {
beschreibung,
schritte: JSON.stringify(schritte),
tipp,
});
UI.toast.success('Übung gespeichert.');
form.style.display = 'none';
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Speichern.');
} finally {
btn.disabled = false;
btn.textContent = 'Speichern';
}
});
});
}
// ------------------------------------------------------------------
// ------------------------------------------------------------------
// TAB: REFERRALS
// ------------------------------------------------------------------
async function _renderReferrals(el) {
el.innerHTML = `Lade…
`;
let d;
try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `Fehler beim Laden.
`; return; }
const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0;
const topRows = d.top_referrers.map((r, i) => `
${i + 1}
${UI.escape(r.name)}
${UI.escape(r.email)}
${r.invited_count}
`).join('');
const recentRows = d.recent_invites.slice(0, 50).map(r => `
${UI.escape(r.name)}
${UI.escape(r.referrer_name)}
${(r.created_at || '').slice(0, 10)}
`).join('');
el.innerHTML = `
${d.total_referred}
Geworbene User
${pct}%
Anteil geworbener User
${d.viral_factor}
Virality Factor
#
Name
E-Mail
Eingeladen
${topRows || 'Noch keine Empfehlungen '}
User
Geworben von
Datum
${recentRows || 'Noch keine Daten '}
`;
}
// ------------------------------------------------------------------
// TAB: UPGRADES
// ------------------------------------------------------------------
async function _renderUpgrades(el) {
const rows = await API.get('/admin/upgrade-requests');
const tierBadge = t => {
const cfg = { pro: ['Pro', '#16a34a'], breeder: ['Züchter', '#C4843A'] };
const [label, color] = cfg[t] || [t, '#888'];
return `${label} `;
};
const pending = rows.filter(r => !r.fulfilled_at);
const done = rows.filter(r => r.fulfilled_at);
// Offene Anfragen als Cards (mobile-freundlich, Button immer sichtbar)
const _pendingCard = r => `
${UI.escape(r.name)}
${UI.escape(r.email)}
${tierBadge(r.tier)}
${r.discount_pct > 0 ? `
${r.discount_pct}% Rabatt ` : ''}
${r.created_at?.slice(0,10) || ''}
${r.discount_reason === 'founder' ? `
Gründer — kostenfrei
` : ''}
${r.discount_reason === 'referred_by_founder' ? `
Von Gründer eingeladen
` : ''}
${r.discount_reason === 'referral' ? `
${r.referral_count} Freunde geworben
` : ''}
${r.message ? `
${UI.escape(r.message)}
` : ''}
${r.existing_invoice_id ? `
${UI.icon('receipt')} Rechnung bearbeiten
` : `
${UI.icon('receipt')} Rechnung erstellen
`}
✓ Freischalten
`;
// Erledigte als kompakte Tabellenzeilen
const _doneRow = r => `
${UI.escape(r.name)}
${UI.escape(r.email)}
${tierBadge(r.tier)}
✓ ${r.fulfilled_at?.slice(0,10) || ''}
`;
el.innerHTML = `
${pending.length
? pending.map(_pendingCard).join('')
: `
Keine offenen Anfragen
`}
${done.length ? `
` : ''}`;
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 = `
${UI.icon('list-bullets')} Rechnungen
${UI.icon('chart-bar')} Cashflow
${_subView === 'liste' ? `
${UI.icon('plus')} Neue Rechnung
` : ''}
`;
el.querySelectorAll('.adm-inv-nav').forEach(btn => {
btn.addEventListener('click', () => {
_subView = btn.dataset.v;
_load();
});
});
el.querySelector('#adm-inv-new')?.addEventListener('click', () => _openNeueRechnungModal(_load));
const content = el.querySelector('#adm-inv-content');
if (_subView === 'liste') {
await _loadInvoiceList(content, _load);
} else {
await _loadCashflow(content);
}
}
await _load();
}
async function _loadInvoiceList(el, reload) {
let invoices;
try {
invoices = await API.get('/admin/invoices');
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Rechnungen konnten nicht geladen werden.');
return;
}
if (!invoices.length) {
el.innerHTML = _emptyState('receipt', 'Keine Rechnungen', 'Noch keine Rechnungen erstellt.');
return;
}
const _statusBadge = status => {
const cfg = {
draft: ['Entwurf', 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'],
sent: ['Versendet', 'var(--c-primary)', 'var(--c-primary-subtle,#eff6ff)','var(--c-primary)'],
paid: ['Bezahlt', 'var(--c-success,#16a34a)','#d1fae5', 'var(--c-success,#16a34a)'],
cancelled: ['Storniert', 'var(--c-danger,#dc2626)', '#fee2e2', 'var(--c-danger,#dc2626)'],
};
const [label, color, bg, border] = cfg[status] || [status, 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'];
return `${label} `;
};
const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—';
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
const rows = invoices.map((inv, i) => {
const actions = [];
if (inv.status === 'draft') {
actions.push(`
${UI.icon('pencil')} Bearbeiten
`);
actions.push(`
${UI.icon('paper-plane-tilt')} Senden
`);
}
if (inv.status === 'sent') {
actions.push(`
${UI.icon('paper-plane-tilt')} Erneut senden
`);
}
if (inv.status === 'sent') {
actions.push(`
${UI.icon('check-circle')} Bezahlt
`);
actions.push(`
${UI.icon('x-circle')} Storno
`);
}
if (inv.status === 'paid' || inv.status === 'cancelled') {
actions.push(`
${UI.icon('eye')} Details
`);
}
return `
${UI.escape(inv.invoice_number)}
${UI.escape(inv.recipient_name)}
${UI.escape(inv.recipient_email || '')}
${_fmtEur(inv.amount_gross)}
${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01
? `
erhalten: ${_fmtEur(inv.paid_amount)}
${inv.paid_amount < inv.amount_gross
? `-${_fmtEur(inv.amount_gross - inv.paid_amount)} `
: ''}
`
: ''}
${_statusBadge(inv.status)}
${_fmtDate(inv.created_at)}
${actions.join('')}
`;
}).join('');
el.innerHTML = `
`;
// Senden
el.querySelectorAll('.adm-inv-send').forEach(btn => {
btn.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: `Rechnung ${btn.dataset.num} versenden?`,
message: 'Die Rechnung wird als PDF erzeugt und per E-Mail an den Empfänger versendet.',
confirmText: 'Jetzt versenden',
});
if (!ok) return;
btn.disabled = true;
try {
await API.post(`/admin/invoices/${btn.dataset.id}/send`, {});
UI.toast.success('Rechnung versendet.');
reload();
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Versenden.');
btn.disabled = false;
}
});
});
// Entwurf bearbeiten
el.querySelectorAll('.adm-inv-edit').forEach(btn => {
btn.addEventListener('click', async () => {
const inv = await API.get(`/admin/invoices/${btn.dataset.id}`);
_openNeueRechnungModal(reload, {
recipient_name: inv.recipient_name,
recipient_email: inv.recipient_email,
recipient_address: inv.recipient_address || '',
service_period: inv.service_period || '',
discount_pct: inv.discount_pct || 0,
notes: inv.notes || '',
items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })),
}, inv.id, inv.status, inv.invoice_number);
});
});
// Als bezahlt markieren
el.querySelectorAll('.adm-inv-pay').forEach(btn => {
btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload));
});
// Stornieren
el.querySelectorAll('.adm-inv-cancel').forEach(btn => {
btn.addEventListener('click', () => _openStornoModal(btn.dataset.id, btn.dataset.num, reload));
});
// Details
el.querySelectorAll('.adm-inv-detail').forEach(btn => {
btn.addEventListener('click', () => _openDetailModal(btn.dataset.id));
});
}
function _openNeueRechnungModal(reload, prefill = null, invoiceId = null, status = null, invoiceNumber = null) {
const id = `inv-new-${Date.now()}`;
const p = prefill || {};
const isEdit = !!invoiceId;
const isLocked = isEdit && status && status !== 'draft'; // sent / paid / cancelled → nicht änderbar
const canCancel = isEdit && status !== 'cancelled'; // alles außer schon storniert
const lockedBanner = isLocked ? (() => {
const map = {
sent: ['#fff8f0', '#f0a060', '#c05000', 'Diese Rechnung wurde bereits versendet — Inhalt nicht mehr änderbar. Korrekturen nur per Stornierung.'],
paid: ['#f0fdf4', '#86efac', '#15803d', 'Diese Rechnung ist bezahlt — Inhalt nicht änderbar.'],
cancelled: ['#fef2f2', '#fca5a5', '#b91c1c', 'Diese Rechnung wurde storniert — Inhalt nicht änderbar.'],
};
const [bg, br, fg, msg] = map[status] || ['#f5f5f5', '#ccc', '#444', `Status: ${status}`];
return `${msg}
`;
})() : '';
UI.modal.open({
title: `${UI.icon('receipt')} ${isEdit ? (isLocked ? 'Rechnung ansehen' : 'Rechnung bearbeiten') : 'Neue Rechnung erstellen'}`,
body: `
${lockedBanner}
${!isEdit && !p.recipient_name ? `
Diese Rechnung ist für sonstige Leistungen (Beratung, Einmalleistung etc.).
Für Abo-Verlängerungen bitte den Button „Rechnung erstellen" in der Upgrades-Liste verwenden.
` : ''}
Adresse
${p.recipient_name && !p.recipient_address
? ` ⚠ Nutzer hat keine Rechnungsadresse hinterlegt `
: '(optional) '}
${UI.escape(p.recipient_address || '')}
Leistungszeitraum (optional)
Positionen *
+ Position hinzufügen
Notizen (optional)
${UI.escape(p.notes || (!isEdit && !p.recipient_name ? 'Zahlbar innerhalb von 14 Tagen ab Rechnungsdatum.' : ''))}
`,
footer: `
${isLocked
? `
Schließen `
: `
Abbrechen
${UI.icon('receipt')} ${isEdit ? 'Speichern' : 'Erstellen'}
`}
${canCancel ? `
${UI.icon('x-circle')} Rechnung stornieren
` : ''}
`,
});
// 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 = `
${UI.icon('x')}
`;
itemEl.querySelector('.inv-item-remove').addEventListener('click', () => {
if (itemsContainer.querySelectorAll('.adm-inv-item-row').length > 1) {
itemEl.remove();
_updatePreview();
}
});
itemEl.querySelectorAll('input').forEach(inp => inp.addEventListener('input', _updatePreview));
itemsContainer.appendChild(itemEl);
_updatePreview();
}
function _updatePreview() {
let netto = 0;
itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => {
const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 0;
const price = parseFloat(row.querySelector('.inv-item-price').value) || 0;
netto += qty * price;
});
const disc = Math.min(100, Math.max(0, parseFloat(discountEl?.value) || 0));
const rabatt = netto * disc / 100;
const brutto = netto - rabatt;
previewEl.innerHTML = `
Netto:
${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} €
${disc > 0 ? ` · -${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%) ` : ''}
· Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} €
`;
}
// Erste Position — aus Prefill oder Standard
if (p.items && p.items.length) {
p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0));
} else {
_addItem('Ban Yaro Pro Jahresabo', 1, 29.00);
}
// Weitere Position
document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem());
discountEl?.addEventListener('input', _updatePreview);
// Form Submit
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
if (isLocked) return; // gesperrte Rechnung — Submit ignorieren (Button ist eh ausgeblendet)
const fd = new FormData(e.target);
const items = [];
itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => {
const desc = row.querySelector('.inv-item-desc').value.trim();
const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 1;
const price = parseFloat(row.querySelector('.inv-item-price').value) || 0;
if (desc) items.push({ description: desc, quantity: qty, unit_price: price });
});
if (!items.length) { UI.toast.warning('Mindestens eine Position angeben.'); return; }
const submitBtn = e.target.closest('.modal-content, [id]')
? document.querySelector(`button[form="${id}"]`)
: null;
if (submitBtn) submitBtn.disabled = true;
try {
const payload = {
recipient_name: fd.get('recipient_name'),
recipient_email: fd.get('recipient_email') || null,
recipient_address: fd.get('recipient_address') || null,
service_period: fd.get('service_period') || null,
discount_pct: parseFloat(fd.get('discount_pct')) || 0,
notes: fd.get('notes') || null,
items,
};
if (isEdit) {
await API.patch(`/admin/invoices/${invoiceId}`, payload);
} else {
await API.post('/admin/invoices', payload);
}
UI.modal.close();
UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.');
reload();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Erstellen.');
if (submitBtn) submitBtn.disabled = false;
}
});
}
function _openBezahltModal(invoiceId, defaultAmount, reload) {
const today = new Date().toISOString().slice(0, 10);
const id = `inv-pay-${Date.now()}`;
UI.modal.open({
title: `${UI.icon('check-circle')} Als bezahlt markieren`,
body: `
Zahlungsdatum *
Eingegangener Betrag (€) *
`,
footer: `
Abbrechen
${UI.icon('check-circle')} Als bezahlt markieren
`,
});
// Differenz live anzeigen
const amtEl = document.getElementById(`${id}-amt`);
const diffEl = document.getElementById(`${id}-diff`);
const _checkDiff = () => {
const entered = parseFloat(amtEl?.value) || 0;
const diff = defaultAmount - entered;
if (Math.abs(diff) < 0.01) { diffEl.style.display = 'none'; return; }
diffEl.style.display = 'block';
if (diff > 0) {
diffEl.innerHTML = `Differenz: -${diff.toFixed(2)} € weniger als fakturiert.
Als Kulanz/Forderungsverlust abschreiben (Notiz wird automatisch eingetragen)
`;
} else {
diffEl.innerHTML = `Überzahlung: +${(-diff).toFixed(2)} € mehr eingegangen.`;
diffEl.style.background = '#f0fff8';
diffEl.style.borderColor = '#34d399';
diffEl.style.color = '#065f46';
}
};
amtEl?.addEventListener('input', _checkDiff);
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const paidAmount = parseFloat(fd.get('paid_amount'));
const diff = defaultAmount - paidAmount;
const kulanz = diff > 0.01 && document.getElementById(`${id}-kulanz`)?.checked;
const submitBtn = document.querySelector(`button[form="${id}"]`);
if (submitBtn) submitBtn.disabled = true;
try {
const kulanzNote = kulanz
? `Forderungsverlust/Kulanz: ${diff.toFixed(2)} EUR nicht eingegangen (${fd.get('paid_at')}). Als Kulanz abgeschrieben.`
: null;
await API.post(`/admin/invoices/${invoiceId}/pay`, {
paid_at: fd.get('paid_at'),
paid_amount: paidAmount,
...(kulanzNote ? { notes: kulanzNote } : {}),
});
UI.modal.close();
UI.toast.success(kulanz
? `Bezahlt (${paidAmount.toFixed(2)} €) · ${diff.toFixed(2)} € als Kulanz notiert.`
: 'Rechnung als bezahlt markiert.');
reload();
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
if (submitBtn) submitBtn.disabled = false;
}
});
}
function _openStornoModal(invoiceId, invoiceNum, reload) {
const id = `inv-cancel-${Date.now()}`;
UI.modal.open({
title: `${UI.icon('x-circle')} Rechnung stornieren`,
body: `
Rechnung ${UI.escape(invoiceNum)} stornieren.
Stornierungsgrund *
`,
footer: `
Abbrechen
${UI.icon('x-circle')} Rechnung stornieren
`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(e.target);
const reason = (fd.get('reason') || '').trim();
if (!reason) { UI.toast.warning('Bitte einen Grund angeben.'); return; }
const submitBtn = document.querySelector(`button[form="${id}"]`);
if (submitBtn) submitBtn.disabled = true;
try {
await API.post(`/admin/invoices/${invoiceId}/cancel`, { reason });
UI.modal.close();
UI.toast.success('Rechnung storniert.');
reload();
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
if (submitBtn) submitBtn.disabled = false;
}
});
}
async function _openDetailModal(invoiceId) {
let inv;
try {
inv = await API.get(`/admin/invoices/${invoiceId}`);
} catch (e) {
UI.toast.error(e.message || 'Detail konnte nicht geladen werden.');
return;
}
const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—';
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
const statusColors = {
draft: 'var(--c-text-muted)', sent: 'var(--c-primary)',
paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)',
};
const statusLabels = { draft: 'Entwurf', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' };
const itemsHtml = (inv.items || []).map(item => `
${UI.escape(item.description)}
${item.quantity}
${_fmtEur(item.unit_price)}
${_fmtEur(item.total)}
`).join('');
UI.modal.open({
title: `${UI.icon('receipt')} ${UI.escape(inv.invoice_number)}`,
body: `
Empfänger
${UI.escape(inv.recipient_name)}
${inv.recipient_email ? `
${UI.escape(inv.recipient_email)}
` : ''}
${inv.recipient_address ? `
${UI.escape(inv.recipient_address)}
` : ''}
Status
${statusLabels[inv.status] || inv.status}
Erstellt: ${_fmtDate(inv.created_at)}
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)} ` : ''}
${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)} ` : ''}
${inv.service_period ? `
Leistungszeitraum
${UI.escape(inv.service_period)}
` : ''}
Positionen
Beschreibung
Menge
Preis
Gesamt
${itemsHtml}
Gesamt (brutto)
${_fmtEur(inv.amount_gross)}
${inv.notes ? `
Notizen
${UI.escape(inv.notes)}
` : ''}
`,
footer: `Schließen `,
});
}
async function _loadCashflow(el) {
let cf;
try {
cf = await API.get('/admin/invoices/cashflow');
} catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Cashflow konnte nicht geladen werden.');
return;
}
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
const statusLabels = { draft: 'Entwürfe', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' };
const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)' };
const countKacheln = Object.entries(cf.counts || {}).map(([s, n]) => `
${n}
${statusLabels[s] || s}
`).join('');
const monthRows = (cf.monthly || []).map((m, i) => `
${UI.escape(m.month)}
${m.count}
${_fmtEur(m.revenue)}
`).join('');
// Quartalsbericht-Download
const currentYear = new Date().getFullYear();
const years = [currentYear, currentYear - 1].map(y => `${y} `).join('');
el.innerHTML = `
${_fmtEur(cf.total_paid)}
Einnahmen (bezahlt)
${_fmtEur(cf.total_outstanding)}
Offene Forderungen
${_fmtEur(cf.total_year)}
Jahresumsatz gesamt
${countKacheln}
${UI.icon('file-csv')} Quartalsbericht herunterladen
Jahr
${years}
Quartal
Q1 (Jan–Mär)
Q2 (Apr–Jun)
Q3 (Jul–Sep)
Q4 (Okt–Dez)
${UI.icon('download-simple')} CSV herunterladen
${UI.icon('eye')} Vorschau
`;
// CSV Download
el.querySelector('#adm-inv-csv')?.addEventListener('click', async () => {
const year = el.querySelector('#adm-inv-year').value;
const q = el.querySelector('#adm-inv-quarter').value;
try {
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
if (!data.invoices?.length) { UI.toast.warning('Keine Rechnungen in diesem Quartal.'); return; }
// CSV generieren
const fmtEur = v => v != null ? Number(v).toFixed(2) : '0.00';
const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '';
const escape = v => `"${String(v || '').replace(/"/g, '""')}"`;
const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' };
const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag (eingegangen);Rechnungsbetrag;Status;Versendet am;Zahlungseingang\n';
const csvRows = data.invoices.map(inv => {
const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross;
return [
inv.invoice_number,
inv.recipient_name, inv.recipient_email || '',
fmtDate(inv.created_at), inv.service_period || '',
fmtEur(effectiveAmt),
fmtEur(inv.amount_gross),
statusLabel[inv.status] || inv.status,
fmtDate(inv.sent_at), fmtDate(inv.paid_at)
].map(escape).join(';');
}).join('\n');
const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `banyaro-rechnungen-${year}-Q${q}.csv`;
a.click();
URL.revokeObjectURL(url);
UI.toast.success(`CSV mit ${data.invoices.length} Rechnungen heruntergeladen.`);
} catch (e) {
UI.toast.error(e.message || 'Fehler beim Laden.');
}
});
// Quartals-Vorschau
el.querySelector('#adm-inv-preview-q')?.addEventListener('click', async () => {
const year = el.querySelector('#adm-inv-year').value;
const q = el.querySelector('#adm-inv-quarter').value;
const resultEl = el.querySelector('#adm-inv-q-result');
resultEl.innerHTML = 'Lade…
';
try {
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
if (!data.invoices?.length) {
resultEl.innerHTML = `Keine Rechnungen in ${data.period || `Q${q} ${year}`}.
`;
return;
}
const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—';
const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—';
const sL = { draft:'Entwurf', sent:'Versendet', paid:'Bezahlt', cancelled:'Storniert (Orig.)', storno:'Stornorechnung' };
const rows2 = data.invoices.map((inv, i) => {
const isStorno = inv.status === 'storno';
const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross;
const amtColor = isStorno ? 'color:var(--c-danger)' : (effectiveAmt < 0 ? 'color:var(--c-danger)' : '');
const amtNote = (inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01)
? ` (RG: ${_fmtE(inv.amount_gross)}) ` : '';
return `
${UI.escape(inv.invoice_number)}
${UI.escape(inv.recipient_name)}
${_fmtE(effectiveAmt)}${amtNote}
${sL[inv.status]||inv.status}
${_fmtD(inv.created_at)}
`;
}).join('');
resultEl.innerHTML = `
${UI.escape(data.period || `Q${q} ${year}`)} — ${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)}
`;
} catch (e) {
resultEl.innerHTML = `Fehler: ${UI.escape(e.message)}
`;
}
});
}
return { init, refresh, onDogChange };
})();