Rene: Statistik zählte alles in einen Topf (3 statt 2) und zeigte nicht, WER sich registriert hat. Jetzt: - registrations = email_verified=1, attempts = unbestätigte Versuche — Versuche werden bei späterer Bestätigung automatisch zu Registrierungen - Admin: 👥-Button pro Kontingent klappt Account-Liste auf (Name, E-Mail, Datum, ✓ bestätigt/⏳ Versuch, Sticker-Nr #seq) — lazy geladen, admin-only (personenbezogene Daten); Partner sehen weiter nur Zahlen (Registr. +N) - Test deckt Versuch→Bestätigung-Übergang und Detail-Endpoint ab
4872 lines
248 KiB
JavaScript
4872 lines
248 KiB
JavaScript
/* ============================================================
|
||
BAN YARO — Admin-Bereich
|
||
Nur für Admins und Moderatoren.
|
||
============================================================ */
|
||
|
||
window.Page_admin = (() => {
|
||
|
||
let _container = null;
|
||
let _appState = null;
|
||
let _tab = 'uebersicht';
|
||
let _delegationAttached = null; // WeakRef-Ersatz: zuletzt mit Delegation versehener Container
|
||
|
||
const TABS = [
|
||
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
|
||
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
||
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' },
|
||
{ id: 'zuchter', label: 'Züchter', icon: 'certificate' },
|
||
{ id: 'forum', label: 'Forum', icon: 'chat-circle-dots' },
|
||
{ id: 'social', label: 'Social Media', icon: 'camera' },
|
||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||
{ id: 'system', label: 'System', icon: 'gear' },
|
||
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
|
||
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
|
||
{ id: 'partner', label: 'Partner', icon: 'handshake' },
|
||
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
|
||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
|
||
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
||
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
|
||
{ id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' },
|
||
{ id: 'rechnungen', label: 'Rechnungen', icon: 'receipt' },
|
||
];
|
||
|
||
// ------------------------------------------------------------------
|
||
async function init(container, appState) {
|
||
_container = container;
|
||
_appState = appState;
|
||
|
||
const u = appState.user;
|
||
const isMod = u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator;
|
||
if (!isMod) {
|
||
container.innerHTML = _emptyState('shield', 'Kein Zugriff', 'Dieser Bereich ist nur für Admins und Moderatoren.');
|
||
return;
|
||
}
|
||
|
||
_render();
|
||
}
|
||
|
||
function refresh() { _renderTab(); }
|
||
function onDogChange() {}
|
||
|
||
// ------------------------------------------------------------------
|
||
// SHELL
|
||
// ------------------------------------------------------------------
|
||
function _render() {
|
||
_container.innerHTML = `
|
||
<!-- Action Items -->
|
||
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
|
||
|
||
<!-- Sidebar + Content (Desktop: nebeneinander) -->
|
||
<div class="adm-shell">
|
||
<div class="by-tabs adm-tabs" id="adm-tabs">
|
||
${TABS.map(t => `
|
||
<button class="by-tab${t.id === _tab ? ' active' : ''}" data-tab="${t.id}">
|
||
${UI.icon(t.icon)} ${t.label}
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
<div id="adm-content"></div>
|
||
</div>
|
||
`;
|
||
|
||
_container.querySelector('#adm-tabs')
|
||
?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2));
|
||
|
||
// Event-Delegation: Listener EINMAL pro Container-Instanz setzen
|
||
// (innerHTML überschreibt zwar das DOM, aber bei jedem init() würden ohne
|
||
// Flag erneut N=18 Tab-Listener akkumuliert werden)
|
||
if (_delegationAttached !== _container) {
|
||
_container.addEventListener('click', _onContainerClick);
|
||
_delegationAttached = _container;
|
||
}
|
||
|
||
_renderActionItems();
|
||
_renderTab();
|
||
}
|
||
|
||
// Delegation-Handler für Tab-Buttons + Action-Item-Buttons
|
||
function _onContainerClick(e) {
|
||
// Tab-Button (#adm-tabs .by-tab)
|
||
const tabBtn = e.target.closest('#adm-tabs .by-tab');
|
||
if (tabBtn && _container.contains(tabBtn)) {
|
||
_tab = tabBtn.dataset.tab;
|
||
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.tab === _tab)
|
||
);
|
||
_renderTab();
|
||
return;
|
||
}
|
||
// Action-Item-Button ([data-action-tab])
|
||
const actionBtn = e.target.closest('[data-action-tab]');
|
||
if (actionBtn && _container.contains(actionBtn)) {
|
||
_tab = actionBtn.dataset.actionTab;
|
||
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.tab === _tab)
|
||
);
|
||
_renderTab();
|
||
return;
|
||
}
|
||
}
|
||
|
||
async function _renderActionItems() {
|
||
const el = _container.querySelector('#adm-action-items');
|
||
if (!el) return;
|
||
let d;
|
||
try { d = await API.get('/admin/action-items'); } catch { return; }
|
||
|
||
const items = [
|
||
{ key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' },
|
||
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
|
||
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
|
||
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
||
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
|
||
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
|
||
{ key: 'invoices_unpaid', label: 'Offene Rechnungen', tab: 'rechnungen', icon: 'receipt' },
|
||
{ 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 = `
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
|
||
background:var(--c-surface);border:1px solid var(--c-border);
|
||
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
|
||
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
|
||
${UI.icon('check-square')} Zu erledigen
|
||
</span>
|
||
${open.length === 0
|
||
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
|
||
${UI.icon('check-circle')} Alles erledigt
|
||
</span>`
|
||
: open.map(i => `
|
||
<button data-action-tab="${i.tab}"
|
||
style="display:inline-flex;align-items:center;gap:4px;
|
||
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
|
||
border:1px solid var(--c-warning,#e65100);border-radius:999px;
|
||
padding:2px 10px;font-size:var(--text-xs);font-weight:700;cursor:pointer">
|
||
${UI.icon(i.icon)} ${i.label}
|
||
<span style="background:var(--c-warning,#e65100);color:#fff;
|
||
border-radius:999px;padding:0 6px;margin-left:2px">
|
||
${d[i.key]}
|
||
</span>
|
||
</button>`).join('')
|
||
}
|
||
<span style="margin-left:auto;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
|
||
</span>
|
||
</div>`;
|
||
|
||
// Klicks auf [data-action-tab] werden zentral via _onContainerClick (Delegation) behandelt
|
||
}
|
||
|
||
async function _renderTab() {
|
||
const el = _container.querySelector('#adm-content');
|
||
if (!el) return;
|
||
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||
try {
|
||
switch (_tab) {
|
||
case 'uebersicht': await _renderStats(el); break;
|
||
case 'nutzer': await _renderUsers(el); break;
|
||
case 'moderation': await _renderModeration(el); break;
|
||
case 'zuchter': await _renderZuechter(el); break;
|
||
case 'forum': await _renderForum(el); break;
|
||
case 'social': await _renderSocial(el); break;
|
||
case 'analytics': await _renderAnalytics(el); break;
|
||
case 'system': await _renderSystem(el); break;
|
||
case 'jobs': await _renderJobs(el); break;
|
||
case 'partner': await _renderPartner(el); break;
|
||
case 'outreach': await _renderOutreach(el); break;
|
||
case 'audit': await _renderAudit(el); break;
|
||
case 'bewerbungen': await _renderBewerbungen(el); break;
|
||
case 'hilfe': await _renderHilfe(el); break;
|
||
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
||
case 'referrals': await _renderReferrals(el); break;
|
||
case 'upgrades': await _renderUpgrades(el); break;
|
||
case 'rechnungen': await _renderRechnungen(el); break;
|
||
}
|
||
} catch (e) {
|
||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: SOCIAL MEDIA
|
||
async function _renderSocial(el) {
|
||
const d = await API.get('/admin/social');
|
||
|
||
const _PL = { instagram: '📸 Instagram', tiktok: '🎵 TikTok', both: '📱 Beide' };
|
||
const _fmt = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '–';
|
||
|
||
// Manager-Tabelle
|
||
const managerRows = d.managers.map(m => `
|
||
<tr>
|
||
<td style="font-weight:600">${UI.escape(m.name)}</td>
|
||
<td class="text-right">${m.published}</td>
|
||
<td class="text-right">${m.with_link}
|
||
${m.published > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">
|
||
(${Math.round(m.with_link/m.published*100)}%)</span>` : ''}</td>
|
||
<td class="text-right">${m.scheduled}</td>
|
||
<td class="text-right">${m.ideas}</td>
|
||
<td style="text-align:right;color:var(--c-text-muted)">${m.total}</td>
|
||
</tr>`).join('');
|
||
|
||
// Plattform-Balken
|
||
const maxPlat = Math.max(...d.by_platform.map(p => p.n), 1);
|
||
const platBars = d.by_platform.map(p => `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
|
||
<div style="width:120px;flex-shrink:0;font-size:var(--text-sm)">${_PL[p.platform]||p.platform}</div>
|
||
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
|
||
<div style="width:${Math.round(p.n/maxPlat*100)}%;height:100%;
|
||
background:var(--c-primary);border-radius:var(--radius-full)"></div>
|
||
</div>
|
||
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${p.n}</div>
|
||
</div>`).join('');
|
||
|
||
// Monats-Timeline
|
||
const maxMonth = Math.max(...d.by_month.map(m => m.n), 1);
|
||
const monthBars = [...d.by_month].reverse().map(m => `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
|
||
<div style="width:55px;flex-shrink:0;font-size:11px;color:var(--c-text-muted)">${m.monat}</div>
|
||
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
|
||
<div style="width:${Math.round(m.n/maxMonth*100)}%;height:100%;
|
||
background:var(--c-success);border-radius:var(--radius-full)"></div>
|
||
</div>
|
||
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${m.n}</div>
|
||
</div>`).join('');
|
||
|
||
// Veröffentlichte Posts (mit/ohne Link)
|
||
const postRows = d.recent_published.map(p => `
|
||
<tr>
|
||
<td style="color:var(--c-text-muted);white-space:nowrap">${_fmt(p.published_at)}</td>
|
||
<td style="font-weight:500;max-width:200px;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap">${UI.escape(p.topic)}</td>
|
||
<td>${_PL[p.platform]||p.platform||'–'}</td>
|
||
<td style="font-size:10px;color:var(--c-text-muted)">${UI.escape(p.category||'–')}</td>
|
||
<td>${p.ai_score ? '⭐'.repeat(p.ai_score) : '–'}</td>
|
||
<td style="font-weight:500">${UI.escape(p.manager||'–')}</td>
|
||
<td>${p.post_url
|
||
? `<a href="${UI.escape(p.post_url)}" target="_blank" rel="noopener"
|
||
style="font-size:11px;color:var(--c-primary)">🔗 Link</a>`
|
||
: `<span style="font-size:11px;color:var(--c-text-muted)">–</span>`}</td>
|
||
</tr>`).join('');
|
||
|
||
el.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);margin-bottom:var(--space-4)">
|
||
<!-- Plattform -->
|
||
<div class="card p-4">
|
||
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
|
||
Veröffentlicht nach Plattform
|
||
</div>
|
||
${platBars || '<div class="text-sm-muted">Noch keine Posts</div>'}
|
||
</div>
|
||
<!-- Timeline -->
|
||
<div class="card p-4">
|
||
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
|
||
Posts pro Monat
|
||
</div>
|
||
${monthBars || '<div class="text-sm-muted">Noch keine Posts</div>'}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Manager -->
|
||
${d.managers.length ? `
|
||
<div class="card adm-table-card mb-4">
|
||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
|
||
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
|
||
border-bottom:1px solid var(--c-border)">Manager-Übersicht</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead><tr>
|
||
<th>Manager</th>
|
||
<th class="text-right">Veröffentlicht</th>
|
||
<th class="text-right">Mit Link</th>
|
||
<th class="text-right">Geplant</th>
|
||
<th class="text-right">Ideen</th>
|
||
<th class="text-right">Gesamt</th>
|
||
</tr></thead>
|
||
<tbody>${managerRows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<!-- Alle veröffentlichten Posts -->
|
||
<div class="card adm-table-card">
|
||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
|
||
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
|
||
border-bottom:1px solid var(--c-border)">
|
||
Veröffentlichte Posts (letzte 50)
|
||
</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead><tr>
|
||
<th>Datum</th><th>Thema</th><th>Plattform</th>
|
||
<th>Kategorie</th><th>Score</th><th>Manager</th><th>Link</th>
|
||
</tr></thead>
|
||
<tbody>${postRows || `<tr><td colspan="7" style="text-align:center;
|
||
color:var(--c-text-muted);padding:var(--space-6)">Noch keine Posts</td></tr>`}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: ANALYTICS
|
||
async function _renderAnalytics(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade Analytics…</div>`;
|
||
let d;
|
||
try { d = await API.get('/admin/analytics'); }
|
||
catch (err) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger);font-size:var(--text-sm)">
|
||
${UI.icon('warning')} Fehler: ${UI.escape(err.message || String(err))}</div>`;
|
||
return;
|
||
}
|
||
|
||
const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0);
|
||
const fmt = v => Number(v).toLocaleString('de');
|
||
|
||
const pv = d.pageviews?.pageviews ?? [];
|
||
const ses = d.pageviews?.sessions ?? [];
|
||
|
||
// Bounce + Verweildauer
|
||
const _bounces = tv(d.today?.bounces), _vis = tv(d.today?.visits);
|
||
const bounceToday = _vis > 0 ? ((_bounces / _vis) * 100).toFixed(0) + ' %' : '—';
|
||
const _tt = tv(d.week?.totaltime), _vw = tv(d.week?.visits);
|
||
const timeWeek = _tt > 0 && _vw > 0 ? Math.round(_tt / _vw) + ' s' : '—';
|
||
|
||
// Dual-Series Area+Line Chart (SVG)
|
||
function _dualChart(pvData, sesData) {
|
||
if (!pvData.length) return `<p style="color:var(--c-text-muted);font-size:var(--text-xs);margin:0">Keine Daten</p>`;
|
||
const W = 800, H = 120, padX = 0, padY = 8;
|
||
const pvVals = pvData.map(p => p.y ?? 0);
|
||
const sesVals = sesData.map(p => p.y ?? 0);
|
||
const maxVal = Math.max(...pvVals, ...sesVals, 1);
|
||
const n = pvData.length;
|
||
|
||
function toXY(vals) {
|
||
return vals.map((v, i) => {
|
||
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
|
||
const y = H - padY - (v / maxVal) * (H - 2*padY);
|
||
return [x.toFixed(1), y.toFixed(1)];
|
||
});
|
||
}
|
||
const pvPts = toXY(pvVals);
|
||
const sesPts = toXY(sesVals);
|
||
const pvLine = pvPts.map(p => p.join(',')).join(' ');
|
||
const sesLine = sesPts.map(p => p.join(',')).join(' ');
|
||
|
||
// Filled area unter pageviews
|
||
const areaPath = `M ${pvPts[0][0]},${H} ` +
|
||
pvPts.map(p => `L ${p[0]},${p[1]}`).join(' ') +
|
||
` L ${pvPts[pvPts.length-1][0]},${H} Z`;
|
||
|
||
// X-Achsen-Labels: Anfang, Mitte, Ende
|
||
const labelIdx = [0, Math.floor(n/2), n-1].filter((v,i,a) => a.indexOf(v)===i);
|
||
const labels = labelIdx.map(i => {
|
||
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
|
||
const date = pvData[i]?.x ? new Date(pvData[i].x).toLocaleDateString('de',{day:'2-digit',month:'2-digit'}) : '';
|
||
return `<text x="${x.toFixed(0)}" y="${H+14}" text-anchor="middle"
|
||
font-size="10" fill="currentColor" class="text-muted">${date}</text>`;
|
||
}).join('');
|
||
|
||
return `<svg viewBox="0 0 ${W} ${H+18}" style="width:100%;height:100px;display:block;overflow:visible"
|
||
preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id="pvGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.25"/>
|
||
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<path d="${areaPath}" fill="url(#pvGrad)"/>
|
||
<polyline points="${pvLine}" fill="none" stroke="var(--c-primary)"
|
||
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||
<polyline points="${sesLine}" fill="none" stroke="var(--c-success)"
|
||
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="4,2"/>
|
||
${labels}
|
||
</svg>`;
|
||
}
|
||
|
||
// Balken-Chart für Top-Pages / Referrers
|
||
function _barChart(items, labelKey = 'x', valKey = 'y') {
|
||
if (!items?.length) return `<p class="text-sm-muted">Keine Daten</p>`;
|
||
const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1);
|
||
return `<div class="flex-col-gap-2">
|
||
${items.map(p => {
|
||
const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(0);
|
||
return `<div>
|
||
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);margin-bottom:3px">
|
||
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:78%">${UI.escape(p[labelKey] || '—')}</span>
|
||
<span style="color:var(--c-text-secondary);flex-shrink:0;margin-left:var(--space-2)">${fmt(p[valKey] ?? 0)}</span>
|
||
</div>
|
||
<div style="height:4px;border-radius:2px;background:var(--c-surface-2)">
|
||
<div style="height:100%;width:${pct}%;border-radius:2px;background:var(--c-primary);transition:width .4s"></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
|
||
|
||
<!-- Stat-Karten -->
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:var(--space-3)">
|
||
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
|
||
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
|
||
${_statCard('users', 'Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
|
||
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
|
||
${_statCard('users', 'Besucher 30 Tage', fmt(tv(d.month?.visitors)), 'var(--c-text-secondary)')}
|
||
${_statCard('eye', 'Aufrufe 30 Tage', fmt(tv(d.month?.pageviews)), 'var(--c-text-secondary)')}
|
||
${_statCard('arrow-u-up-left', 'Bounce heute', bounceToday, 'var(--c-text-secondary)')}
|
||
${_statCard('timer', 'Ø Verweildauer 7d', timeWeek, 'var(--c-text-secondary)')}
|
||
</div>
|
||
|
||
<!-- Verlaufschart 30 Tage -->
|
||
<div class="card p-4">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Verlauf — letzte 30 Tage</span>
|
||
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
|
||
<span style="display:flex;align-items:center;gap:4px">
|
||
<span style="width:12px;height:2px;background:var(--c-primary);display:inline-block;border-radius:1px"></span> Aufrufe
|
||
</span>
|
||
<span style="display:flex;align-items:center;gap:4px">
|
||
<span style="width:12px;height:2px;background:var(--c-success);display:inline-block;border-radius:1px;
|
||
border-bottom:2px dashed var(--c-success);background:none"></span> Besucher
|
||
</span>
|
||
</div>
|
||
</div>
|
||
${_dualChart(pv, ses)}
|
||
</div>
|
||
|
||
<!-- Jahres-Übersicht -->
|
||
<div class="card p-4">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4)">
|
||
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Jahresübersicht — letzte 12 Monate</span>
|
||
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
|
||
<span style="display:flex;align-items:center;gap:4px">
|
||
<span style="width:12px;height:10px;background:var(--c-primary);opacity:0.7;display:inline-block;border-radius:2px"></span> Seitenaufrufe
|
||
</span>
|
||
<span style="display:flex;align-items:center;gap:4px">
|
||
<span style="width:12px;height:10px;background:var(--c-success);opacity:0.7;display:inline-block;border-radius:2px"></span> Neuanmeldungen
|
||
</span>
|
||
</div>
|
||
</div>
|
||
${(() => {
|
||
const pvYear = d.pageviews_year?.pageviews ?? [];
|
||
const regs = d.monthly_registrations ?? [];
|
||
|
||
// Alle Monate der letzten 12 sammeln
|
||
const months = [];
|
||
const now = new Date();
|
||
for (let i = 11; i >= 0; i--) {
|
||
const d2 = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||
months.push(d2.getFullYear() + '-' + String(d2.getMonth()+1).padStart(2,'0'));
|
||
}
|
||
|
||
// Umami liefert x als ISO-Datum-String
|
||
const pvByMonth = {};
|
||
pvYear.forEach(p => {
|
||
const key = (p.x || '').slice(0, 7);
|
||
pvByMonth[key] = (pvByMonth[key] || 0) + (p.y ?? 0);
|
||
});
|
||
const regByMonth = {};
|
||
regs.forEach(r => { regByMonth[r.month] = r.count; });
|
||
|
||
const pvVals = months.map(m => pvByMonth[m] || 0);
|
||
const regVals = months.map(m => regByMonth[m] || 0);
|
||
const maxPv = Math.max(...pvVals, 1);
|
||
const maxReg = Math.max(...regVals, 1);
|
||
|
||
const W = 800, H = 120, n = months.length;
|
||
const barW = Math.floor((W - (n-1)*4) / n);
|
||
|
||
const bars = months.map((m, i) => {
|
||
const x = i * (barW + 4);
|
||
const pvH = Math.round((pvVals[i] / maxPv) * (H - 20));
|
||
const regH = Math.round((regVals[i] / maxReg) * (H - 20));
|
||
const label = m.slice(5); // MM
|
||
return `
|
||
<rect x="${x}" y="${H - 20 - pvH}" width="${barW}" height="${pvH}"
|
||
fill="var(--c-primary)" opacity="0.65" rx="2"/>
|
||
<rect x="${x + Math.floor(barW*0.55)}" y="${H - 20 - regH}" width="${Math.floor(barW*0.4)}" height="${regH}"
|
||
fill="var(--c-success)" opacity="0.8" rx="2"/>
|
||
<text x="${x + barW/2}" y="${H - 4}" text-anchor="middle"
|
||
font-size="9" fill="currentColor" class="text-muted">${label}</text>
|
||
`;
|
||
}).join('');
|
||
|
||
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:120px;display:block;overflow:visible"
|
||
preserveAspectRatio="none">${bars}</svg>`;
|
||
})()}
|
||
</div>
|
||
|
||
<!-- Top Seiten + Referrers nebeneinander -->
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4)">
|
||
<div class="card p-4">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
margin-bottom:var(--space-3)">Top Seiten — 30 Tage</div>
|
||
${_barChart(d.top_pages)}
|
||
</div>
|
||
<div class="card p-4">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
margin-bottom:var(--space-3)">Referrers — 30 Tage</div>
|
||
${_barChart(d.referrers)}
|
||
</div>
|
||
</div>
|
||
|
||
</div>`;
|
||
}
|
||
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: ÜBERSICHT
|
||
// ------------------------------------------------------------------
|
||
async function _renderStats(el) {
|
||
const [s, ki, kiH] = await Promise.all([
|
||
API.get('/admin/stats'),
|
||
API.get('/admin/ki/status').catch(() => null),
|
||
API.get('/admin/ki/history').catch(() => null),
|
||
]);
|
||
|
||
const _kiStatusBadge = () => {
|
||
if (!ki) return '';
|
||
if (ki.mode === 'off') {
|
||
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
|
||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||
border-radius:var(--radius-md)">
|
||
<span style="width:8px;height:8px;border-radius:50%;background:var(--c-text-muted);flex-shrink:0"></span>
|
||
<span class="text-xs-muted">KI-Modus: <strong>off</strong></span>
|
||
</div>`;
|
||
}
|
||
const dot = ki.local_reachable ? 'var(--c-success)' : 'var(--c-danger)';
|
||
const label = ki.local_reachable ? 'Lokal erreichbar' : 'Nicht erreichbar';
|
||
const model = ki.local_model_loaded || ki.local_model_config || '?';
|
||
return `<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3);
|
||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||
border-radius:var(--radius-md);flex-wrap:wrap">
|
||
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0;
|
||
box-shadow:0 0 4px ${dot}"></span>
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);font-weight:600">${label}</span>
|
||
<span class="text-xs-muted">·</span>
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-family:monospace">${UI.escape(model)}</span>
|
||
<span style="margin-left:auto;font-size:10px;padding:1px 6px;border-radius:10px;
|
||
background:var(--c-surface);color:var(--c-text-muted);border:1px solid var(--c-border)">
|
||
Modus: ${ki.local_reachable ? 'local' : 'cloud'}
|
||
</span>
|
||
</div>`;
|
||
};
|
||
|
||
el.innerHTML = `
|
||
<div class="adm-stats-grid" id="adm-overview-grid">
|
||
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)', 'nutzer')}
|
||
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)', 'nutzer')}
|
||
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)', 'nutzer')}
|
||
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)', 'nutzer')}
|
||
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)','forum')}
|
||
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)', 'moderation')}
|
||
${_statCard('camera', 'Fotos freizugeben', s.pending_fotos ?? 0, (s.pending_fotos ?? 0) > 0 ? 'var(--c-warning)' : 'var(--c-text-muted)', 'moderation')}
|
||
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)', 'nutzer')}
|
||
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)', 'system')}
|
||
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)','system')}
|
||
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
|
||
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
|
||
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
|
||
${_statCard('map-trifold', 'OSM-Marker', s.osm_total.toLocaleString('de'), 'var(--c-success)', 'system')}
|
||
${_statCard('squares-four', 'Gecachte Tiles', s.osm_tiles.toLocaleString('de'), 'var(--c-text-secondary)', 'system')}
|
||
</div>
|
||
|
||
<div class="card p-4">
|
||
<!-- Header -->
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||
<p style="font-size:var(--text-sm);font-weight:600;margin:0;display:flex;align-items:center;gap:var(--space-2)">
|
||
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
|
||
KI-Nutzung
|
||
</p>
|
||
<span style="font-size:var(--text-xs);font-weight:700;padding:2px 10px;border-radius:100px;
|
||
background:var(--c-primary-subtle);color:var(--c-primary)">
|
||
${s.ki_today ?? 0} heute · ${s.ki_week ?? 0} (7 Tage)
|
||
</span>
|
||
</div>
|
||
<!-- KI-Status Badge -->
|
||
${_kiStatusBadge()}
|
||
<!-- Quellen-Zeile -->
|
||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||
${[
|
||
['☁️ Claude', s.ki_cloud_week, 'var(--c-primary)'],
|
||
['🖥️ Lokal', s.ki_local_week, 'var(--c-success)'],
|
||
['🌙 Luna', s.ki_luna_week, 'var(--c-warning)'],
|
||
['📅 Monat', s.ki_month, 'var(--c-text-secondary)'],
|
||
['👥 User heute',s.ki_users_today, 'var(--c-text-secondary)'],
|
||
].map(([label, val, color]) => `
|
||
<span style="font-size:var(--text-xs);padding:2px 8px;border-radius:100px;
|
||
background:var(--c-surface-2);border:1px solid var(--c-border);white-space:nowrap">
|
||
<span class="text-muted">${label}</span>
|
||
<strong style="color:${color};margin-left:3px">${val ?? 0}</strong>
|
||
</span>`).join('')}
|
||
</div>
|
||
<!-- Sparkline (30 Tage) -->
|
||
${(() => {
|
||
const hist = kiH?.daily_history || [];
|
||
if (!hist.length) return '<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-3)">Noch keine Verlaufsdaten</p>';
|
||
const max = Math.max(...hist.map(d => d.total), 1);
|
||
const W = 400, H = 60, n = hist.length;
|
||
const pts = hist.map((d, i) => {
|
||
const x = n === 1 ? W/2 : (i / (n - 1)) * W;
|
||
const y = H - 5 - (d.total / max) * (H - 10);
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).join(' ');
|
||
const first = hist[0]?.date?.slice(5) || '';
|
||
const last = hist[hist.length-1]?.date?.slice(5) || '';
|
||
return `<div class="mb-3">
|
||
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:60px;display:block" preserveAspectRatio="none">
|
||
<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="1.5" stroke-linejoin="round"/>
|
||
</svg>
|
||
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--c-text-muted);margin-top:2px">
|
||
<span>${first}</span><span>30 Tage</span><span>${last}</span>
|
||
</div>
|
||
</div>`;
|
||
})()}
|
||
<!-- Limit-Hinweis -->
|
||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-3)">
|
||
Cloud-Limit: <strong>${s.ki_cloud_weekly_limit ?? 20} Anfragen / Woche</strong> pro User
|
||
</p>
|
||
<!-- Top-Nutzer -->
|
||
${(kiH?.top_users || []).length ? `
|
||
<p style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);margin:0 0 var(--space-2);text-transform:uppercase;letter-spacing:.04em">Aktivste Nutzer</p>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead><tr>
|
||
<th>Name</th><th>E-Mail</th><th>☁️ Cloud</th><th>Gesamt</th><th>Zuletzt</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${(kiH.top_users).map(u => `<tr>
|
||
<td style="font-weight:600">${UI.escape(u.name)}</td>
|
||
<td class="text-muted">${UI.escape(u.email.length > 22 ? u.email.split('@')[1] : u.email)}</td>
|
||
<td style="color:var(--c-primary);font-weight:600">${u.cloud}</td>
|
||
<td>${u.total}</td>
|
||
<td class="text-muted">${u.last_date?.slice(5) || '—'}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>` : ''}
|
||
</div>
|
||
|
||
${(() => {
|
||
const POI_LABELS = {
|
||
waste_basket: 'Mülleimer', dog_park: 'Hundewiese', drinking_water: 'Wasserstelle',
|
||
tierarzt: 'Tierarzt', hundesalon: 'Hundesalon', hundeschule: 'Hundeschule',
|
||
shop: 'Shop', restaurant: 'Café / Restaurant', bank: 'Sitzbank',
|
||
hotel: 'Hotel', freilauf: 'Freilauf', kotbeutel: 'Kotbeutel',
|
||
parkplatz: 'Parkplatz', treffpunkt: 'Treffpunkt', sonstiges: 'Sonstiges',
|
||
giftkoeder: 'Giftköder', gefahr: 'Gefahr',
|
||
};
|
||
const label = t => POI_LABELS[t] || t;
|
||
const row = ([type, count]) => `
|
||
<div style="display:flex;justify-content:space-between;font-size:var(--text-sm)">
|
||
<span class="text-secondary">${label(type)}</span>
|
||
<span style="font-weight:600">${count.toLocaleString('de')}</span>
|
||
</div>`;
|
||
const userByType = s.user_poi_by_type || {};
|
||
const userTotal = s.user_poi_total ?? 0;
|
||
return `
|
||
<div class="card p-4">
|
||
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
|
||
OSM-Cache nach Typ <span style="color:var(--c-text-muted);font-weight:400">— ${(s.osm_total || 0).toLocaleString('de')} gecacht</span>
|
||
</p>
|
||
<div class="flex-col-gap-2">
|
||
${Object.entries(s.osm_by_type).map(row).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="card p-4">
|
||
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
|
||
Nutzer-POIs nach Typ <span style="color:var(--c-text-muted);font-weight:400">— ${userTotal.toLocaleString('de')} gesamt</span>
|
||
</p>
|
||
<div class="flex-col-gap-2">
|
||
${Object.keys(userByType).length
|
||
? Object.entries(userByType).map(row).join('')
|
||
: '<div style="color:var(--c-text-muted);font-size:var(--text-sm);text-align:center;padding:var(--space-2)">Noch keine Nutzer-POIs</div>'}
|
||
</div>
|
||
</div>`;
|
||
})()}
|
||
|
||
<!-- Social Media Tracking -->
|
||
<div class="card p-4">
|
||
<p style="font-size:var(--text-sm);font-weight:600;margin:0 0 var(--space-3)">
|
||
📱 Social Media Tracking</p>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);
|
||
margin-bottom:var(--space-3)">
|
||
${[
|
||
['Gesamt generiert', s.social_total, 'var(--c-text-secondary)'],
|
||
['Veröffentlicht', s.social_published, 'var(--c-success)'],
|
||
['Geplant', s.social_scheduled, 'var(--c-primary)'],
|
||
['Ideen offen', s.social_ideas, 'var(--c-warning)'],
|
||
['Diese Woche live', s.social_this_week, 'var(--c-success)'],
|
||
].map(([label, val, color]) => `
|
||
<div style="background:var(--c-surface-2);border-radius:8px;padding:10px">
|
||
<div style="font-size:10px;color:var(--c-text-muted);margin-bottom:2px">${label}</div>
|
||
<div style="font-size:1.4em;font-weight:700;color:${color}">${val ?? 0}</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
${Object.keys(s.social_by_cat||{}).length ? `
|
||
<div class="mb-3">
|
||
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
|
||
font-weight:600;text-transform:uppercase">Kategorien</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||
${Object.entries(s.social_by_cat).map(([cat, n]) => `
|
||
<span style="background:var(--c-surface-2);border-radius:20px;
|
||
padding:2px 10px;font-size:11px">
|
||
${cat} <strong>${n}</strong></span>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
${s.social_recent?.length ? `
|
||
<div>
|
||
<div style="font-size:11px;color:var(--c-text-muted);margin-bottom:6px;
|
||
font-weight:600;text-transform:uppercase">Letzte 10 Posts</div>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
${s.social_recent.map(p => `
|
||
<div style="display:flex;align-items:center;gap:8px;
|
||
font-size:var(--text-xs);padding:6px 0;
|
||
border-bottom:1px solid var(--c-border)">
|
||
<span style="padding:2px 7px;border-radius:4px;font-size:10px;
|
||
background:var(--c-surface-2);flex-shrink:0;
|
||
color:${{idea:'var(--c-text-muted)',draft:'var(--c-warning)',
|
||
scheduled:'var(--c-primary)',published:'var(--c-success)',
|
||
archived:'var(--c-text-muted)'}[p.status]||'inherit'}">
|
||
${p.status}</span>
|
||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;
|
||
white-space:nowrap">${p.topic}</span>
|
||
<span style="color:var(--c-text-muted);flex-shrink:0">
|
||
${(p.published_at||p.created_at||'').slice(0,10)}</span>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
</div>
|
||
|
||
<div class="card p-4">
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0;line-height:1.6">
|
||
<svg class="ph-icon text-primary" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#info"></use>
|
||
</svg>
|
||
Ersten Admin per SQL setzen:
|
||
<code style="background:var(--c-surface-2);padding:2px 6px;border-radius:4px;font-size:var(--text-xs);word-break:break-all;display:inline-block">
|
||
UPDATE users SET rolle='admin', is_moderator=1 WHERE email='deine@email.de';
|
||
</code>
|
||
</p>
|
||
</div>
|
||
`;
|
||
|
||
el.querySelector('#adm-overview-grid')?.addEventListener('click', e => {
|
||
const card = e.target.closest('[data-adm-tab]');
|
||
if (!card) return;
|
||
const tab = card.dataset.admTab;
|
||
_container.querySelector(`#adm-tabs .by-tab[data-tab="${tab}"]`)?.click();
|
||
});
|
||
}
|
||
|
||
function _statCard(icon, label, value, color, tab = null) {
|
||
const clickable = tab ? `data-adm-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
|
||
return `
|
||
<div class="card" ${clickable}>
|
||
<svg class="ph-icon" style="width:24px;height:24px;color:${color};margin-bottom:var(--space-2)"
|
||
aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||
</svg>
|
||
<div style="font-size:var(--text-2xl);font-weight:var(--weight-bold);color:var(--c-text)">
|
||
${value ?? '—'}
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${label}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: NUTZER
|
||
// ------------------------------------------------------------------
|
||
async function _renderUsers(el) {
|
||
el.innerHTML = `
|
||
<div class="adm-filter-row">
|
||
<input id="adm-user-q" type="search" placeholder="Name oder E-Mail…"
|
||
class="adm-filter-input">
|
||
<select id="adm-user-rolle" class="adm-filter-select">
|
||
<option value="">Alle Rollen</option>
|
||
<option value="user">user</option>
|
||
<option value="moderator">moderator</option>
|
||
<option value="admin">admin</option>
|
||
</select>
|
||
</div>
|
||
<div id="adm-user-list">Lade…</div>
|
||
`;
|
||
|
||
const load = async () => {
|
||
const q = el.querySelector('#adm-user-q').value;
|
||
const rolle = el.querySelector('#adm-user-rolle').value;
|
||
const data = await API.get(`/admin/users?q=${encodeURIComponent(q)}&rolle=${rolle}`);
|
||
_renderUserList(el.querySelector('#adm-user-list'), data.users, data.total);
|
||
};
|
||
|
||
let timer;
|
||
el.querySelector('#adm-user-q').addEventListener('input', () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(load, 350);
|
||
});
|
||
el.querySelector('#adm-user-rolle').addEventListener('change', load);
|
||
await load();
|
||
}
|
||
|
||
function _renderUserList(el, users, total) {
|
||
if (!users.length) {
|
||
el.innerHTML = _emptyState('users', 'Keine Nutzer gefunden', '');
|
||
return;
|
||
}
|
||
|
||
const isAdmin = _appState.user?.rolle === 'admin';
|
||
|
||
el.innerHTML = `
|
||
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||
${total} Nutzer gefunden
|
||
</div>
|
||
<div class="flex-col-gap-2">
|
||
${users.map(u => `
|
||
<div class="card" style="padding:var(--space-3) var(--space-4);
|
||
${u.is_banned ? 'opacity:0.6;border-left:3px solid var(--c-danger)' : ''}">
|
||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||
|
||
<!-- Avatar -->
|
||
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
|
||
background:var(--c-surface-2);
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-weight:var(--weight-bold);color:var(--c-text-secondary)">
|
||
${UI.escape(u.name[0].toUpperCase())}
|
||
</div>
|
||
|
||
<!-- Info -->
|
||
<div class="flex-1-min">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text)">
|
||
${UI.escape(u.name)}
|
||
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;border-radius:3px;
|
||
background:var(--c-danger);color:#fff;margin-left:4px">
|
||
GESPERRT</span>` : ''}
|
||
</div>
|
||
<div class="text-xs-muted">
|
||
${UI.escape(u.email)} ·
|
||
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
|
||
${UI.escape(u.rolle)}
|
||
</span>
|
||
· <span style="color:${u.subscription_tier && u.subscription_tier !== 'standard' ? 'var(--c-primary)' : 'var(--c-text-muted)'}">
|
||
${UI.escape(u.subscription_tier || 'standard')}
|
||
</span>
|
||
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
|
||
· ${u.thread_count} Threads
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
|
||
🗺 ${u.route_count} Routen · ${u.total_km} km
|
||
· 📍 ${u.poi_count} POIs
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
|
||
${u.last_seen
|
||
? '🟢 zuletzt aktiv ' + new Date(u.last_seen).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
|
||
: u.last_login
|
||
? '🔵 zuletzt eingeloggt ' + new Date(u.last_login).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})
|
||
: '⚪ nie aktiv'}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Aktionen -->
|
||
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
|
||
${u.is_banned
|
||
? `<button class="btn btn-sm btn-ghost adm-unban text-success" data-uid="${u.id}" data-name="${UI.escape(u.name)}"
|
||
title="Sperre aufheben">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock-open"></use></svg>
|
||
</button>`
|
||
: `<button class="btn btn-sm btn-ghost adm-ban text-danger" data-uid="${u.id}" data-name="${UI.escape(u.name)}"
|
||
title="Sperren">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#lock"></use></svg>
|
||
</button>`
|
||
}
|
||
${isAdmin ? `
|
||
<button class="btn btn-sm btn-ghost adm-rolle" data-uid="${u.id}"
|
||
data-name="${UI.escape(u.name)}" data-rolle="${UI.escape(u.rolle)}"
|
||
title="Rolle ändern">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost adm-tier" data-uid="${u.id}"
|
||
data-name="${UI.escape(u.name)}" data-tier="${UI.escape(u.subscription_tier || 'standard')}"
|
||
title="Abo-Stufe ändern">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#star"></use></svg>
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost adm-delete text-danger" data-uid="${u.id}"
|
||
data-name="${UI.escape(u.name)}" title="Löschen"
|
||
>
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// Events
|
||
el.querySelectorAll('.adm-ban').forEach(btn => {
|
||
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, true));
|
||
});
|
||
el.querySelectorAll('.adm-unban').forEach(btn => {
|
||
btn.addEventListener('click', () => _banUser(btn.dataset.uid, btn.dataset.name, false));
|
||
});
|
||
el.querySelectorAll('.adm-rolle').forEach(btn => {
|
||
btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle));
|
||
});
|
||
el.querySelectorAll('.adm-tier').forEach(btn => {
|
||
btn.addEventListener('click', () => _changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier));
|
||
});
|
||
el.querySelectorAll('.adm-delete').forEach(btn => {
|
||
btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name));
|
||
});
|
||
}
|
||
|
||
async function _banUser(uid, name, ban) {
|
||
if (ban) {
|
||
const reason = await _prompt(`${name} sperren — Grund (optional):`);
|
||
if (reason === null) return; // abgebrochen
|
||
try {
|
||
await API.patch(`/admin/users/${uid}`, { is_banned: 1, ban_reason: reason || 'Kein Grund angegeben.' });
|
||
UI.toast.success(`${name} gesperrt.`);
|
||
_renderTab();
|
||
} catch (e) { UI.toast.error(e.message); }
|
||
} else {
|
||
try {
|
||
await API.patch(`/admin/users/${uid}`, { is_banned: 0, ban_reason: null });
|
||
UI.toast.success(`Sperre für ${name} aufgehoben.`);
|
||
_renderTab();
|
||
} catch (e) { UI.toast.error(e.message); }
|
||
}
|
||
}
|
||
|
||
async function _changeRolle(uid, name, currentRolle) {
|
||
const rollen = ['user', 'moderator', 'admin'].filter(r => r !== currentRolle);
|
||
UI.modal.open({
|
||
title: `Rolle ändern: ${name}`,
|
||
body: `
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
|
||
Aktuelle Rolle: <strong>${currentRolle}</strong>
|
||
</p>
|
||
<div class="flex-col-gap-2">
|
||
${rollen.map(r => `
|
||
<button class="btn btn-secondary adm-rolle-choice" data-rolle="${r}" form="">
|
||
${r === 'admin' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield"></use></svg>` : ''}
|
||
${r === 'moderator' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>` : ''}
|
||
${r === 'user' ? `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>` : ''}
|
||
Als <strong>${r}</strong> setzen
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
`,
|
||
});
|
||
|
||
document.querySelectorAll('.adm-rolle-choice').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
UI.modal.close();
|
||
try {
|
||
await API.patch(`/admin/users/${uid}`, { rolle: btn.dataset.rolle });
|
||
UI.toast.success(`${name} ist jetzt ${btn.dataset.rolle}.`);
|
||
_renderTab();
|
||
} catch (e) { UI.toast.error(e.message); }
|
||
});
|
||
});
|
||
}
|
||
|
||
async function _changeTier(uid, name, currentTier) {
|
||
const tiers = ['standard', 'pro', 'breeder', 'standard_test', 'pro_test', 'breeder_test'];
|
||
const tierLabels = {
|
||
standard: 'Standard (kostenlos)',
|
||
pro: 'Pro (bezahlt)',
|
||
breeder: 'Breeder (Züchter)',
|
||
standard_test: 'Standard Test (intern)',
|
||
pro_test: 'Pro Test (intern)',
|
||
breeder_test: 'Breeder Test (intern)',
|
||
};
|
||
UI.modal.open({
|
||
title: `Abo-Stufe ändern: ${name}`,
|
||
body: `
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
|
||
Aktuelle Stufe: <strong>${currentTier}</strong>
|
||
</p>
|
||
<div class="flex-col-gap-2">
|
||
${tiers.map(t => `
|
||
<button class="btn ${t === currentTier ? 'btn-primary' : 'btn-secondary'} adm-tier-choice"
|
||
data-tier="${t}" form="" ${t === currentTier ? 'disabled' : ''}>
|
||
${tierLabels[t]}${t === currentTier ? ' ✓' : ''}
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
`,
|
||
});
|
||
|
||
document.querySelectorAll('.adm-tier-choice:not([disabled])').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
UI.modal.close();
|
||
try {
|
||
await API.patch(`/admin/users/${uid}`, { subscription_tier: btn.dataset.tier });
|
||
// Eigenes Tier geändert → User-State neu laden + Welten neu rendern
|
||
if (String(uid) === String(_appState?.user?.id)) {
|
||
_appState.user.subscription_tier = btn.dataset.tier;
|
||
if (window.Worlds) {
|
||
window.Worlds.init(_appState);
|
||
}
|
||
UI.toast.success(`Dein Tier ist jetzt ${btn.dataset.tier} — Ansicht aktualisiert.`);
|
||
} else {
|
||
UI.toast.success(`${name}: Abo-Stufe ist jetzt ${btn.dataset.tier}.`);
|
||
}
|
||
_renderTab();
|
||
} catch (e) { UI.toast.error(e.message); }
|
||
});
|
||
});
|
||
}
|
||
|
||
async function _deleteUser(uid, name) {
|
||
const ok = await UI.modal.confirm({
|
||
title: `${name} löschen?`,
|
||
message: 'Alle Daten dieses Accounts werden unwiderruflich gelöscht — Hunde, Tagebuch, Beiträge.',
|
||
confirmText: 'Endgültig löschen',
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await API.del(`/admin/users/${uid}`);
|
||
UI.toast.success(`${name} gelöscht.`);
|
||
_renderTab();
|
||
} catch (e) { UI.toast.error(e.message); }
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: FORUM & MELDUNGEN
|
||
// ------------------------------------------------------------------
|
||
async function _renderForum(el) {
|
||
el.innerHTML = `
|
||
<!-- Unternavigation -->
|
||
<div class="adm-subnav">
|
||
<button class="btn btn-primary btn-sm adm-forum-nav" data-view="reports" id="adm-fn-reports">
|
||
Offene Meldungen
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm adm-forum-nav" data-view="threads" id="adm-fn-threads">
|
||
Alle Threads
|
||
</button>
|
||
</div>
|
||
<div id="adm-forum-content">Lade…</div>
|
||
`;
|
||
|
||
el.querySelectorAll('.adm-forum-nav').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
el.querySelectorAll('.adm-forum-nav').forEach(b => {
|
||
b.className = b === btn ? 'btn btn-primary btn-sm adm-forum-nav' : 'btn btn-ghost btn-sm adm-forum-nav';
|
||
});
|
||
await _renderForumView(el.querySelector('#adm-forum-content'), btn.dataset.view);
|
||
});
|
||
});
|
||
|
||
await _renderForumView(el.querySelector('#adm-forum-content'), 'reports');
|
||
}
|
||
|
||
async function _renderForumView(el, view) {
|
||
el.innerHTML = '<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>';
|
||
|
||
if (view === 'reports') {
|
||
const reports = await API.get('/admin/reports');
|
||
if (!reports.length) {
|
||
el.innerHTML = _emptyState('check', 'Keine offenen Meldungen', 'Alles sauber.');
|
||
return;
|
||
}
|
||
el.innerHTML = `
|
||
<div class="flex-col-gap-3">
|
||
${reports.map(r => `
|
||
<div class="card" style="padding:var(--space-4);
|
||
${r.resolved ? 'opacity:0.5' : 'border-left:3px solid var(--c-danger)'}">
|
||
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
|
||
<div class="flex-1-min">
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">
|
||
${r.resolved ? '✓ Erledigt · ' : ''}
|
||
${UI.escape(r.target_type)} #${r.target_id} ·
|
||
Gemeldet von <strong>${UI.escape(r.melder_name)}</strong>
|
||
</div>
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-1)">
|
||
Grund: ${UI.escape(r.grund)}
|
||
</div>
|
||
${r.content_preview ? `
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||
border-radius:var(--radius-sm)">
|
||
${UI.escape(r.content_preview)}
|
||
</div>` : ''}
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
|
||
<button class="btn btn-sm ${r.resolved ? 'btn-ghost' : 'btn-primary'} adm-resolve-btn"
|
||
data-rid="${r.id}" data-resolved="${r.resolved}"
|
||
title="${r.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#${r.resolved ? 'arrow-square-out' : 'check'}"></use></svg>
|
||
</button>
|
||
${!r.resolved ? `
|
||
<button class="btn btn-sm btn-ghost adm-del-content text-danger"
|
||
data-type="${r.target_type}" data-id="${r.target_id}"
|
||
title="Inhalt löschen">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||
</button>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
el.querySelectorAll('.adm-resolve-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
try {
|
||
await API.patch(`/admin/reports/${btn.dataset.rid}`, {});
|
||
_renderForumView(el, 'reports');
|
||
} catch (e) { UI.toast.error(e.message); }
|
||
});
|
||
});
|
||
|
||
el.querySelectorAll('.adm-del-content').forEach(btn => {
|
||
btn.addEventListener('click', () => _deleteContent(btn.dataset.type, btn.dataset.id, el, 'reports'));
|
||
});
|
||
|
||
} else {
|
||
// Threads
|
||
el.innerHTML = `
|
||
<div class="adm-filter-row">
|
||
<input id="adm-thread-q" type="search" placeholder="Threads durchsuchen…"
|
||
class="adm-filter-input">
|
||
<label class="adm-filter-label">
|
||
<input type="checkbox" id="adm-show-deleted"> Gelöschte
|
||
</label>
|
||
</div>
|
||
<div id="adm-thread-list">Lade…</div>
|
||
`;
|
||
|
||
const loadThreads = async () => {
|
||
const q = el.querySelector('#adm-thread-q').value;
|
||
const deleted = el.querySelector('#adm-show-deleted').checked ? 1 : 0;
|
||
const data = await API.get(`/admin/forum/threads?q=${encodeURIComponent(q)}&deleted=${deleted}`);
|
||
_renderThreadList(el.querySelector('#adm-thread-list'), data.threads, el);
|
||
};
|
||
|
||
let t2;
|
||
el.querySelector('#adm-thread-q').addEventListener('input', () => { clearTimeout(t2); t2 = setTimeout(loadThreads, 350); });
|
||
el.querySelector('#adm-show-deleted').addEventListener('change', loadThreads);
|
||
await loadThreads();
|
||
}
|
||
}
|
||
|
||
function _renderThreadList(el, threads, parentEl) {
|
||
if (!threads.length) {
|
||
el.innerHTML = _emptyState('chat-circle-dots', 'Keine Threads', '');
|
||
return;
|
||
}
|
||
el.innerHTML = `
|
||
<div class="flex-col-gap-2">
|
||
${threads.map(t => `
|
||
<div class="card" style="padding:var(--space-3) var(--space-4);
|
||
${t.is_deleted ? 'opacity:0.5;border-left:3px solid var(--c-danger)' : ''}
|
||
${t.is_locked ? 'border-left:3px solid #f59e0b' : ''}">
|
||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||
<div class="flex-1-min">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||
${t.is_deleted ? '<s>' : ''}${UI.escape(t.titel)}${t.is_deleted ? '</s>' : ''}
|
||
</div>
|
||
<div class="text-xs-muted">
|
||
von ${UI.escape(t.autor_name)} ·
|
||
${t.antworten} Antworten ·
|
||
${t.is_pinned ? '📌 ' : ''}${t.is_locked ? '🔒 ' : ''}${t.is_deleted ? '🗑 gelöscht' : ''}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
|
||
${!t.is_deleted ? `
|
||
<button class="btn btn-sm btn-ghost adm-pin" data-tid="${t.id}"
|
||
data-pinned="${t.is_pinned}" title="${t.is_pinned ? 'Entpinnen' : 'Anpinnen'}">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#push-pin"></use></svg>
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost adm-lock" data-tid="${t.id}"
|
||
data-locked="${t.is_locked}" title="${t.is_locked ? 'Entsperren' : 'Sperren'}">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#${t.is_locked ? 'lock-open' : 'lock'}"></use></svg>
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost adm-del-thread text-danger" data-tid="${t.id}"
|
||
title="Löschen">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||
</button>
|
||
` : `
|
||
<button class="btn btn-sm btn-ghost adm-restore-thread text-success" data-tid="${t.id}"
|
||
title="Wiederherstellen">
|
||
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg>
|
||
</button>
|
||
`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
el.querySelectorAll('.adm-pin').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_pinned: btn.dataset.pinned === '1' ? 0 : 1 });
|
||
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
|
||
});
|
||
});
|
||
el.querySelectorAll('.adm-lock').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_locked: btn.dataset.locked === '1' ? 0 : 1 });
|
||
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
|
||
});
|
||
});
|
||
el.querySelectorAll('.adm-del-thread').forEach(btn => {
|
||
btn.addEventListener('click', () => _deleteContent('thread', btn.dataset.tid, parentEl, 'threads'));
|
||
});
|
||
el.querySelectorAll('.adm-restore-thread').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
await API.patch(`/admin/forum/threads/${btn.dataset.tid}`, { is_deleted: 0 });
|
||
parentEl.querySelector('#adm-thread-q').dispatchEvent(new Event('input'));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function _deleteContent(type, id, parentEl, view) {
|
||
const ok = await UI.modal.confirm({
|
||
title: `${type === 'thread' ? 'Thread' : 'Beitrag'} löschen?`,
|
||
message: 'Der Inhalt wird als gelöscht markiert.',
|
||
confirmText: 'Löschen',
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await API.del(`/admin/forum/${type === 'thread' ? 'threads' : 'posts'}/${id}`);
|
||
UI.toast.success('Gelöscht.');
|
||
_renderForumView(parentEl, view);
|
||
} catch (e) { UI.toast.error(e.message); }
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: SYSTEM
|
||
// ------------------------------------------------------------------
|
||
async function _renderSystem(el) {
|
||
el.innerHTML = `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||
<button class="btn btn-ghost btn-sm" id="adm-sys-refresh">
|
||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||
</button>
|
||
</div>
|
||
<div id="adm-ors-card"></div>
|
||
<div id="adm-sys-cards">Lade…</div>
|
||
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-3)">Medien</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||
<button class="btn btn-secondary btn-sm" id="adm-generate-previews">
|
||
${UI.icon('images')} Previews generieren (Bestand)
|
||
</button>
|
||
</div>
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-3)">Wiki-Daten</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
|
||
<button class="btn btn-secondary btn-sm" id="adm-enrichment-status">
|
||
${UI.icon('arrows-clockwise')} Enrichment-Status
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" id="adm-evaluate-breeds">
|
||
${UI.icon('chart-bar')} Qualitätsbewertung (20 Rassen)
|
||
</button>
|
||
<button class="btn btn-secondary btn-sm" id="adm-fetch-photos">
|
||
${UI.icon('image')} Fotos nachladen
|
||
</button>
|
||
</div>
|
||
<div id="adm-maint-result" style="margin-top:var(--space-2);font-size:var(--text-xs);
|
||
color:var(--c-text-secondary)"></div>
|
||
<div id="adm-eval-result" style="margin-top:var(--space-3);display:none"></div>
|
||
</div>
|
||
|
||
<div style="margin-top:var(--space-5)">
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||
<span style="font-size:var(--text-sm);font-weight:600">Server-Logs</span>
|
||
<select id="adm-log-level" class="input" style="width:auto;padding:2px 8px;font-size:var(--text-xs)">
|
||
<option value="">Alle</option>
|
||
<option value="INFO">INFO</option>
|
||
<option value="WARNING">WARNING</option>
|
||
<option value="ERROR">ERROR</option>
|
||
</select>
|
||
<button class="btn btn-ghost btn-sm" id="adm-log-refresh">${UI.icon('arrows-clockwise')}</button>
|
||
</div>
|
||
<div id="adm-log-box" style="
|
||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||
padding:var(--space-3);font-family:monospace;font-size:11px;
|
||
max-height:420px;overflow-y:auto;line-height:1.6">Lade…</div>
|
||
</div>
|
||
`;
|
||
const loadLogs = async () => {
|
||
const level = el.querySelector('#adm-log-level').value;
|
||
const box = el.querySelector('#adm-log-box');
|
||
box.textContent = 'Lade…';
|
||
const rows = await API.get(`/admin/logs?lines=200${level ? '&level=' + level : ''}`);
|
||
const COLORS = { ERROR: '#ef4444', WARNING: '#f59e0b', INFO: '#6b7280', DEBUG: '#94a3b8' };
|
||
box.innerHTML = rows.reverse().map(r => {
|
||
const color = COLORS[r.l] || '#6b7280';
|
||
return `<div style="border-bottom:1px solid var(--c-border);padding:2px 0">` +
|
||
`<span class="text-muted">${r.t}</span> ` +
|
||
`<span style="color:${color};font-weight:600">${r.l}</span> ` +
|
||
`<span class="text-secondary">${UI.escape(r.n)}</span> ` +
|
||
`<span>${UI.escape(r.m)}</span></div>`;
|
||
}).join('') || '<span class="text-muted">Keine Einträge</span>';
|
||
};
|
||
el.querySelector('#adm-sys-refresh').addEventListener('click', () => {
|
||
_loadSystemCards(el.querySelector('#adm-sys-cards'));
|
||
loadLogs();
|
||
});
|
||
el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs);
|
||
el.querySelector('#adm-log-level').addEventListener('change', loadLogs);
|
||
el.querySelector('#adm-generate-previews').addEventListener('click', async (e) => {
|
||
const btn = e.currentTarget;
|
||
const res = el.querySelector('#adm-maint-result');
|
||
btn.disabled = true;
|
||
res.textContent = 'Generiere Previews… (kann 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 => `<span style="color:${scoreColor(v)};font-weight:600">${v.toFixed(1)}</span>`;
|
||
const rows = d.results.filter(r => !r.error).map(r =>
|
||
`<tr>
|
||
<td style="padding:2px 6px">${UI.escape(r.name)}</td>
|
||
<td style="text-align:center;padding:2px 6px">${scoreBar(r.vollstaendigkeit)}</td>
|
||
<td style="text-align:center;padding:2px 6px">${scoreBar(r.korrektheit)}</td>
|
||
<td style="text-align:center;padding:2px 6px">${scoreBar(r.sprachqualitaet)}</td>
|
||
<td style="text-align:center;padding:2px 6px">${scoreBar(r.konsistenz)}</td>
|
||
<td style="text-align:center;padding:2px 6px;font-weight:700">${scoreBar(r.gesamt)}</td>
|
||
<td style="padding:2px 6px;color:var(--c-text-muted);font-size:0.9em">${UI.escape(r.hinweis || '')}</td>
|
||
</tr>`
|
||
).join('');
|
||
box.style.display = 'block';
|
||
box.innerHTML = `
|
||
<div style="font-size:var(--text-xs);font-weight:600;margin-bottom:var(--space-2)">
|
||
Ø-Scores (${d.evaluated}/${d.sample_size} Rassen bewertet)
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||
${['vollstaendigkeit','korrektheit','sprachqualitaet','konsistenz','gesamt'].map(k =>
|
||
`<div class="text-center">
|
||
<div style="font-size:1.4em;font-weight:700;color:${scoreColor(avg[k])}">${avg[k]?.toFixed(1) ?? '–'}</div>
|
||
<div style="font-size:0.75em;color:var(--c-text-muted)">${{vollstaendigkeit:'Vollst.',korrektheit:'Korrekt.',sprachqualitaet:'Sprache',konsistenz:'Konsistenz',gesamt:'Gesamt'}[k]}</div>
|
||
</div>`
|
||
).join('')}
|
||
</div>
|
||
<div style="overflow-x:auto">
|
||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
|
||
<thead><tr style="border-bottom:1px solid var(--c-border)">
|
||
<th style="text-align:left;padding:2px 6px">Rasse</th>
|
||
<th style="padding:2px 6px">Vollst.</th><th style="padding:2px 6px">Korrekt.</th>
|
||
<th style="padding:2px 6px">Sprache</th><th style="padding:2px 6px">Konsis.</th>
|
||
<th style="padding:2px 6px">Ges.</th><th style="text-align:left;padding:2px 6px">Hinweis</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>`;
|
||
res.textContent = `✓ Bewertung abgeschlossen`;
|
||
} catch (err) {
|
||
res.textContent = '✗ Fehler: ' + (err.message || err);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
const [, orsStats] = await Promise.all([
|
||
_loadSystemCards(el.querySelector('#adm-sys-cards')),
|
||
API.get('/admin/ors/stats').catch(() => null),
|
||
]);
|
||
_renderOrsCard(el.querySelector('#adm-ors-card'), orsStats);
|
||
await loadLogs();
|
||
}
|
||
|
||
async function _loadSystemCards(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||
const s = await API.get('/admin/system');
|
||
const diskUsedGb = s.disk_total_gb - s.disk_free_gb;
|
||
const diskPct = s.disk_total_gb > 0 ? Math.round(diskUsedGb / s.disk_total_gb * 100) : 0;
|
||
const uptime = _formatUptime(s.uptime_seconds);
|
||
el.innerHTML = `
|
||
<div class="adm-stats-grid">
|
||
${_statCard('database', 'Datenbank', s.db_size_mb.toFixed(1) + ' MB', 'var(--c-primary)')}
|
||
${_statCard('image', 'Media-Ordner', s.media_size_mb.toFixed(1) + ' MB','var(--c-text-secondary)')}
|
||
${_statCard('timer', 'Uptime', uptime, 'var(--c-success)')}
|
||
${_statCard('hard-drive','Disk frei', s.disk_free_gb.toFixed(1) + ' GB','diskPct > 85 ? "var(--c-danger)" : "var(--c-text-secondary)"')}
|
||
</div>
|
||
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text);margin-bottom:var(--space-3)">Disk-Auslastung</div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||
<div style="flex:1;height:8px;background:var(--c-surface-2);border-radius:4px;overflow:hidden">
|
||
<div style="height:100%;width:${diskPct}%;background:${diskPct > 85 ? 'var(--c-danger)' : diskPct > 65 ? '#f59e0b' : 'var(--c-success)'};border-radius:4px;transition:width .3s"></div>
|
||
</div>
|
||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
|
||
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:4px">
|
||
<span>Python ${UI.escape(s.python_version)}</span>
|
||
<span style="font-family:monospace;font-size:var(--text-xs);background:var(--c-surface-2);padding:2px 8px;border-radius:4px;color:var(--c-text-secondary)">
|
||
APP v${typeof APP_VER !== 'undefined' ? APP_VER : '—'} · ${UI.escape(s.sw_version || '?')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function _renderOrsCard(el, d) {
|
||
if (!el) return;
|
||
if (!d || d.today_count == null) { el.innerHTML = ''; return; }
|
||
|
||
const limit = d.today_limit ?? 2000;
|
||
const count = d.today_count ?? 0;
|
||
const pct = Math.min(Math.round(count / limit * 100), 100);
|
||
const barColor = pct < 50 ? '#4ade80' : pct <= 80 ? '#facc15' : '#f87171';
|
||
|
||
// Wochensumme + Gesamtsumme aus daily_history
|
||
const hist7 = (d.daily_history || []).slice(-7);
|
||
const weekTotal = hist7.reduce((s, h) => s + (h.count ?? 0), 0);
|
||
const totalAll = (d.daily_history || []).reduce((s, h) => s + (h.count ?? 0), 0);
|
||
|
||
// Sparkline aus daily_history (letzte 30 Tage)
|
||
const hist = Array.isArray(d.daily_history) ? d.daily_history : [];
|
||
const W = 400, H = 60, padY = 5;
|
||
let sparkline = '';
|
||
if (hist.length >= 2) {
|
||
const maxC = Math.max(...hist.map(h => h.count), 1);
|
||
const pts = hist.map((h, i) => {
|
||
const x = (i / (hist.length - 1)) * W;
|
||
const y = H - padY - (h.count / maxC) * (H - 2 * padY);
|
||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||
}).join(' ');
|
||
sparkline = `<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
|
||
} else {
|
||
sparkline = `<polyline points="0,55 ${W},55" fill="none" stroke="var(--c-primary)" stroke-width="1.5"/>`;
|
||
}
|
||
const lastDate = hist.length ? hist[hist.length - 1].date : '';
|
||
|
||
// Top-Nutzer-Tabelle
|
||
const topUsers = Array.isArray(d.top_users) ? d.top_users.slice(0, 10) : [];
|
||
const userRows = topUsers.map(u => {
|
||
const emailDisplay = (u.email || '').length > 20
|
||
? '@' + (u.email || '').split('@')[1]
|
||
: UI.escape(u.email || '');
|
||
return `<tr>
|
||
<td style="padding:5px 8px;font-weight:500">${UI.escape(u.name || '–')}</td>
|
||
<td style="padding:5px 8px;color:var(--c-text-muted);font-size:var(--text-xs)">${emailDisplay}</td>
|
||
<td style="padding:5px 8px;text-align:right;font-weight:600">${u.total ?? 0}</td>
|
||
<td style="padding:5px 8px;text-align:right;color:var(--c-text-muted);font-size:var(--text-xs)">${u.last_week || '–'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
el.innerHTML = `
|
||
<div class="card" style="margin-bottom:var(--space-4);padding:var(--space-4)">
|
||
|
||
<!-- Header -->
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#path"></use>
|
||
</svg>
|
||
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text);flex:1">
|
||
OpenRouteService
|
||
</span>
|
||
<div style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
|
||
<span style="font-size:var(--text-xs);font-weight:700;padding:3px 10px;border-radius:999px;
|
||
background:${barColor}22;color:${barColor};border:1px solid ${barColor}44">
|
||
${count.toLocaleString('de')} / ${limit.toLocaleString('de')} heute
|
||
</span>
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
|
||
${weekTotal} diese Woche · ${totalAll} gesamt (30 Tage)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fortschrittsbalken -->
|
||
<div style="height:6px;border-radius:3px;background:var(--c-border);overflow:hidden;margin-bottom:var(--space-4)">
|
||
<div style="height:100%;width:${pct}%;background:${barColor};border-radius:3px;transition:width 0.6s ease"></div>
|
||
</div>
|
||
|
||
<!-- Sparkline -->
|
||
<div style="margin-bottom:var(--space-1)">
|
||
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:60px;display:block">
|
||
${sparkline}
|
||
</svg>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);
|
||
color:var(--c-text-muted);margin-bottom:var(--space-4)">
|
||
<span>30 Tage</span>
|
||
<span>${UI.escape(lastDate)}</span>
|
||
</div>
|
||
|
||
${topUsers.length ? `
|
||
<!-- Top-Nutzer -->
|
||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.05em;
|
||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">Aktivste Nutzer</div>
|
||
<div style="overflow-x:auto">
|
||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
|
||
<thead>
|
||
<tr style="border-bottom:1px solid var(--c-border)">
|
||
<th style="text-align:left;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Name</th>
|
||
<th style="text-align:left;padding:4px 8px;color:var(--c-text-muted);font-weight:600">E-Mail</th>
|
||
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Gesamt</th>
|
||
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Letzte Woche</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${userRows}</tbody>
|
||
</table>
|
||
</div>` : ''}
|
||
|
||
</div>`;
|
||
}
|
||
|
||
function _formatUptime(secs) {
|
||
const d = Math.floor(secs / 86400);
|
||
const h = Math.floor((secs % 86400) / 3600);
|
||
const m = Math.floor((secs % 3600) / 60);
|
||
if (d > 0) return `${d}d ${h}h`;
|
||
if (h > 0) return `${h}h ${m}min`;
|
||
return `${m}min`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: JOBS
|
||
// ------------------------------------------------------------------
|
||
// TAB: MODERATION
|
||
// ------------------------------------------------------------------
|
||
function _ageLabel(createdAt) {
|
||
if (!createdAt) return '';
|
||
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
|
||
const overdue = h >= 24;
|
||
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
|
||
return `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
|
||
margin-left:6px;${overdue
|
||
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
|
||
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
|
||
${overdue ? '⚠️ ' : ''}${label}
|
||
</span>`;
|
||
}
|
||
|
||
function _historySection(label, items, renderItem) {
|
||
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
|
||
return `
|
||
<details class="mb-4">
|
||
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:var(--space-2);
|
||
font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||
text-transform:uppercase;letter-spacing:.06em;padding:var(--space-2) 0;
|
||
border-top:1px solid var(--c-border)">
|
||
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
|
||
<svg class="ph-icon" style="margin-left:auto;transition:transform .2s" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#caret-down"></use>
|
||
</svg>
|
||
</summary>
|
||
<div style="margin-top:var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)">
|
||
${items.map(item => `
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||
border-radius:var(--radius-sm);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
|
||
${renderItem(item)}
|
||
</div>`).join('')}
|
||
</div>
|
||
</details>`;
|
||
}
|
||
|
||
async function _renderModeration(el) {
|
||
el.innerHTML = `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||
<button class="btn btn-ghost btn-sm" id="adm-mod-refresh">${UI.icon('arrows-clockwise')} Aktualisieren</button>
|
||
</div>
|
||
<div id="adm-mod-content">Lade…</div>
|
||
`;
|
||
el.querySelector('#adm-mod-refresh').addEventListener('click', () => _loadModeration(el.querySelector('#adm-mod-content')));
|
||
await _loadModeration(el.querySelector('#adm-mod-content'));
|
||
}
|
||
|
||
async function _loadModeration(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||
|
||
const [zuchter, fotos, reports, poiEdits] = await Promise.all([
|
||
API.get('/wiki/zuchter/pending').catch(() => []),
|
||
API.get('/wiki/foto-submissions').catch(() => []),
|
||
API.get('/moderation/reports').catch(() => []),
|
||
API.get('/moderation/poi-edits').catch(() => []),
|
||
]);
|
||
const zuchterPending = zuchter.filter(z => !z.verified);
|
||
const zuchterDone = zuchter.filter(z => z.verified);
|
||
const fotosPending = fotos.filter(f => f.status === 'pending');
|
||
const fotosDone = fotos.filter(f => f.status !== 'pending');
|
||
const reportsPending = reports.filter(r => !r.resolved);
|
||
const reportsDone = reports.filter(r => r.resolved);
|
||
const poiPending = poiEdits.filter(e => e.status === 'pending');
|
||
const poiDone = poiEdits.filter(e => e.status !== 'pending');
|
||
|
||
const modItems = [
|
||
{ label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' },
|
||
{ label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' },
|
||
{ label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' },
|
||
{ label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' },
|
||
].filter(i => i.count > 0);
|
||
|
||
let html = `
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
|
||
background:var(--c-surface);border:1px solid var(--c-border);
|
||
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4);
|
||
margin-bottom:var(--space-4)">
|
||
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
|
||
${UI.icon('check-square')} Zu erledigen
|
||
</span>
|
||
${modItems.length === 0
|
||
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
|
||
${UI.icon('check-circle')} Alles erledigt
|
||
</span>`
|
||
: modItems.map(i => `
|
||
<span style="display:inline-flex;align-items:center;gap:4px;
|
||
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
|
||
border:1px solid var(--c-warning,#e65100);border-radius:999px;
|
||
padding:2px 10px;font-size:var(--text-xs);font-weight:700">
|
||
${UI.icon(i.icon)} ${i.label}
|
||
<strong style="background:var(--c-warning,#e65100);color:#fff;
|
||
border-radius:999px;padding:0 6px;margin-left:2px">${i.count}</strong>
|
||
</span>`).join('')
|
||
}
|
||
</div>`;
|
||
|
||
// --- Züchter-Einreichungen ---
|
||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||
margin-bottom:var(--space-3)">
|
||
Züchter-Einreichungen
|
||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
|
||
</h3>`;
|
||
|
||
if (!zuchterPending.length) {
|
||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
|
||
} else {
|
||
html += `<div class="card adm-table-card mb-3"><div class="adm-table-scroll"><table class="adm-table">
|
||
<thead><tr style="background:var(--c-surface-2);text-align:left">
|
||
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
|
||
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
|
||
</tr></thead><tbody>
|
||
${zuchterPending.map((z, i) => `
|
||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||
<td class="adm-td" style="font-weight:var(--weight-semibold)">${UI.escape(z.rasse_slug)}</td>
|
||
<td class="adm-td">${UI.escape(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${UI.escape(z.zwingername)}</span>` : ''}</td>
|
||
<td class="adm-td">${UI.escape([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
|
||
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
|
||
<td class="adm-td">${_ageLabel(z.created_at)}</td>
|
||
<td class="adm-td">${z.website ? `<a href="${UI.escape(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
|
||
<td class="adm-td" style="text-align:right;white-space:nowrap">
|
||
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
|
||
<button class="btn btn-sm btn-ghost adm-zuchter-delete text-danger" data-id="${z.id}"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody></table></div></div>`;
|
||
}
|
||
// Züchter-History
|
||
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
|
||
z => `<span style="font-weight:600">${UI.escape(z.name)}</span> · ${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 += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||
margin-bottom:var(--space-3)">
|
||
Wiki-Foto-Einreichungen
|
||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
|
||
</h3>`;
|
||
|
||
if (!fotosPending.length) {
|
||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
|
||
} else {
|
||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
|
||
${fotosPending.map(f => `
|
||
<div class="card p-4">
|
||
<img src="${UI.escape(f.foto_url)}" alt=""
|
||
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
|
||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${UI.escape(f.rasse_name)}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${UI.escape(f.user_name)}</div>
|
||
<div class="mb-3">${_ageLabel(f.created_at)}</div>
|
||
${f.aktuell_foto ? `<img src="${UI.escape(f.aktuell_foto)}" alt="Aktuell"
|
||
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
|
||
opacity:.5;margin-bottom:var(--space-2)">
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">↑ aktuelles Foto</div>` : ''}
|
||
<div class="flex-gap-2">
|
||
<button class="btn btn-sm btn-primary adm-foto-approve flex-1" data-id="${f.id}">✓</button>
|
||
<button class="btn btn-sm btn-ghost adm-foto-reject text-danger" data-id="${f.id}">✗</button>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
// Fotos-History
|
||
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
|
||
f => `<img src="${UI.escape(f.foto_url)}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px">
|
||
<span style="font-weight:600">${UI.escape(f.rasse_name||'?')}</span> · 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 += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||
margin:var(--space-4) 0 var(--space-3)">
|
||
Forum-Meldungen
|
||
<span style="background:${reportsPending.length ? 'var(--c-danger)' : 'var(--c-primary)'};color:#fff;
|
||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
|
||
${reportsPending.length}
|
||
</span>
|
||
</h3>`;
|
||
if (!reportsPending.length) {
|
||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine offenen Meldungen.</p>`;
|
||
} else {
|
||
html += `<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||
${reportsPending.map(r => `
|
||
<div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)">
|
||
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
|
||
<div class="flex-1-min">
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
|
||
${UI.escape(r.target_type)} #${r.target_id} · Gemeldet von <strong>${UI.escape(r.melder_name || '?')}</strong>
|
||
${_ageLabel(r.created_at)}
|
||
</div>
|
||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
|
||
Grund: ${UI.escape(r.grund)}
|
||
</div>
|
||
${r.content_preview ? `
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||
border-radius:var(--radius-sm)">${UI.escape(r.content_preview)}</div>` : ''}
|
||
</div>
|
||
<button class="btn btn-sm btn-primary adm-mod-resolve" data-rid="${r.id}" title="Als erledigt markieren">
|
||
${UI.icon('check')}
|
||
</button>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
// Meldungen-History
|
||
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
|
||
r => `${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 += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||
margin:var(--space-2) 0 var(--space-3)">
|
||
POI-Korrekturen
|
||
<span style="background:${poiPending.length ? 'var(--c-warning,#e65100)' : 'var(--c-primary)'};color:#fff;
|
||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
|
||
${poiPending.length}
|
||
</span>
|
||
</h3>`;
|
||
if (!poiPending.length) {
|
||
html += `<p class="text-sm-muted">Keine ausstehenden POI-Korrekturen.</p>`;
|
||
} else {
|
||
html += `<div class="card adm-table-card"><div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead><tr style="background:var(--c-surface-2);text-align:left">
|
||
<th class="adm-th">Ort</th>
|
||
<th class="adm-th">Feld</th>
|
||
<th class="adm-th">Alt</th>
|
||
<th class="adm-th">Neu</th>
|
||
<th class="adm-th">Von</th>
|
||
<th class="adm-th">Alter</th>
|
||
<th class="adm-th"></th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${poiPending.map((e, i) => `
|
||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||
<td class="adm-td" style="font-weight:var(--weight-semibold)">${UI.escape(e.poi_name || `OSM #${e.osm_id}`)}</td>
|
||
<td class="adm-td"><code class="text-xs">${UI.escape(e.field)}</code></td>
|
||
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${UI.escape(e.old_value || '—')}</td>
|
||
<td class="adm-td text-xs">${UI.escape(e.new_value || '—')}</td>
|
||
<td class="adm-td text-muted">${UI.escape(e.einreicher_name || '?')}</td>
|
||
<td class="adm-td">${_ageLabel(e.created_at)}</td>
|
||
<td class="adm-td" style="text-align:right;white-space:nowrap">
|
||
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
|
||
${UI.icon('check')}
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost adm-poi-reject text-danger" data-id="${e.id}">
|
||
${UI.icon('x')}
|
||
</button>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div></div>`;
|
||
}
|
||
// POI-History
|
||
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
|
||
e => `<span style="font-weight:600">${UI.escape(e.poi_name||`OSM #${e.osm_id}`)}</span> ·
|
||
<code class="text-xs">${UI.escape(e.field)}</code>:
|
||
<span style="text-decoration:line-through;color:var(--c-text-muted)">${UI.escape(e.old_value||'—')}</span> →
|
||
${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 = `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||
<button class="btn btn-ghost btn-sm" id="adm-zuchter-refresh">
|
||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||
</button>
|
||
</div>
|
||
<div id="adm-zuchter-antraege">Lade…</div>
|
||
<div id="adm-zuchter-liste" class="mt-4">Lade…</div>
|
||
`;
|
||
el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => {
|
||
_loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege'));
|
||
_loadZuechterListe(el.querySelector('#adm-zuchter-liste'));
|
||
});
|
||
await Promise.all([
|
||
_loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')),
|
||
_loadZuechterListe(el.querySelector('#adm-zuchter-liste')),
|
||
]);
|
||
}
|
||
|
||
async function _loadZuechterAntraege(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||
let antraege;
|
||
try {
|
||
antraege = await API.breeder.pendingList();
|
||
} catch (e) {
|
||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Anträge konnten nicht geladen werden.');
|
||
return;
|
||
}
|
||
|
||
if (!antraege.length) {
|
||
el.innerHTML = `<div class="card p-4">
|
||
<div class="by-card-section-header">Offene Anträge</div>
|
||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-sm);color:var(--c-text-muted)">
|
||
${UI.icon('check-circle')} Keine offenen Anträge
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<div class="card" style="margin-bottom:0">
|
||
<div class="by-card-section-header">Offene Anträge (${antraege.length})</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-top:var(--space-3)">
|
||
${antraege.map(a => `
|
||
<div class="card p-4">
|
||
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
|
||
|
||
<!-- Infos -->
|
||
<div class="flex-1-min">
|
||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm);
|
||
color:var(--c-text);margin-bottom:var(--space-1)">
|
||
${UI.escape(a.name)}
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:400;margin-left:6px">
|
||
${UI.escape(a.email)}
|
||
</span>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-3);font-size:var(--text-xs);
|
||
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||
<span>${UI.icon('paw-print')} ${UI.escape(a.rasse_text || '–')}</span>
|
||
<span>${UI.icon('house-line')} ${UI.escape(a.zwingername || '–')}</span>
|
||
<span>${UI.icon('users')} ${UI.escape(a.verein || '–')}</span>
|
||
<span>${UI.icon('map-pin')} ${UI.escape(a.stadt || '–')}</span>
|
||
<span style="color:${a.vdh_mitglied ? 'var(--c-success)' : 'var(--c-text-muted)'}">
|
||
${UI.icon('certificate')} VDH: ${a.vdh_mitglied ? 'ja' : 'nein'}
|
||
</span>
|
||
${a.created_at ? `<span class="text-muted">${UI.icon('clock')} ${new Date(a.created_at).toLocaleDateString('de-DE')}</span>` : ''}
|
||
</div>
|
||
${a.beschreibung ? `
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||
border-radius:var(--radius-sm);margin-top:var(--space-1)">
|
||
${UI.escape(a.beschreibung)}
|
||
</div>` : ''}
|
||
</div>
|
||
|
||
<!-- Aktionen -->
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);flex-shrink:0">
|
||
<button class="btn btn-sm btn-secondary adm-breeder-docs"
|
||
data-uid="${a.user_id || a.id}">
|
||
${UI.icon('file-text')} Dokumente
|
||
</button>
|
||
<button class="btn btn-sm btn-primary adm-breeder-approve"
|
||
data-uid="${a.user_id || a.id}" data-name="${UI.escape(a.name)}">
|
||
${UI.icon('check')} Freischalten
|
||
</button>
|
||
<button class="btn btn-sm btn-ghost adm-breeder-reject text-danger"
|
||
data-uid="${a.user_id || a.id}" data-name="${UI.escape(a.name)}"
|
||
>
|
||
${UI.icon('x')} Ablehnen
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// Dokumente anzeigen
|
||
el.querySelectorAll('.adm-breeder-docs').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const uid = btn.dataset.uid;
|
||
let docs;
|
||
try {
|
||
docs = await API.breeder.documents(uid);
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Dokumente konnten nicht geladen werden.');
|
||
return;
|
||
}
|
||
UI.modal.open({
|
||
title: `${UI.icon('file-text')} Hochgeladene Dokumente`,
|
||
body: docs.length
|
||
? `<div class="flex-col-gap-3">
|
||
${docs.map(d => `
|
||
<a href="${UI.escape(API.breeder.documentUrl(uid, d.id))}"
|
||
target="_blank" rel="noopener"
|
||
class="btn btn-secondary"
|
||
style="text-align:left;word-break:break-all">
|
||
${UI.icon('file')} ${UI.escape(d.filename || d.name || 'Dokument ' + d.id)}
|
||
</a>`).join('')}
|
||
</div>`
|
||
: `<p class="text-sm-muted">Keine Dokumente hochgeladen.</p>`,
|
||
});
|
||
});
|
||
});
|
||
|
||
// Freischalten
|
||
el.querySelectorAll('.adm-breeder-approve').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const ok = window.confirm(`${btn.dataset.name} als Züchter freischalten?`);
|
||
if (!ok) return;
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await API.breeder.approve(btn.dataset.uid);
|
||
UI.toast.success(res.message || `${btn.dataset.name} freigeschaltet.`);
|
||
await _loadZuechterAntraege(el);
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Freischaltung fehlgeschlagen.');
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Ablehnen
|
||
el.querySelectorAll('.adm-breeder-reject').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const uid = btn.dataset.uid;
|
||
const name = btn.dataset.name;
|
||
UI.modal.open({
|
||
title: `${UI.icon('x-circle')} Antrag ablehnen: ${name}`,
|
||
body: `
|
||
<form id="breeder-reject-form" class="flex-col-gap-3">
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
|
||
Bitte gib einen Ablehnungsgrund an. Dieser wird dem Antragsteller mitgeteilt.
|
||
</p>
|
||
<textarea id="breeder-reject-grund" name="grund" rows="4" required
|
||
placeholder="z. B. Dokumente unvollständig, Rasse nicht unterstützt…"
|
||
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
font-size:var(--text-sm);font-family:inherit;
|
||
background:var(--c-surface);color:var(--c-text);resize:vertical"></textarea>
|
||
</form>
|
||
`,
|
||
footer: `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||
<button class="btn btn-primary" id="breeder-reject-submit"
|
||
form="breeder-reject-form" style="width:100%;background:var(--c-danger);border-color:var(--c-danger)">
|
||
Antrag ablehnen
|
||
</button>
|
||
<button class="btn btn-ghost" data-modal-close>Abbrechen</button>
|
||
</div>
|
||
`,
|
||
});
|
||
|
||
document.getElementById('breeder-reject-form')?.addEventListener('submit', async ev => {
|
||
ev.preventDefault();
|
||
const grund = document.getElementById('breeder-reject-grund')?.value?.trim();
|
||
if (!grund) {
|
||
UI.toast.warning('Bitte einen Ablehnungsgrund angeben.');
|
||
return;
|
||
}
|
||
const submitBtn = document.getElementById('breeder-reject-submit');
|
||
if (submitBtn) submitBtn.disabled = true;
|
||
try {
|
||
const res = await API.breeder.reject(uid, grund);
|
||
UI.modal.close?.();
|
||
UI.toast.success(res.message || `Antrag von ${name} abgelehnt.`);
|
||
await _loadZuechterAntraege(el);
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Ablehnung fehlgeschlagen.');
|
||
if (submitBtn) submitBtn.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
async function _loadZuechterListe(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||
let breeders;
|
||
try {
|
||
breeders = await API.breeder.allList();
|
||
} catch (e) {
|
||
el.innerHTML = _emptyState('warning', 'Fehler', e.message);
|
||
return;
|
||
}
|
||
|
||
const tierBadge = t => {
|
||
if (t === 'breeder') return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#C4843A;color:#fff">Züchter-Abo</span>`;
|
||
if (t === 'breeder_test') return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#aaa;color:#fff">Test</span>`;
|
||
return `<span style="display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;background:#eee;color:#666">Standard</span>`;
|
||
};
|
||
|
||
const rows = breeders.map(b => `
|
||
<tr>
|
||
<td style="padding:var(--space-2) var(--space-3)">
|
||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(b.name)}</div>
|
||
<div class="text-xs-muted">${UI.escape(b.email)}</div>
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm)">${UI.escape(b.zwingername || '—')}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(b.rasse_text || '—')}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(b.stadt || '—')}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-size:var(--text-xs)">
|
||
${b.wuerfe_count || 0} Würfe<br>
|
||
<span class="text-muted">${b.hunde_count || 0} Hunde</span>
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(b.subscription_tier)}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||
${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'}
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3)">
|
||
<button class="btn btn-sm btn-ghost adm-breeder-tier-btn text-xs"
|
||
data-uid="${b.id}" data-name="${UI.escape(b.name)}" data-tier="${UI.escape(b.subscription_tier || 'standard')}"
|
||
>
|
||
Abo
|
||
</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
el.innerHTML = `
|
||
<div class="card adm-table-card">
|
||
<div class="by-card-section-header">Alle Züchter (${breeders.length})</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table" style="width:100%;border-collapse:collapse">
|
||
<thead><tr>
|
||
${['Nutzer','Zwingername','Rasse','Stadt','Aktivität','Abo','Seit',''].map(h =>
|
||
`<th style="padding:var(--space-2) var(--space-3);text-align:left;
|
||
font-size:var(--text-xs);color:var(--c-text-muted);font-weight:600;
|
||
border-bottom:1px solid var(--c-border);white-space:nowrap">${h}</th>`
|
||
).join('')}
|
||
</tr></thead>
|
||
<tbody>
|
||
${rows || `<tr><td colspan="8" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Züchter</td></tr>`}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
|
||
el.querySelectorAll('.adm-breeder-tier-btn').forEach(btn => {
|
||
btn.addEventListener('click', () =>
|
||
_changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier)
|
||
);
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
async function _renderJobs(el) {
|
||
el.innerHTML = `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||
<button class="btn btn-ghost btn-sm" id="adm-jobs-refresh">
|
||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||
</button>
|
||
</div>
|
||
<div id="adm-jobs-list">Lade…</div>
|
||
`;
|
||
el.querySelector('#adm-jobs-refresh').addEventListener('click', () => _loadJobs(el.querySelector('#adm-jobs-list')));
|
||
await _loadJobs(el.querySelector('#adm-jobs-list'));
|
||
}
|
||
|
||
async function _loadJobs(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||
const jobs = await API.get('/admin/scheduler/jobs');
|
||
if (!jobs.length) {
|
||
el.innerHTML = _emptyState('timer', 'Keine Jobs', 'Der Scheduler hat keine registrierten Jobs.');
|
||
return;
|
||
}
|
||
el.innerHTML = `
|
||
<div class="card adm-table-card">
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead>
|
||
<tr style="background:var(--c-surface-2);text-align:left">
|
||
<th class="adm-th">Job</th>
|
||
<th class="adm-th">Nächster Lauf</th>
|
||
<th class="adm-th">Trigger</th>
|
||
<th class="adm-th"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${jobs.map((j, i) => `
|
||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||
<td class="adm-td" style="font-weight:var(--weight-semibold);color:var(--c-text)">
|
||
${UI.escape(j.name)}
|
||
<div class="adm-job-id">${UI.escape(j.id)}</div>
|
||
</td>
|
||
<td class="adm-td" style="color:var(--c-text-secondary);white-space:nowrap">
|
||
${j.next_run_time ? _formatDateTime(j.next_run_time) : '<span class="text-muted">—</span>'}
|
||
</td>
|
||
<td class="adm-td adm-td-trigger">
|
||
${UI.escape(j.trigger)}
|
||
</td>
|
||
<td class="adm-td text-right">
|
||
<button class="btn btn-sm btn-ghost adm-job-trigger adm-icon-btn text-primary" data-id="${UI.escape(j.id)}" data-name="${UI.escape(j.name)}"
|
||
title="Jetzt ausführen">
|
||
${UI.icon('play')}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
el.querySelectorAll('.adm-job-trigger').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
btn.disabled = true;
|
||
try {
|
||
await API.post(`/admin/scheduler/trigger/${encodeURIComponent(btn.dataset.id)}`, {});
|
||
UI.toast.success(`Job "${btn.dataset.name}" wird ausgeführt.`);
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Fehler beim Auslösen des Jobs.');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function _formatDateTime(iso) {
|
||
try {
|
||
const d = new Date(iso);
|
||
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||
} catch { return iso; }
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: AUDIT-LOG
|
||
// ------------------------------------------------------------------
|
||
async function _renderPartner(el) {
|
||
const codes = (await API.get('/admin/partner/codes')) || [];
|
||
const profiles = (await API.get('/admin/partner/profiles').catch(() => [])) || [];
|
||
const qrBatches = (await API.get('/admin/partner/qr-batches').catch(() => [])) || [];
|
||
|
||
el.innerHTML = `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
||
|
||
<!-- Anleitung -->
|
||
<div class="by-card" style="padding:var(--space-4);background:var(--c-surface-2);border-left:3px solid var(--c-primary)">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-sm);font-weight:700">So funktioniert das Partner-System</h3>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);display:flex;flex-direction:column;gap:var(--space-2)">
|
||
<p style="margin:0"><strong>1. Partner-Code erstellen</strong> — Erstelle einen Code (z. B. <code>HUNDEBLOG</code>) für einen Influencer oder Partner. Der Code wird an die Person weitergegeben.</p>
|
||
<p style="margin:0"><strong>2. Registrierung mit Code</strong> — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als <em>Gründer</em> markiert (Platz #1–100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.</p>
|
||
<p style="margin:0"><strong>3. Partner-Status vergeben</strong> — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt <em>Partner-Badge</em> (blaues Badge im Profil) und <em>Gründer-Lizenz</em>. So ist auch er als Gründer #X sichtbar.</p>
|
||
<p style="margin:0"><strong>Max. 100 Gründer</strong> — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.</p>
|
||
<p style="margin:0"><strong>Freunde werben</strong> — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — lebenslang, sobald Bezahlfunktionen aktiv sind.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Neuen Code anlegen -->
|
||
<div class="by-card p-4">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuen Partner-Code erstellen</h3>
|
||
<form id="adm-partner-create" class="flex-col-gap-3">
|
||
<div class="grid-2">
|
||
<div>
|
||
<label class="form-label text-xs">Code</label>
|
||
<input class="form-control" name="code" placeholder="z. B. HUNDEBLOG"
|
||
style="text-transform:uppercase;font-family:monospace;letter-spacing:.08em" required>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Bezeichnung</label>
|
||
<input class="form-control" name="label" placeholder="z. B. Max Musterhund (Instagram)" required>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);align-items:center">
|
||
<div>
|
||
<label class="form-label text-xs">Max. Einlösungen <span class="text-muted">(leer = unbegrenzt)</span></label>
|
||
<input class="form-control" name="max_uses" type="number" min="1" placeholder="∞">
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);padding-top:var(--space-5)">
|
||
<input type="checkbox" id="adm-grants-founder" name="grants_founder" checked
|
||
style="width:16px;height:16px;accent-color:var(--c-primary)">
|
||
<label for="adm-grants-founder" style="font-size:var(--text-sm);cursor:pointer">
|
||
Gründer-Lizenz (lebenslang kostenlos)
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start">
|
||
${UI.icon('plus')} Code erstellen
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Aktive Codes -->
|
||
<div class="by-card p-4">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Aktive Codes</h3>
|
||
<div id="adm-partner-codes-list">
|
||
${codes.length === 0
|
||
? `<p class="text-sm-muted">Noch keine Partner-Codes angelegt.</p>`
|
||
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||
<thead>
|
||
<tr style="border-bottom:1px solid var(--c-border)">
|
||
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
|
||
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Bezeichnung</th>
|
||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Nutzungen</th>
|
||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Gründer</th>
|
||
<th style="padding:var(--space-2) var(--space-3)"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${codes.map(c => `
|
||
<tr style="border-bottom:1px solid var(--c-border)" data-code-id="${c.id}">
|
||
<td style="padding:var(--space-2) var(--space-3)">
|
||
<code style="font-weight:700;color:var(--c-primary);letter-spacing:.08em">${c.code}</code>
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3);color:var(--c-text)">
|
||
${c.label}
|
||
<div class="text-xs-muted">
|
||
${c.owner_name
|
||
? `👤 ${UI.escape(c.owner_name)}`
|
||
: `<button class="btn btn-ghost btn-sm adm-code-owner" data-id="${c.id}" style="font-size:var(--text-xs);padding:0 4px">👤 Besitzer zuordnen</button>`}
|
||
</div>
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">
|
||
${c.uses}${c.max_uses ? `/${c.max_uses}` : ''}
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:center">
|
||
${c.grants_founder ? '✓' : '—'}
|
||
</td>
|
||
<td style="padding:var(--space-2) var(--space-3)">
|
||
<button class="btn btn-ghost btn-sm adm-del-code" data-id="${c.id}"
|
||
style="color:var(--c-danger,#dc2626);font-size:var(--text-xs)">
|
||
${UI.icon('trash')} Löschen
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>`
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- QR-Kontingente (Sticker/Flyer mit Rückverfolgung) -->
|
||
<div class="by-card p-4">
|
||
<h3 style="margin:0 0 var(--space-2);font-size:var(--text-base)">QR-Kontingente</h3>
|
||
<p class="text-xs-muted" style="margin:0 0 var(--space-3)">
|
||
Druckfertige QR-Codes für Partner (Sticker, Flyer, Visitenkarten). Jeder Code ist einzeln
|
||
rückverfolgbar: Scans und Registrierungen werden pro Kontingent gezählt.
|
||
</p>
|
||
<form id="adm-qr-create" class="flex-col-gap-3" style="margin-bottom:var(--space-4)">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 100px;gap:var(--space-3)">
|
||
<div>
|
||
<label class="form-label text-xs">Partner-Code</label>
|
||
<select class="form-control" name="code_id" required>
|
||
${codes.map(c => `<option value="${c.id}">${UI.escape(c.code)} — ${UI.escape(c.label)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Bezeichnung</label>
|
||
<input class="form-control" name="label" placeholder="z. B. Sticker-Bestellung Juni" required>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Stückzahl</label>
|
||
<input class="form-control" name="quantity" type="number" min="1" max="500" value="24" required>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary btn-sm" style="align-self:flex-start"
|
||
${codes.length === 0 ? 'disabled title="Zuerst einen Partner-Code anlegen"' : ''}>
|
||
${UI.icon('qr-code')} Kontingent erstellen
|
||
</button>
|
||
</form>
|
||
${qrBatches.length === 0
|
||
? `<p class="text-sm-muted">Noch keine Kontingente bestellt.</p>`
|
||
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||
<thead>
|
||
<tr style="border-bottom:1px solid var(--c-border)">
|
||
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Code</th>
|
||
<th style="text-align:left;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Kontingent</th>
|
||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Stk.</th>
|
||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)">Scans</th>
|
||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)" title="E-Mail bestätigt">Registr.</th>
|
||
<th style="text-align:center;padding:var(--space-2) var(--space-3);color:var(--c-text-muted);font-weight:600;font-size:var(--text-xs)" title="Registriert, aber E-Mail (noch) unbestätigt">Versuche</th>
|
||
<th style="padding:var(--space-2) var(--space-3)"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${qrBatches.map(b => `
|
||
<tr style="border-bottom:1px solid var(--c-border)">
|
||
<td style="padding:var(--space-2) var(--space-3)"><code style="font-weight:700;color:var(--c-primary)">${UI.escape(b.code)}</code></td>
|
||
<td style="padding:var(--space-2) var(--space-3)">${UI.escape(b.label)}<div class="text-xs-muted">${(b.created_at || '').slice(0, 10)}</div></td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:center">${b.quantity}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600">${b.scans}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:center;font-weight:600;color:${b.registrations > 0 ? 'var(--c-success,#16a34a)' : 'inherit'}">${b.registrations}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:center;color:${b.attempts > 0 ? 'var(--c-warning,#e65100)' : 'var(--c-text-muted)'}">${b.attempts}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);text-align:right;white-space:nowrap">
|
||
${b.registrations + b.attempts > 0 ? `
|
||
<button class="btn btn-ghost btn-sm adm-qr-detail" data-id="${b.id}" title="Accounts anzeigen">
|
||
${UI.icon('users')}
|
||
</button>` : ''}
|
||
<a class="btn btn-sm btn-secondary" href="/api/admin/partner/qr-batches/${b.id}/pdf" download>
|
||
${UI.icon('file-pdf')} PDF
|
||
</a>
|
||
<button class="btn btn-ghost btn-sm adm-qr-del text-danger" data-id="${b.id}" data-label="${UI.escape(b.label)}">
|
||
${UI.icon('trash')}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
<tr class="hidden" id="adm-qr-detail-${b.id}">
|
||
<td colspan="7" style="padding:0 var(--space-3) var(--space-3);background:var(--c-surface-2)">
|
||
<div class="text-sm-muted" style="padding:var(--space-3) 0">Lädt…</div>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`}
|
||
</div>
|
||
|
||
<!-- Partner-Profil-Freigaben -->
|
||
<div class="by-card p-4">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">
|
||
Profil-Freigaben
|
||
${profiles.filter(p => p.submitted_at && p.approved === 0).length
|
||
? `<span class="badge" style="background:var(--c-warning,#f59e0b);color:#fff;margin-left:var(--space-2)">${profiles.filter(p => p.submitted_at && p.approved === 0).length} offen</span>` : ''}
|
||
</h3>
|
||
${profiles.length === 0
|
||
? `<p class="text-sm-muted">Noch keine Partner-Profile angelegt.</p>`
|
||
: profiles.map(p => `
|
||
<div style="border-bottom:1px solid var(--c-border)" data-pp-uid="${p.user_id}">
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0">
|
||
${p.logo_url
|
||
? `<img src="${UI.escape(p.logo_url)}" style="width:36px;height:36px;border-radius:var(--radius-md);object-fit:contain;background:var(--c-surface-2);flex-shrink:0">`
|
||
: `<div style="width:36px;height:36px;border-radius:var(--radius-md);background:var(--c-surface-2);flex-shrink:0"></div>`}
|
||
<div class="flex-1-min">
|
||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(p.display_name || p.name)}</div>
|
||
<div class="text-xs-muted">${UI.escape(p.name)} · ${UI.escape(p.email)}${p.photos?.length ? ` · ${p.photos.length} Medien` : ''}</div>
|
||
</div>
|
||
${p.approved === 1
|
||
? `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ Frei</span>`
|
||
: p.approved === -1
|
||
? `<span class="badge" style="background:#fee2e2;color:#dc2626">✗ Abgelehnt</span>`
|
||
: p.submitted_at
|
||
? `<span class="badge" style="background:#fef9c3;color:#a16207">⏳ Prüfen</span>`
|
||
: `<span class="badge">Entwurf</span>`}
|
||
<button class="btn btn-sm btn-secondary adm-pp-preview" data-uid="${p.user_id}">
|
||
${UI.icon('eye')} Vorschau
|
||
</button>
|
||
${p.approved !== 1 ? `<button class="btn btn-sm btn-primary adm-pp-review" data-uid="${p.user_id}" data-val="1">✓ Freigeben</button>` : ''}
|
||
${p.approved !== -1 ? `<button class="btn btn-sm btn-ghost adm-pp-review text-danger" data-uid="${p.user_id}" data-val="-1">✗</button>` : ''}
|
||
</div>
|
||
<!-- Vorschau: so erscheint die Karte auf der öffentlichen Partner-Seite -->
|
||
<div class="adm-pp-preview-card hidden" id="adm-pp-preview-${p.user_id}" style="margin:0 0 var(--space-3)">
|
||
<div class="text-xs-muted" style="margin-bottom:var(--space-2)">
|
||
${UI.icon('eye')} So erscheint die Karte auf der Partner-Seite:
|
||
</div>
|
||
<div class="by-card" style="padding:var(--space-4);position:relative;overflow:hidden;max-width:340px">
|
||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(135deg,#7c3aed,#a855f7)"></div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||
${p.logo_url
|
||
? `<img src="${UI.escape(p.logo_url)}" alt="" style="width:56px;height:56px;border-radius:var(--radius-md);object-fit:contain;flex-shrink:0;background:var(--c-surface-2);padding:4px">`
|
||
: `<div style="width:56px;height:56px;border-radius:50%;flex-shrink:0;background:linear-gradient(135deg,#7c3aed,#a855f7);display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:800;color:#fff">${UI.escape((p.display_name || p.name || '?')[0].toUpperCase())}</div>`}
|
||
<div class="flex-1-min">
|
||
<div style="font-weight:700;font-size:var(--text-base)">${UI.escape(p.display_name || p.name)}</div>
|
||
${p.tagline ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:1px">${UI.escape(p.tagline)}</div>` : ''}
|
||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-1)">
|
||
${p.website ? `<a href="${UI.escape(p.website)}" target="_blank" rel="noopener" style="font-size:var(--text-xs);color:var(--c-primary)">🌐 ${UI.escape(p.website.replace(/^https?:\/\//, ''))}</a>` : ''}
|
||
${p.instagram ? `<span class="text-xs-muted">📸 ${UI.escape(p.instagram)}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${p.bio ? `<p style="margin:var(--space-3) 0 0;font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${UI.escape(p.bio)}</p>` : ''}
|
||
${p.photos?.length ? `
|
||
<div style="display:grid;grid-template-columns:repeat(${Math.min(p.photos.length, 3)},1fr);gap:var(--space-1);margin-top:var(--space-3);border-radius:var(--radius-md);overflow:hidden">
|
||
${p.photos.slice(0, 3).map(url => {
|
||
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
|
||
return isVid
|
||
? `<video src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover" muted playsinline loop autoplay></video>`
|
||
: `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover">`;
|
||
}).join('')}
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
|
||
<!-- User-Status vergeben -->
|
||
<div class="by-card p-4">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Nutzer-Status manuell vergeben</h3>
|
||
<form id="adm-partner-grant" class="flex-col-gap-3">
|
||
<div>
|
||
<label class="form-label text-xs">User-ID oder Benutzername</label>
|
||
<input class="form-control" name="user_search" id="adm-grant-search"
|
||
placeholder="Name eingeben…" autocomplete="off">
|
||
<div id="adm-grant-result" class="mt-2"></div>
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-4)">
|
||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
|
||
<input type="checkbox" name="is_founder" value="1"
|
||
style="width:16px;height:16px;accent-color:var(--c-primary)">
|
||
Gründer-Lizenz
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-sm)">
|
||
<input type="checkbox" name="is_partner" value="1"
|
||
style="width:16px;height:16px;accent-color:var(--c-primary)">
|
||
Partner-Badge (Creator)
|
||
</label>
|
||
</div>
|
||
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
|
||
${UI.icon('check')} Status setzen
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
</div>
|
||
`;
|
||
|
||
// Code-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
|
||
? `<div class="text-sm-muted" style="padding:var(--space-3) 0">Keine Accounts.</div>`
|
||
: regs.map(u => `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border);font-size:var(--text-sm)">
|
||
<div class="flex-1-min">
|
||
<span style="font-weight:600">${UI.escape(u.name)}</span>
|
||
<span class="text-xs-muted">· ${UI.escape(u.email)}</span>
|
||
</div>
|
||
<span class="text-xs-muted" title="Über welchen Einzel-Code (Sticker-Nr.)">#${u.seq}</span>
|
||
<span class="text-xs-muted">${(u.created_at || '').slice(0, 16).replace(' ', ' · ')}</span>
|
||
${u.email_verified
|
||
? `<span class="badge" style="background:#dcfce7;color:#16a34a">✓ bestätigt</span>`
|
||
: `<span class="badge" style="background:#fef9c3;color:#a16207" title="Registriert, E-Mail noch nicht bestätigt">⏳ Versuch</span>`}
|
||
</div>`).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 = `<p class="text-xs-muted">Kein User gefunden.</p>`;
|
||
return;
|
||
}
|
||
grantResult.innerHTML = users.map(u => `
|
||
<div class="adm-grant-user" data-id="${u.id}" data-name="${u.name}"
|
||
data-founder="${u.is_founder||0}" data-partner="${u.is_partner||0}"
|
||
style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);
|
||
cursor:pointer;background:var(--c-surface-2);margin-bottom:2px;
|
||
font-size:var(--text-sm);display:flex;justify-content:space-between">
|
||
<span><strong>${u.name}</strong></span>
|
||
<span style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||
${u.rolle}${u.is_founder ? ' · ⭐' : ''}${u.is_partner ? ' · 🤝' : ''}
|
||
</span>
|
||
</div>
|
||
`).join('');
|
||
grantResult.querySelectorAll('.adm-grant-user').forEach(div => {
|
||
div.addEventListener('click', () => {
|
||
_grantUserId = parseInt(div.dataset.id);
|
||
searchInput.value = div.dataset.name;
|
||
// Aktuellen Status in Checkboxen setzen
|
||
const form = el.querySelector('#adm-partner-grant');
|
||
if (form) {
|
||
form.querySelector('[name="is_founder"]').checked = div.dataset.founder === '1';
|
||
form.querySelector('[name="is_partner"]').checked = div.dataset.partner === '1';
|
||
}
|
||
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}</p>`;
|
||
});
|
||
});
|
||
} catch(e) {
|
||
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-danger)">${e.message || 'Suchfehler'}</p>`;
|
||
}
|
||
}, 400);
|
||
});
|
||
|
||
el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; }
|
||
const btn = e.target.querySelector('[type="submit"]');
|
||
const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0;
|
||
const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0;
|
||
await UI.asyncButton(btn, async () => {
|
||
const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, {
|
||
is_founder: isFounder,
|
||
is_partner: isPartner,
|
||
});
|
||
if (!result) throw new Error('Keine Antwort vom Server.');
|
||
UI.toast.success(`Status für ${result.name} gesetzt.`);
|
||
grantResult.innerHTML = `<p style="font-size:var(--text-xs);color:var(--c-success,#16a34a)">✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}</p>`;
|
||
}).catch(e => UI.toast.error(e.message || 'Fehler beim Speichern.'));
|
||
});
|
||
}
|
||
|
||
async function _renderOutreach(el) {
|
||
const [templates, log] = await Promise.all([
|
||
API.get('/outreach/templates').catch(() => []),
|
||
API.get('/outreach/log').catch(() => []),
|
||
]);
|
||
|
||
const accountBadge = a => a === 'support'
|
||
? `<span style="font-size:10px;background:var(--c-warning-bg,#FEF3C7);color:var(--c-warning,#D97706);padding:1px 6px;border-radius:999px">support@</span>`
|
||
: `<span style="font-size:10px;background:var(--c-primary-bg,#EFF6FF);color:var(--c-primary,#2563EB);padding:1px 6px;border-radius:999px">partner@</span>`;
|
||
|
||
el.innerHTML = `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
|
||
|
||
<!-- Vorlagen-Manager -->
|
||
<div class="by-card p-4">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||
<h3 style="margin:0;font-size:var(--text-base)">Vorlagen</h3>
|
||
<button class="btn btn-sm btn-secondary" id="adm-tpl-new">
|
||
${UI.icon('plus')} Neue Vorlage
|
||
</button>
|
||
</div>
|
||
${templates.length === 0
|
||
? `<p class="text-sm-muted">Noch keine Vorlagen.</p>`
|
||
: `<div class="flex-col-gap-2">
|
||
${templates.map(t => `
|
||
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);
|
||
background:var(--c-bg-elevated);border-radius:var(--radius-md);border:1px solid var(--c-border)">
|
||
<div class="flex-1-min">
|
||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||
<span style="font-size:var(--text-sm);font-weight:600">${UI.escape(t.label)}</span>
|
||
${accountBadge(t.from_account)}
|
||
</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||
${UI.escape(t.subject)}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
|
||
<button class="btn btn-xs btn-secondary adm-tpl-load" data-id="${t.id}" title="In Compose laden">
|
||
${UI.icon('arrow-bend-up-left')}
|
||
</button>
|
||
<button class="btn btn-xs btn-secondary adm-tpl-edit" data-id="${t.id}" title="Bearbeiten">
|
||
${UI.icon('pencil-simple')}
|
||
</button>
|
||
<button class="btn btn-xs btn-danger adm-tpl-del" data-id="${t.id}" title="Löschen">
|
||
${UI.icon('trash')}
|
||
</button>
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>`}
|
||
</div>
|
||
|
||
<!-- Compose -->
|
||
<div class="by-card p-4">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">E-Mail senden</h3>
|
||
<form id="adm-outreach-form" class="flex-col-gap-3">
|
||
|
||
<!-- Absender -->
|
||
<div class="grid-2">
|
||
<div>
|
||
<label class="form-label text-xs">Absender</label>
|
||
<select id="adm-outreach-from" class="form-control">
|
||
<option value="partner">partner@banyaro.app (Influencer/Partner)</option>
|
||
<option value="support">support@banyaro.app (Support/Moderation)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">
|
||
Empfänger <span class="text-muted">(Komma-getrennt)</span>
|
||
</label>
|
||
<input class="form-control" id="adm-outreach-to" type="text"
|
||
placeholder="name@example.com, andere@example.com">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Betreff -->
|
||
<div>
|
||
<label class="form-label text-xs">Betreff</label>
|
||
<input class="form-control" id="adm-outreach-subject" type="text">
|
||
</div>
|
||
|
||
<!-- Text -->
|
||
<div>
|
||
<label class="form-label text-xs">Text</label>
|
||
<textarea id="adm-outreach-body" class="form-control" rows="14"
|
||
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:var(--space-3);align-items:center">
|
||
<button type="submit" class="btn btn-primary">
|
||
${UI.icon('paper-plane-tilt')} Senden
|
||
</button>
|
||
<span class="text-xs-muted">
|
||
{name} wird nicht automatisch ersetzt — bitte manuell anpassen.
|
||
</span>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Versand-Log -->
|
||
<div class="by-card p-4">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Versand-Log</h3>
|
||
${log.length === 0
|
||
? `<p class="text-sm-muted">Noch keine E-Mails gesendet.</p>`
|
||
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
|
||
<thead>
|
||
<tr style="border-bottom:1px solid var(--c-border)">
|
||
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Von</th>
|
||
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Empfänger</th>
|
||
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Betreff</th>
|
||
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wer</th>
|
||
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wann</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${log.map((l, i) => `
|
||
<tr data-log-idx="${i}" class="by-hover-surface2" style="border-bottom:1px solid var(--c-border);cursor:pointer">
|
||
<td class="p-2">${accountBadge(l.from_account)}</td>
|
||
<td class="p-2">${UI.escape(l.recipient)}</td>
|
||
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${UI.escape(l.subject)}</td>
|
||
<td style="padding:var(--space-2);color:var(--c-text-muted)">${UI.escape(l.sent_by_name || '')}</td>
|
||
<td style="padding:var(--space-2);color:var(--c-text-muted)">${(l.sent_at||'').slice(0,16).replace('T',' ')}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`}
|
||
</div>
|
||
|
||
</div>
|
||
`;
|
||
|
||
// Log-Zeile: Mail-Inhalt anzeigen
|
||
el.querySelectorAll('tr[data-log-idx]').forEach(row => {
|
||
row.addEventListener('click', () => {
|
||
const l = log[Number(row.dataset.logIdx)];
|
||
if (!l) return;
|
||
UI.modal.open({
|
||
title: UI.escape(l.subject),
|
||
body: `
|
||
<div style="margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-muted)">
|
||
<strong>An:</strong> ${UI.escape(l.recipient)} ·
|
||
<strong>Von:</strong> ${UI.escape(l.from_account)}@banyaro.app ·
|
||
${(l.sent_at||'').slice(0,16).replace('T',' ')}
|
||
</div>
|
||
<pre style="white-space:pre-wrap;font-family:inherit;font-size:var(--text-sm);
|
||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||
padding:var(--space-3);max-height:60vh;overflow-y:auto;
|
||
color:var(--c-text)">${UI.escape(l.body || '(kein Text gespeichert)')}</pre>`,
|
||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||
});
|
||
});
|
||
});
|
||
|
||
// Vorlage in Compose laden
|
||
function _loadTplIntoCompose(id) {
|
||
const tpl = templates.find(t => t.id === id);
|
||
if (!tpl) return;
|
||
el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner';
|
||
el.querySelector('#adm-outreach-subject').value = tpl.subject;
|
||
el.querySelector('#adm-outreach-body').value = tpl.body;
|
||
}
|
||
|
||
el.querySelectorAll('.adm-tpl-load').forEach(btn => {
|
||
btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id)));
|
||
});
|
||
|
||
// Vorlage löschen
|
||
el.querySelectorAll('.adm-tpl-del').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
if (!window.confirm('Vorlage löschen?')) return;
|
||
await API.del(`/outreach/templates/${btn.dataset.id}`);
|
||
await _renderOutreach(el);
|
||
});
|
||
});
|
||
|
||
// Vorlage bearbeiten
|
||
el.querySelectorAll('.adm-tpl-edit').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const tpl = templates.find(t => t.id === Number(btn.dataset.id));
|
||
if (tpl) _openTplModal(el, tpl);
|
||
});
|
||
});
|
||
|
||
// Neue Vorlage
|
||
el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null));
|
||
|
||
// Senden
|
||
el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const btn = e.target.querySelector('[type="submit"]');
|
||
const from_account = el.querySelector('#adm-outreach-from').value;
|
||
const to = (el.querySelector('#adm-outreach-to').value || '')
|
||
.split(',').map(s => s.trim()).filter(Boolean);
|
||
const subject = el.querySelector('#adm-outreach-subject').value.trim();
|
||
const body = el.querySelector('#adm-outreach-body').value.trim();
|
||
|
||
if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; }
|
||
if (!subject) { UI.toast.warning('Betreff fehlt.'); return; }
|
||
if (!body) { UI.toast.warning('Text fehlt.'); return; }
|
||
|
||
await UI.asyncButton(btn, async () => {
|
||
const res = await API.post('/outreach/send', { to, subject, body, from_account });
|
||
if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`);
|
||
if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`);
|
||
await _renderOutreach(el);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _openTplModal(el, tpl) {
|
||
const isNew = !tpl;
|
||
const id = `adm-tpl-modal-${Date.now()}`;
|
||
UI.modal.open({
|
||
title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten',
|
||
body: `
|
||
<form id="${id}" class="flex-col-gap-3">
|
||
<div class="grid-2">
|
||
<div>
|
||
<label class="form-label text-xs">Name (intern)</label>
|
||
<input class="form-control" id="${id}-key" type="text" placeholder="z.B. willkommen_neu"
|
||
value="${UI.escape(tpl?.key || '')}" ${isNew ? '' : 'readonly'}>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Absender</label>
|
||
<select id="${id}-from" class="form-control">
|
||
<option value="partner" ${(tpl?.from_account||'partner')==='partner'?'selected':''}>partner@banyaro.app</option>
|
||
<option value="support" ${tpl?.from_account==='support'?'selected':''}>support@banyaro.app</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Bezeichnung (sichtbar)</label>
|
||
<input class="form-control" id="${id}-label" type="text" placeholder="z.B. Willkommensnachricht"
|
||
value="${UI.escape(tpl?.label || '')}">
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Betreff</label>
|
||
<input class="form-control" id="${id}-subject" type="text"
|
||
value="${UI.escape(tpl?.subject || '')}">
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Text</label>
|
||
<textarea id="${id}-body" class="form-control" rows="12"
|
||
style="font-family:monospace;font-size:var(--text-sm);resize:vertical">${UI.escape(tpl?.body || '')}</textarea>
|
||
</div>
|
||
</form>`,
|
||
footer: `
|
||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
|
||
});
|
||
|
||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const payload = {
|
||
label: document.getElementById(`${id}-label`).value.trim(),
|
||
subject: document.getElementById(`${id}-subject`).value.trim(),
|
||
body: document.getElementById(`${id}-body`).value.trim(),
|
||
from_account: document.getElementById(`${id}-from`).value,
|
||
};
|
||
if (!payload.label || !payload.subject || !payload.body) {
|
||
UI.toast.warning('Alle Felder ausfüllen.'); return;
|
||
}
|
||
if (isNew) {
|
||
const key = document.getElementById(`${id}-key`).value.trim();
|
||
if (!key) { UI.toast.warning('Interner Name fehlt.'); return; }
|
||
await API.post('/outreach/templates', { ...payload, key });
|
||
} else {
|
||
await API.put(`/outreach/templates/${tpl.id}`, payload);
|
||
}
|
||
UI.modal.close();
|
||
await _renderOutreach(el);
|
||
});
|
||
}
|
||
|
||
async function _renderAudit(el) {
|
||
el.innerHTML = `
|
||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||
<button class="btn btn-ghost btn-sm" id="adm-audit-refresh">
|
||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||
</button>
|
||
</div>
|
||
<div id="adm-audit-list">Lade…</div>
|
||
`;
|
||
el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list')));
|
||
await _loadAudit(el.querySelector('#adm-audit-list'));
|
||
}
|
||
|
||
async function _loadAudit(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||
const rows = await API.get('/admin/audit?limit=50');
|
||
if (!rows.length) {
|
||
el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.');
|
||
return;
|
||
}
|
||
el.innerHTML = `
|
||
<div class="card adm-table-card">
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead>
|
||
<tr style="background:var(--c-surface-2);text-align:left">
|
||
<th class="adm-th">Wann</th>
|
||
<th class="adm-th">Admin</th>
|
||
<th class="adm-th">Aktion</th>
|
||
<th class="adm-th">Ziel</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows.map((r, i) => `
|
||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||
<td class="adm-td" style="color:var(--c-text-muted);white-space:nowrap;font-size:var(--text-xs)">
|
||
${_formatDateTime(r.created_at)}
|
||
</td>
|
||
<td class="adm-td" style="color:var(--c-text);white-space:nowrap">
|
||
${UI.escape(r.admin_name || '—')}
|
||
</td>
|
||
<td class="adm-td">
|
||
<span class="adm-badge-mono">${UI.escape(r.action)}</span>
|
||
${r.detail ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${UI.escape(r.detail)}</div>` : ''}
|
||
</td>
|
||
<td class="adm-td" style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:nowrap">
|
||
${UI.escape(r.target || '—')}
|
||
</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// HELPERS
|
||
// ------------------------------------------------------------------
|
||
function _prompt(msg) {
|
||
return new Promise(resolve => {
|
||
UI.modal.open({
|
||
title: 'Eingabe',
|
||
body: `
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">${msg}</p>
|
||
<input id="adm-prompt-input" type="text"
|
||
style="width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
font-size:var(--text-sm);font-family:inherit;
|
||
background:var(--c-surface);color:var(--c-text)">
|
||
`,
|
||
footer: `
|
||
<button class="btn btn-primary" id="adm-prompt-ok" form="">OK</button>
|
||
<button class="btn btn-ghost" id="adm-prompt-cancel" form="">Abbrechen</button>
|
||
`,
|
||
});
|
||
document.getElementById('adm-prompt-ok')?.addEventListener('click', () => {
|
||
const val = document.getElementById('adm-prompt-input')?.value || '';
|
||
UI.modal.close();
|
||
resolve(val);
|
||
});
|
||
document.getElementById('adm-prompt-cancel')?.addEventListener('click', () => {
|
||
UI.modal.close();
|
||
resolve(null);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _emptyState(icon, title, text) {
|
||
return `
|
||
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||
<svg class="ph-icon" style="width:40px;height:40px;color:var(--c-border);
|
||
margin-bottom:var(--space-3)" aria-hidden="true">
|
||
<use href="/icons/phosphor.svg#${icon}"></use>
|
||
</svg>
|
||
<p style="font-weight:var(--weight-semibold);color:var(--c-text);margin:0 0 var(--space-1)">${title}</p>
|
||
${text ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">${text}</p>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// BEWERBUNGEN — Social-Media-Job
|
||
// ------------------------------------------------------------------
|
||
async function _renderBewerbungen(el) {
|
||
let _statusFilter = 'pending';
|
||
|
||
async function _load() {
|
||
el.innerHTML = `
|
||
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
|
||
${['pending','reviewing','accepted','rejected','alle'].map(s => `
|
||
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
|
||
${s==='pending' ? `${UI.icon('clock')} Neu`
|
||
: s==='reviewing' ? `${UI.icon('magnifying-glass')} In Prüfung`
|
||
: s==='accepted' ? `${UI.icon('check-circle')} Angenommen`
|
||
: s==='rejected' ? `${UI.icon('x')} Abgelehnt`
|
||
: 'Alle'}
|
||
</button>`).join('')}
|
||
</div>
|
||
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
|
||
|
||
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
_statusFilter = btn.dataset.s;
|
||
_load();
|
||
});
|
||
});
|
||
|
||
try {
|
||
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
|
||
const list = el.querySelector('#adm-bew-list');
|
||
if (!rows.length) {
|
||
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
|
||
return;
|
||
}
|
||
list.innerHTML = rows.map(r => `
|
||
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
|
||
<div class="flex-1-min">
|
||
<div style="font-weight:700;font-size:var(--text-base)">${UI.escape(r.name)}
|
||
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${UI.escape(r.username)})</span>` : ''}
|
||
</div>
|
||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
|
||
${UI.escape(r.email)} · @${UI.escape(r.social_handle||'—')}
|
||
${r.dog_name ? ` · 🐕 ${UI.escape(r.dog_name)} (${UI.escape(r.dog_rasse||'')})` : ''}
|
||
</div>
|
||
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
|
||
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
|
||
</div>
|
||
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
|
||
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
|
||
${UI.escape((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
|
||
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
|
||
<select class="form-control adm-bew-status" data-id="${r.id}"
|
||
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
|
||
<option value="pending" ${r.status==='pending' ?'selected':''}>⏳ Neu</option>
|
||
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
|
||
<option value="accepted" ${r.status==='accepted' ?'selected':''}>✅ Angenommen</option>
|
||
<option value="rejected" ${r.status==='rejected' ?'selected':''}>❌ Abgelehnt</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>`).join('');
|
||
|
||
list.querySelectorAll('.adm-bew-status').forEach(sel => {
|
||
sel.addEventListener('change', async () => {
|
||
const id = sel.dataset.id;
|
||
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
|
||
UI.toast.success('Status aktualisiert.');
|
||
setTimeout(_load, 500);
|
||
});
|
||
});
|
||
|
||
list.querySelectorAll('.adm-bew-view').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.id;
|
||
const app = await API.get(`/jobs/admin/applications/${id}`);
|
||
const docsHtml = app.docs?.length
|
||
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
|
||
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
|
||
📎 ${UI.escape(d.filename)}</a>`).join('')
|
||
: '<span class="text-sm-muted">Keine Anhänge</span>';
|
||
|
||
UI.modal.open({
|
||
title: `Bewerbung — ${UI.escape(app.name)}`,
|
||
body: `
|
||
<div style="display:grid;gap:var(--space-3)">
|
||
<div><b>E-Mail:</b> ${UI.escape(app.email)}</div>
|
||
<div><b>Social:</b> @${UI.escape(app.social_handle||'—')}</div>
|
||
${app.dog_name ? `<div><b>Hund:</b> ${UI.escape(app.dog_name)} (${UI.escape(app.dog_rasse||'')})</div>` : ''}
|
||
<div><b>Motivation:</b><br>
|
||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
|
||
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${UI.escape(app.motivation)}</div>
|
||
</div>
|
||
<div><b>Anhänge:</b><br>${docsHtml}</div>
|
||
<div>
|
||
<b>Admin-Notiz:</b>
|
||
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
|
||
placeholder="Interne Notiz / Nachricht an Bewerber">${UI.escape(app.admin_note||'')}</textarea>
|
||
</div>
|
||
</div>`,
|
||
footer: `
|
||
<button class="btn btn-secondary" data-modal-close>Schließen</button>
|
||
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
|
||
});
|
||
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
|
||
const note = document.getElementById('adm-bew-note')?.value || '';
|
||
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
|
||
UI.toast.success('Notiz gespeichert.');
|
||
UI.modal.close();
|
||
});
|
||
});
|
||
});
|
||
} catch (e) {
|
||
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
|
||
}
|
||
}
|
||
|
||
await _load();
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: HILFE / FAQ
|
||
async function _renderHilfe(el) {
|
||
const KAT_LABEL = {
|
||
installation: 'Installation & PWA',
|
||
erste_schritte: 'Erste Schritte',
|
||
standort: 'Standort & Wetter',
|
||
account: 'Account & Passwort',
|
||
features: 'Features erklärt',
|
||
probleme: 'Technische Probleme',
|
||
};
|
||
|
||
el.innerHTML = `
|
||
<div class="p-4">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;
|
||
margin-bottom:var(--space-4)">
|
||
<h2 style="margin:0;font-size:var(--text-lg)">Hilfe / FAQ</h2>
|
||
<button class="btn btn-primary btn-sm" id="adm-hilfe-neu">
|
||
${UI.icon('plus')} Neuer Artikel
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Neuer-Artikel-Formular (versteckt) -->
|
||
<div id="adm-hilfe-form" style="display:none;background:var(--c-surface-2);
|
||
border:1px solid var(--c-border);border-radius:var(--radius-lg);
|
||
padding:var(--space-4);margin-bottom:var(--space-4)">
|
||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Neuer Artikel</h3>
|
||
<div style="display:grid;gap:var(--space-3)">
|
||
<div>
|
||
<label style="font-size:var(--text-sm);font-weight:500;display:block;
|
||
margin-bottom:var(--space-1)">Kategorie</label>
|
||
<select id="adm-hilfe-kat" style="width:100%;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
|
||
${Object.entries(KAT_LABEL).map(([k,v]) =>
|
||
`<option value="${k}">${UI.escape(v)}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style="font-size:var(--text-sm);font-weight:500;display:block;
|
||
margin-bottom:var(--space-1)">Frage</label>
|
||
<input id="adm-hilfe-frage" type="text" placeholder="Frage eingeben…"
|
||
style="width:100%;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);
|
||
font-size:var(--text-sm);box-sizing:border-box">
|
||
</div>
|
||
<div>
|
||
<label style="font-size:var(--text-sm);font-weight:500;display:block;
|
||
margin-bottom:var(--space-1)">Antwort</label>
|
||
<textarea id="adm-hilfe-antwort" rows="4" placeholder="Antwort eingeben…"
|
||
style="width:100%;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);
|
||
font-size:var(--text-sm);box-sizing:border-box;
|
||
resize:vertical;font-family:inherit"></textarea>
|
||
</div>
|
||
<div class="flex-gap-2">
|
||
<label style="font-size:var(--text-sm);font-weight:500;margin-right:var(--space-2)">
|
||
Reihenfolge
|
||
</label>
|
||
<input id="adm-hilfe-sort" type="number" value="0" min="0"
|
||
style="width:80px;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
||
<button class="btn btn-secondary btn-sm" id="adm-hilfe-form-cancel">Abbrechen</button>
|
||
<button class="btn btn-primary btn-sm" id="adm-hilfe-form-save">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Artikel-Liste -->
|
||
<div id="adm-hilfe-list">
|
||
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
|
||
Lade…
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
async function _load() {
|
||
const listEl = el.querySelector('#adm-hilfe-list');
|
||
try {
|
||
const articles = await API.get('/help?all=1');
|
||
if (!articles.length) {
|
||
listEl.innerHTML = _emptyState('question', 'Noch keine FAQ-Artikel', '');
|
||
return;
|
||
}
|
||
|
||
// Gruppieren nach Kategorie
|
||
const grouped = {};
|
||
for (const a of articles) {
|
||
if (!grouped[a.kategorie]) grouped[a.kategorie] = [];
|
||
grouped[a.kategorie].push(a);
|
||
}
|
||
|
||
let html = '';
|
||
for (const [kat, items] of Object.entries(grouped)) {
|
||
const label = KAT_LABEL[kat] || kat;
|
||
html += `
|
||
<div style="margin-bottom:var(--space-5)">
|
||
<div style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary);
|
||
text-transform:uppercase;letter-spacing:0.05em;
|
||
padding:var(--space-2) 0;margin-bottom:var(--space-2);
|
||
border-bottom:1px solid var(--c-border)">
|
||
${UI.escape(label)}
|
||
</div>
|
||
`;
|
||
for (const a of items) {
|
||
html += `
|
||
<div class="adm-hilfe-row" data-id="${a.id}"
|
||
style="border:1px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);margin-bottom:var(--space-2)">
|
||
<!-- Zusammenfassung -->
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);
|
||
padding:var(--space-3) var(--space-4)">
|
||
<span style="flex:1;font-size:var(--text-sm);font-weight:500;
|
||
${a.aktiv ? '' : 'opacity:0.45;text-decoration:line-through'}">
|
||
${UI.escape(a.frage)}
|
||
</span>
|
||
<span style="font-size:var(--text-xs);color:var(--c-text-muted);
|
||
white-space:nowrap">
|
||
#${a.sort_order}
|
||
</span>
|
||
<button class="btn btn-sm adm-hilfe-edit-btn"
|
||
style="padding:2px 8px;font-size:var(--text-xs)"
|
||
data-id="${a.id}">
|
||
${UI.icon('pencil-simple')} Bearbeiten
|
||
</button>
|
||
<button class="btn btn-sm adm-hilfe-toggle-btn"
|
||
style="padding:2px 8px;font-size:var(--text-xs);
|
||
background:${a.aktiv ? 'var(--c-warning-bg,#fef3c7)' : 'var(--c-success-bg,#d1fae5)'};
|
||
color:${a.aktiv ? 'var(--c-warning,#92400e)' : 'var(--c-success,#065f46)'}"
|
||
data-id="${a.id}" data-aktiv="${a.aktiv}">
|
||
${a.aktiv ? UI.icon('eye-slash') + ' Ausblenden' : UI.icon('eye') + ' Einblenden'}
|
||
</button>
|
||
<button class="btn btn-sm adm-hilfe-del-btn"
|
||
style="padding:2px 8px;font-size:var(--text-xs);
|
||
background:var(--c-danger-bg,#fee2e2);color:var(--c-danger,#991b1b)"
|
||
data-id="${a.id}" data-frage="${UI.escape(a.frage)}">
|
||
${UI.icon('trash')}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Edit-Formular (versteckt) -->
|
||
<div class="adm-hilfe-edit-form" data-id="${a.id}"
|
||
style="display:none;padding:0 var(--space-4) var(--space-4);
|
||
border-top:1px solid var(--c-border)">
|
||
<div style="display:grid;gap:var(--space-3);padding-top:var(--space-3)">
|
||
<div>
|
||
<label style="font-size:var(--text-xs);font-weight:600;display:block;
|
||
margin-bottom:4px;color:var(--c-text-secondary)">Kategorie</label>
|
||
<select class="adm-hilfe-edit-kat"
|
||
style="width:100%;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
|
||
${Object.entries(KAT_LABEL).map(([k,v]) =>
|
||
`<option value="${k}" ${k === a.kategorie ? 'selected' : ''}>${UI.escape(v)}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style="font-size:var(--text-xs);font-weight:600;display:block;
|
||
margin-bottom:4px;color:var(--c-text-secondary)">Frage</label>
|
||
<input type="text" class="adm-hilfe-edit-frage"
|
||
value="${UI.escape(a.frage)}"
|
||
style="width:100%;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);
|
||
font-size:var(--text-sm);box-sizing:border-box">
|
||
</div>
|
||
<div>
|
||
<label style="font-size:var(--text-xs);font-weight:600;display:block;
|
||
margin-bottom:4px;color:var(--c-text-secondary)">Antwort</label>
|
||
<textarea class="adm-hilfe-edit-antwort" rows="5"
|
||
style="width:100%;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);
|
||
font-size:var(--text-sm);box-sizing:border-box;
|
||
resize:vertical;font-family:inherit">${UI.escape(a.antwort)}</textarea>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||
<label style="font-size:var(--text-xs);font-weight:600;
|
||
color:var(--c-text-secondary)">Reihenfolge</label>
|
||
<input type="number" class="adm-hilfe-edit-sort"
|
||
value="${a.sort_order}" min="0"
|
||
style="width:70px;padding:var(--space-2) var(--space-3);
|
||
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||
background:var(--c-surface);color:var(--c-text);font-size:var(--text-sm)">
|
||
<div class="flex-1"></div>
|
||
<button class="btn btn-secondary btn-sm adm-hilfe-edit-cancel text-xs" data-id="${a.id}"
|
||
>Abbrechen</button>
|
||
<button class="btn btn-primary btn-sm adm-hilfe-edit-save text-xs" data-id="${a.id}"
|
||
>Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
listEl.innerHTML = html;
|
||
_bindListEvents(listEl);
|
||
} catch (e) {
|
||
listEl.innerHTML = _emptyState('warning', 'Fehler beim Laden', e.message || '');
|
||
}
|
||
}
|
||
|
||
function _bindListEvents(listEl) {
|
||
// Edit-Button: Inline-Formular auf/zu klappen
|
||
listEl.querySelectorAll('.adm-hilfe-edit-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const id = btn.dataset.id;
|
||
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
|
||
if (form) form.style.display = form.style.display === 'none' ? '' : 'none';
|
||
});
|
||
});
|
||
|
||
// Edit-Cancel
|
||
listEl.querySelectorAll('.adm-hilfe-edit-cancel').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const id = btn.dataset.id;
|
||
const form = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
|
||
if (form) form.style.display = 'none';
|
||
});
|
||
});
|
||
|
||
// Edit-Save
|
||
listEl.querySelectorAll('.adm-hilfe-edit-save').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.id;
|
||
const row = listEl.querySelector(`.adm-hilfe-edit-form[data-id="${id}"]`);
|
||
const payload = {
|
||
kategorie: row.querySelector('.adm-hilfe-edit-kat').value,
|
||
frage: row.querySelector('.adm-hilfe-edit-frage').value.trim(),
|
||
antwort: row.querySelector('.adm-hilfe-edit-antwort').value.trim(),
|
||
sort_order: parseInt(row.querySelector('.adm-hilfe-edit-sort').value, 10) || 0,
|
||
};
|
||
if (!payload.frage || !payload.antwort) {
|
||
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
|
||
return;
|
||
}
|
||
try {
|
||
await API.patch(`/help/${id}`, payload);
|
||
UI.toast.success('Artikel gespeichert.');
|
||
_load();
|
||
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
|
||
});
|
||
});
|
||
|
||
// Toggle aktiv/inaktiv
|
||
listEl.querySelectorAll('.adm-hilfe-toggle-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.id;
|
||
const aktiv = parseInt(btn.dataset.aktiv, 10);
|
||
try {
|
||
await API.patch(`/help/${id}`, { aktiv: aktiv ? 0 : 1 });
|
||
UI.toast.success(aktiv ? 'Artikel ausgeblendet.' : 'Artikel eingeblendet.');
|
||
_load();
|
||
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
|
||
});
|
||
});
|
||
|
||
// Delete
|
||
listEl.querySelectorAll('.adm-hilfe-del-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.id;
|
||
const frage = btn.dataset.frage;
|
||
if (!window.confirm(`Artikel wirklich löschen?\n\n"${frage}"`)) return;
|
||
try {
|
||
await API.del(`/help/${id}`);
|
||
UI.toast.success('Artikel gelöscht.');
|
||
_load();
|
||
} catch (e) { UI.toast.error(e.message || 'Fehler beim Löschen.'); }
|
||
});
|
||
});
|
||
}
|
||
|
||
// Neuer-Artikel-Button
|
||
el.querySelector('#adm-hilfe-neu').addEventListener('click', () => {
|
||
const form = el.querySelector('#adm-hilfe-form');
|
||
form.style.display = form.style.display === 'none' ? '' : 'none';
|
||
});
|
||
|
||
// Formular abbrechen
|
||
el.querySelector('#adm-hilfe-form-cancel').addEventListener('click', () => {
|
||
el.querySelector('#adm-hilfe-form').style.display = 'none';
|
||
});
|
||
|
||
// Formular speichern
|
||
el.querySelector('#adm-hilfe-form-save').addEventListener('click', async () => {
|
||
const kat = el.querySelector('#adm-hilfe-kat').value;
|
||
const frage = el.querySelector('#adm-hilfe-frage').value.trim();
|
||
const antwort= el.querySelector('#adm-hilfe-antwort').value.trim();
|
||
const sort = parseInt(el.querySelector('#adm-hilfe-sort').value, 10) || 0;
|
||
if (!frage || !antwort) {
|
||
UI.toast.error('Frage und Antwort sind Pflichtfelder.');
|
||
return;
|
||
}
|
||
try {
|
||
await API.post('/help', { kategorie: kat, frage, antwort, sort_order: sort });
|
||
UI.toast.success('Artikel angelegt.');
|
||
el.querySelector('#adm-hilfe-form').style.display = 'none';
|
||
el.querySelector('#adm-hilfe-frage').value = '';
|
||
el.querySelector('#adm-hilfe-antwort').value = '';
|
||
el.querySelector('#adm-hilfe-sort').value = '0';
|
||
_load();
|
||
} catch (e) { UI.toast.error(e.message || 'Fehler beim Anlegen.'); }
|
||
});
|
||
|
||
await _load();
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
async function _renderUebungenAdmin(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade Übungen…</div>`;
|
||
|
||
let byTab;
|
||
try {
|
||
byTab = await API.get('/training/exercises');
|
||
} catch (e) {
|
||
el.innerHTML = `<div style="padding:var(--space-6);color:var(--c-danger)">Fehler: ${e.message}</div>`;
|
||
return;
|
||
}
|
||
|
||
// Flatten to sorted list grouped by kategorie
|
||
const allExercises = [];
|
||
for (const [kat, list] of Object.entries(byTab)) {
|
||
for (const ex of list) allExercises.push({ ...ex, _kat: kat });
|
||
}
|
||
allExercises.sort((a, b) => a._kat.localeCompare(b._kat) || a.name.localeCompare(b.name));
|
||
|
||
// Group by kategorie
|
||
const grouped = {};
|
||
for (const ex of allExercises) {
|
||
grouped[ex._kat] = grouped[ex._kat] || [];
|
||
grouped[ex._kat].push(ex);
|
||
}
|
||
|
||
const KAT_LABELS = {
|
||
'grundkommandos': 'Grundkommandos', 'tricks': 'Tricks',
|
||
'problemverhalten': 'Problemverhalten', 'mentale-auslastung': 'Mentale Auslastung',
|
||
'koerperpflege': 'Körperpflege', 'hundesport': 'Hundesport', 'welpe-basics': 'Welpe Basics',
|
||
};
|
||
|
||
let html = `<div class="p-4">
|
||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4)">
|
||
Trainingsübungen bearbeiten
|
||
</h2>`;
|
||
|
||
for (const [kat, list] of Object.entries(grouped)) {
|
||
html += `<div style="margin-bottom:var(--space-6)">
|
||
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
|
||
letter-spacing:.06em;color:var(--c-text-secondary);
|
||
padding:var(--space-2) 0 var(--space-2);
|
||
border-bottom:1px solid var(--c-border);margin-bottom:var(--space-2)">
|
||
${KAT_LABELS[kat] || kat} (${list.length})
|
||
</div>`;
|
||
for (const ex of list) {
|
||
const schritte = Array.isArray(ex.schritte) ? ex.schritte.join('\n') : '';
|
||
const exId = ex.exercise_id;
|
||
html += `<div class="adm-ueb-row" data-ex-id="${exId}"
|
||
style="padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
|
||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||
<span style="flex:1;font-size:var(--text-sm);font-weight:500">${ex.name}</span>
|
||
<button class="adm-ueb-edit-btn" data-ex-id="${exId}"
|
||
style="font-size:var(--text-xs);padding:2px 10px;border-radius:6px;
|
||
border:1px solid var(--c-border);background:var(--c-surface);
|
||
cursor:pointer;color:var(--c-text-secondary)">Bearbeiten</button>
|
||
</div>
|
||
<div class="adm-ueb-form" data-ex-id="${exId}"
|
||
style="display:none;margin-top:var(--space-3);
|
||
background:var(--c-surface-2);border-radius:8px;padding:var(--space-3)">
|
||
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
|
||
Beschreibung
|
||
</label>
|
||
<textarea class="adm-ueb-beschreibung" rows="2"
|
||
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
|
||
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
|
||
border:1px solid var(--c-border);background:var(--c-bg);
|
||
color:var(--c-text);resize:vertical">${(ex.beschreibung || '').replace(/</g, '<')}</textarea>
|
||
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
|
||
Schritte (eine Zeile = ein Schritt)
|
||
</label>
|
||
<textarea class="adm-ueb-schritte" rows="6"
|
||
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-2);
|
||
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
|
||
border:1px solid var(--c-border);background:var(--c-bg);
|
||
color:var(--c-text);resize:vertical">${schritte.replace(/</g, '<')}</textarea>
|
||
<label style="font-size:var(--text-xs);font-weight:600;color:var(--c-text-secondary)">
|
||
Tipp
|
||
</label>
|
||
<input class="adm-ueb-tipp" type="text"
|
||
value="${(ex.tipp || '').replace(/"/g, '"')}"
|
||
style="width:100%;box-sizing:border-box;margin:4px 0 var(--space-3);
|
||
font-size:var(--text-sm);padding:6px 8px;border-radius:6px;
|
||
border:1px solid var(--c-border);background:var(--c-bg);color:var(--c-text)">
|
||
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
|
||
<button class="adm-ueb-cancel-btn" data-ex-id="${exId}"
|
||
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
|
||
border:1px solid var(--c-border);background:var(--c-surface);
|
||
cursor:pointer;color:var(--c-text-secondary)">Abbrechen</button>
|
||
<button class="adm-ueb-save-btn" data-ex-id="${exId}"
|
||
style="font-size:var(--text-xs);padding:4px 14px;border-radius:6px;
|
||
border:none;background:var(--c-primary);
|
||
cursor:pointer;color:#fff;font-weight:600">Speichern</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
html += `</div>`;
|
||
el.innerHTML = html;
|
||
|
||
// Edit toggle
|
||
el.querySelectorAll('.adm-ueb-edit-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`);
|
||
form.style.display = form.style.display === 'none' ? '' : 'none';
|
||
});
|
||
});
|
||
|
||
// Cancel
|
||
el.querySelectorAll('.adm-ueb-cancel-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
el.querySelector(`.adm-ueb-form[data-ex-id="${btn.dataset.exId}"]`).style.display = 'none';
|
||
});
|
||
});
|
||
|
||
// Save
|
||
el.querySelectorAll('.adm-ueb-save-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const id = btn.dataset.exId;
|
||
const form = el.querySelector(`.adm-ueb-form[data-ex-id="${id}"]`);
|
||
const beschreibung = form.querySelector('.adm-ueb-beschreibung').value.trim();
|
||
const schritte = form.querySelector('.adm-ueb-schritte').value
|
||
.split('\n').map(s => s.trim()).filter(Boolean);
|
||
const tipp = form.querySelector('.adm-ueb-tipp').value.trim();
|
||
btn.disabled = true;
|
||
btn.textContent = 'Speichert…';
|
||
try {
|
||
await API.put(`/training/exercises/${id}`, {
|
||
beschreibung,
|
||
schritte: JSON.stringify(schritte),
|
||
tipp,
|
||
});
|
||
UI.toast.success('Übung gespeichert.');
|
||
form.style.display = 'none';
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Fehler beim Speichern.');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Speichern';
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// ------------------------------------------------------------------
|
||
// TAB: REFERRALS
|
||
// ------------------------------------------------------------------
|
||
async function _renderReferrals(el) {
|
||
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade…</div>`;
|
||
let d;
|
||
try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger)">Fehler beim Laden.</div>`; return; }
|
||
|
||
const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0;
|
||
|
||
const topRows = d.top_referrers.map((r, i) => `
|
||
<tr>
|
||
<td style="padding:8px 10px;color:var(--c-text-muted);font-weight:600">${i + 1}</td>
|
||
<td style="padding:8px 10px;font-weight:600">${UI.escape(r.name)}</td>
|
||
<td style="padding:8px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${UI.escape(r.email)}</td>
|
||
<td style="padding:8px 10px;text-align:right">
|
||
<span style="font-size:var(--text-lg);font-weight:800;color:var(--c-primary)">${r.invited_count}</span>
|
||
</td>
|
||
</tr>`).join('');
|
||
|
||
const recentRows = d.recent_invites.slice(0, 50).map(r => `
|
||
<tr>
|
||
<td style="padding:6px 10px;font-weight:500">${UI.escape(r.name)}</td>
|
||
<td style="padding:6px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${UI.escape(r.referrer_name)}</td>
|
||
<td style="padding:6px 10px;color:var(--c-text-muted);font-size:var(--text-xs)">${(r.created_at || '').slice(0, 10)}</td>
|
||
</tr>`).join('');
|
||
|
||
el.innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-4)">
|
||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${d.total_referred}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Geworbene User</div>
|
||
</div>
|
||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success)">${pct}%</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Anteil geworbener User</div>
|
||
</div>
|
||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-warning)">${d.viral_factor}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Virality Factor</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-bottom:var(--space-4);overflow:hidden">
|
||
<div class="by-card-section-header">Top Werber</div>
|
||
<div style="overflow-x:auto">
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||
<th style="padding:8px 10px;text-align:left">#</th>
|
||
<th style="padding:8px 10px;text-align:left">Name</th>
|
||
<th style="padding:8px 10px;text-align:left">E-Mail</th>
|
||
<th style="padding:8px 10px;text-align:right">Eingeladen</th>
|
||
</tr></thead>
|
||
<tbody>${topRows || '<tr><td colspan="4" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Empfehlungen</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="overflow:hidden">
|
||
<div class="by-card-section-header">Zuletzt geworbene User</div>
|
||
<div style="overflow-x:auto">
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||
<th style="padding:6px 10px;text-align:left">User</th>
|
||
<th style="padding:6px 10px;text-align:left">Geworben von</th>
|
||
<th style="padding:6px 10px;text-align:left">Datum</th>
|
||
</tr></thead>
|
||
<tbody>${recentRows || '<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Daten</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: UPGRADES
|
||
// ------------------------------------------------------------------
|
||
async function _renderUpgrades(el) {
|
||
const rows = await API.get('/admin/upgrade-requests');
|
||
|
||
const tierBadge = t => {
|
||
const cfg = { pro: ['Pro', '#16a34a'], breeder: ['Züchter', '#C4843A'] };
|
||
const [label, color] = cfg[t] || [t, '#888'];
|
||
return `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
|
||
font-size:11px;font-weight:700;background:${color};color:#fff">${label}</span>`;
|
||
};
|
||
|
||
const pending = rows.filter(r => !r.fulfilled_at);
|
||
const done = rows.filter(r => r.fulfilled_at);
|
||
|
||
// Offene Anfragen als Cards (mobile-freundlich, Button immer sichtbar)
|
||
const _pendingCard = r => `
|
||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
|
||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap">
|
||
<div class="flex-1-min">
|
||
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(r.name)}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${UI.escape(r.email)}</div>
|
||
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
||
${tierBadge(r.tier)}
|
||
${r.discount_pct > 0 ? `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
|
||
font-size:11px;font-weight:700;background:#e67e22;color:#fff;margin-left:4px">
|
||
${r.discount_pct}% Rabatt</span>` : ''}
|
||
<span class="text-xs-muted">${r.created_at?.slice(0,10) || ''}</span>
|
||
</div>
|
||
${r.discount_reason === 'founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Gründer — kostenfrei</div>` : ''}
|
||
${r.discount_reason === 'referred_by_founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Von Gründer eingeladen</div>` : ''}
|
||
${r.discount_reason === 'referral' ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">${r.referral_count} Freunde geworben</div>` : ''}
|
||
${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary);
|
||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
background:var(--c-surface-raised,rgba(0,0,0,.04))">
|
||
${UI.escape(r.message)}
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)">
|
||
${r.existing_invoice_id ? `
|
||
<button class="btn adm-invoice-edit-btn"
|
||
data-invoice-id="${r.existing_invoice_id}"
|
||
title="Rechnung ${UI.escape(r.existing_invoice_number)} (${UI.escape(r.existing_invoice_status)}) bearbeiten"
|
||
style="background:#eab308;color:#1a1a1a;border:none;
|
||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
||
${UI.icon('receipt')} Rechnung bearbeiten
|
||
</button>` : `
|
||
<button class="btn adm-invoice-btn"
|
||
data-name="${UI.escape(r.name)}" data-email="${UI.escape(r.email)}"
|
||
data-tier="${r.tier}" data-address="${UI.escape(r.billing_address || '')}"
|
||
data-discount="${r.discount_pct || 0}"
|
||
data-discount-reason="${r.discount_reason || ''}"
|
||
data-referral-count="${r.referral_count || 0}"
|
||
style="background:#e67e22;color:#fff;border:none;
|
||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
||
${UI.icon('receipt')} Rechnung erstellen
|
||
</button>`}
|
||
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${UI.escape(r.name)}" data-tier="${r.tier}"
|
||
style="background:#16a34a;color:#fff;border:none;
|
||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
||
✓ Freischalten
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Erledigte als kompakte Tabellenzeilen
|
||
const _doneRow = r => `
|
||
<tr>
|
||
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-sm)">${UI.escape(r.name)}<br>
|
||
<span class="text-xs-muted">${UI.escape(r.email)}</span></td>
|
||
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(r.tier)}</td>
|
||
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-success)">
|
||
✓ ${r.fulfilled_at?.slice(0,10) || ''}</td>
|
||
</tr>`;
|
||
|
||
el.innerHTML = `
|
||
<div class="mb-4">
|
||
<div class="by-card-section-header mb-3">
|
||
Offene Anfragen (${pending.length})
|
||
</div>
|
||
${pending.length
|
||
? pending.map(_pendingCard).join('')
|
||
: `<div class="card" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted);font-size:var(--text-sm)">
|
||
Keine offenen Anfragen
|
||
</div>`}
|
||
</div>
|
||
${done.length ? `
|
||
<div class="card adm-table-card">
|
||
<div class="by-card-section-header">Erledigt (${done.length})</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table" style="width:100%;border-collapse:collapse">
|
||
<thead><tr>
|
||
${['Nutzer','Tarif','Freigegeben'].map(h =>
|
||
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
|
||
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
|
||
).join('')}
|
||
</tr></thead>
|
||
<tbody>${done.map(_doneRow).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>` : ''}`;
|
||
|
||
el.querySelectorAll('.adm-fulfill-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const { id, name, tier } = btn.dataset;
|
||
const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier;
|
||
const ok = await UI.modal.confirm({
|
||
title: `${name} auf ${tierLabel} freischalten?`,
|
||
message: `Der Account wird auf ${tierLabel} gesetzt und eine Bestätigungsmail gesendet.\n\nFalls noch keine Rechnung gesendet wurde, wird ein Entwurf automatisch angelegt.`,
|
||
confirmText: 'Freischalten',
|
||
danger: false,
|
||
});
|
||
if (!ok) return;
|
||
btn.disabled = true;
|
||
btn.textContent = '…';
|
||
try {
|
||
const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`);
|
||
if (res.invoice_number) {
|
||
UI.toast.success(
|
||
`${res.user} freigeschaltet · Entwurf ${res.invoice_number} unter Rechnungen versenden`,
|
||
6000
|
||
);
|
||
} else {
|
||
UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`);
|
||
}
|
||
_renderTab();
|
||
_renderActionItems();
|
||
} catch (e) {
|
||
UI.toast.error(e.message);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Freischalten';
|
||
}
|
||
});
|
||
});
|
||
|
||
// "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten
|
||
const TIER_ITEMS = {
|
||
pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 },
|
||
breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 },
|
||
};
|
||
const _year = new Date().getFullYear();
|
||
const _now = new Date();
|
||
const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1);
|
||
const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
|
||
const _period = `${_fmt(_now)} - ${_fmt(_end)}`;
|
||
|
||
function _discountNote(reason, count, pct, tierLabel) {
|
||
const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.';
|
||
if (reason === 'founder') return `Gründer-Sonderkonditionen: ${tierLabel} kostenfrei als Dankeschön für deine Unterstützung als Gründer! ${agb}`;
|
||
if (reason === 'referred_by_founder') return `Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied ist dein Jahresabo dauerhaft kostenfrei. ${agb}`;
|
||
if (reason === 'referral') return `Herzlichen Dank für deine Unterstützung! Für ${count} geworbene Freunde erhältst du ${pct}% Rabatt. ${agb}`;
|
||
return agb;
|
||
}
|
||
|
||
el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const { name, email, tier, address } = btn.dataset;
|
||
const discountPct = Number(btn.dataset.discount) || 0;
|
||
const discountReason = btn.dataset.discountReason || '';
|
||
const referralCount = Number(btn.dataset.referralCount) || 0;
|
||
const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
|
||
_openNeueRechnungModal(() => {
|
||
_tab = 'rechnungen';
|
||
_renderTab();
|
||
}, {
|
||
recipient_name: name,
|
||
recipient_email: email,
|
||
recipient_address: address || '',
|
||
service_period: _period,
|
||
discount_pct: discountPct,
|
||
notes: _discountNote(discountReason, referralCount, discountPct, tierItem.description),
|
||
items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
|
||
});
|
||
});
|
||
});
|
||
|
||
// "Rechnung bearbeiten" — lädt existierenden Entwurf/Sent-Rechnung im Edit-Modus
|
||
el.querySelectorAll('.adm-invoice-edit-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
try {
|
||
const inv = await API.get(`/admin/invoices/${btn.dataset.invoiceId}`);
|
||
_openNeueRechnungModal(() => {
|
||
// Nach Speichern/Stornieren: zurück auf Upgrades-Tab, damit der Button neu rendert
|
||
_renderTab();
|
||
}, {
|
||
recipient_name: inv.recipient_name,
|
||
recipient_email: inv.recipient_email,
|
||
recipient_address: inv.recipient_address || '',
|
||
service_period: inv.service_period || '',
|
||
discount_pct: inv.discount_pct || 0,
|
||
notes: inv.notes || '',
|
||
items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })),
|
||
}, inv.id, inv.status, inv.invoice_number);
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Rechnung konnte nicht geladen werden.');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// TAB: RECHNUNGEN
|
||
// ------------------------------------------------------------------
|
||
async function _renderRechnungen(el) {
|
||
let _subView = 'liste'; // 'liste' | 'cashflow'
|
||
|
||
async function _load() {
|
||
el.innerHTML = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap;gap:var(--space-2)">
|
||
<div class="flex-gap-2">
|
||
<button class="btn btn-sm ${_subView === 'liste' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="liste">
|
||
${UI.icon('list-bullets')} Rechnungen
|
||
</button>
|
||
<button class="btn btn-sm ${_subView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} adm-inv-nav" data-v="cashflow">
|
||
${UI.icon('chart-bar')} Cashflow
|
||
</button>
|
||
</div>
|
||
${_subView === 'liste' ? `
|
||
<button class="btn btn-sm btn-secondary" id="adm-inv-new">
|
||
${UI.icon('plus')} Neue Rechnung
|
||
</button>` : ''}
|
||
</div>
|
||
<div id="adm-inv-content">
|
||
<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">Lade…</div>
|
||
</div>
|
||
`;
|
||
|
||
el.querySelectorAll('.adm-inv-nav').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
_subView = btn.dataset.v;
|
||
_load();
|
||
});
|
||
});
|
||
|
||
el.querySelector('#adm-inv-new')?.addEventListener('click', () => _openNeueRechnungModal(_load));
|
||
|
||
const content = el.querySelector('#adm-inv-content');
|
||
if (_subView === 'liste') {
|
||
await _loadInvoiceList(content, _load);
|
||
} else {
|
||
await _loadCashflow(content);
|
||
}
|
||
}
|
||
|
||
await _load();
|
||
}
|
||
|
||
async function _loadInvoiceList(el, reload) {
|
||
let invoices;
|
||
try {
|
||
invoices = await API.get('/admin/invoices');
|
||
} catch (e) {
|
||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Rechnungen konnten nicht geladen werden.');
|
||
return;
|
||
}
|
||
|
||
if (!invoices.length) {
|
||
el.innerHTML = _emptyState('receipt', 'Keine Rechnungen', 'Noch keine Rechnungen erstellt.');
|
||
return;
|
||
}
|
||
|
||
const _statusBadge = status => {
|
||
const cfg = {
|
||
draft: ['Entwurf', 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'],
|
||
sent: ['Versendet', 'var(--c-primary)', 'var(--c-primary-subtle,#eff6ff)','var(--c-primary)'],
|
||
paid: ['Bezahlt', 'var(--c-success,#16a34a)','#d1fae5', 'var(--c-success,#16a34a)'],
|
||
cancelled: ['Storniert', 'var(--c-danger,#dc2626)', '#fee2e2', 'var(--c-danger,#dc2626)'],
|
||
};
|
||
const [label, color, bg, border] = cfg[status] || [status, 'var(--c-text-muted)', 'var(--c-surface-2)', 'var(--c-border)'];
|
||
return `<span style="display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:700;
|
||
background:${bg};color:${color};border:1px solid ${border}">${label}</span>`;
|
||
};
|
||
|
||
const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—';
|
||
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
|
||
|
||
const rows = invoices.map((inv, i) => {
|
||
const actions = [];
|
||
if (inv.status === 'draft') {
|
||
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-edit" data-id="${inv.id}" title="Bearbeiten">
|
||
${UI.icon('pencil')} Bearbeiten
|
||
</button>`);
|
||
actions.push(`<button class="btn btn-sm btn-primary adm-inv-send" data-id="${inv.id}" data-num="${UI.escape(inv.invoice_number)}" title="Senden">
|
||
${UI.icon('paper-plane-tilt')} Senden
|
||
</button>`);
|
||
}
|
||
if (inv.status === 'sent') {
|
||
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-send text-muted" data-id="${inv.id}" data-num="${UI.escape(inv.invoice_number)}" title="Erneut senden"
|
||
>
|
||
${UI.icon('paper-plane-tilt')} Erneut senden
|
||
</button>`);
|
||
}
|
||
if (inv.status === 'sent') {
|
||
actions.push(`<button class="btn btn-sm btn-secondary adm-inv-pay" data-id="${inv.id}" data-amount="${inv.amount_gross}" title="Als bezahlt markieren">
|
||
${UI.icon('check-circle')} Bezahlt
|
||
</button>`);
|
||
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-cancel text-danger" data-id="${inv.id}" data-num="${UI.escape(inv.invoice_number)}"
|
||
title="Stornieren">
|
||
${UI.icon('x-circle')} Storno
|
||
</button>`);
|
||
}
|
||
if (inv.status === 'paid' || inv.status === 'cancelled') {
|
||
actions.push(`<button class="btn btn-sm btn-ghost adm-inv-detail" data-id="${inv.id}" title="Details">
|
||
${UI.icon('eye')} Details
|
||
</button>`);
|
||
}
|
||
|
||
return `
|
||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||
<td class="adm-td" style="font-weight:600;font-family:monospace;font-size:var(--text-xs)">
|
||
${UI.escape(inv.invoice_number)}
|
||
</td>
|
||
<td class="adm-td">
|
||
<div style="font-weight:500">${UI.escape(inv.recipient_name)}</div>
|
||
<div class="text-xs-muted">${UI.escape(inv.recipient_email || '')}</div>
|
||
</td>
|
||
<td class="adm-td" style="text-align:right;font-weight:700;white-space:nowrap">
|
||
${_fmtEur(inv.amount_gross)}
|
||
${inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01
|
||
? `<div style="font-size:10px;color:var(--c-warning,#d97706);font-weight:500">
|
||
erhalten: ${_fmtEur(inv.paid_amount)}
|
||
${inv.paid_amount < inv.amount_gross
|
||
? `<span class="text-danger">-${_fmtEur(inv.amount_gross - inv.paid_amount)}</span>`
|
||
: ''}
|
||
</div>`
|
||
: ''}
|
||
</td>
|
||
<td class="adm-td">${_statusBadge(inv.status)}</td>
|
||
<td class="adm-td" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap">
|
||
${_fmtDate(inv.created_at)}
|
||
</td>
|
||
<td class="adm-td" style="white-space:nowrap">
|
||
<div style="display:flex;gap:var(--space-1);justify-content:flex-end">${actions.join('')}</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
el.innerHTML = `
|
||
<div class="card adm-table-card">
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead>
|
||
<tr style="background:var(--c-surface-2);text-align:left">
|
||
<th class="adm-th">Nummer</th>
|
||
<th class="adm-th">Empfänger</th>
|
||
<th class="adm-th text-right">Betrag</th>
|
||
<th class="adm-th">Status</th>
|
||
<th class="adm-th">Erstellt</th>
|
||
<th class="adm-th"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Senden
|
||
el.querySelectorAll('.adm-inv-send').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const ok = await UI.modal.confirm({
|
||
title: `Rechnung ${btn.dataset.num} versenden?`,
|
||
message: 'Die Rechnung wird als PDF erzeugt und per E-Mail an den Empfänger versendet.',
|
||
confirmText: 'Jetzt versenden',
|
||
});
|
||
if (!ok) return;
|
||
btn.disabled = true;
|
||
try {
|
||
await API.post(`/admin/invoices/${btn.dataset.id}/send`, {});
|
||
UI.toast.success('Rechnung versendet.');
|
||
reload();
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Fehler beim Versenden.');
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Entwurf bearbeiten
|
||
el.querySelectorAll('.adm-inv-edit').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const inv = await API.get(`/admin/invoices/${btn.dataset.id}`);
|
||
_openNeueRechnungModal(reload, {
|
||
recipient_name: inv.recipient_name,
|
||
recipient_email: inv.recipient_email,
|
||
recipient_address: inv.recipient_address || '',
|
||
service_period: inv.service_period || '',
|
||
discount_pct: inv.discount_pct || 0,
|
||
notes: inv.notes || '',
|
||
items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })),
|
||
}, inv.id, inv.status, inv.invoice_number);
|
||
});
|
||
});
|
||
|
||
// Als bezahlt markieren
|
||
el.querySelectorAll('.adm-inv-pay').forEach(btn => {
|
||
btn.addEventListener('click', () => _openBezahltModal(btn.dataset.id, Number(btn.dataset.amount), reload));
|
||
});
|
||
|
||
// Stornieren
|
||
el.querySelectorAll('.adm-inv-cancel').forEach(btn => {
|
||
btn.addEventListener('click', () => _openStornoModal(btn.dataset.id, btn.dataset.num, reload));
|
||
});
|
||
|
||
// Details
|
||
el.querySelectorAll('.adm-inv-detail').forEach(btn => {
|
||
btn.addEventListener('click', () => _openDetailModal(btn.dataset.id));
|
||
});
|
||
}
|
||
|
||
function _openNeueRechnungModal(reload, prefill = null, invoiceId = null, status = null, invoiceNumber = null) {
|
||
const id = `inv-new-${Date.now()}`;
|
||
const p = prefill || {};
|
||
const isEdit = !!invoiceId;
|
||
const isLocked = isEdit && status && status !== 'draft'; // sent / paid / cancelled → nicht änderbar
|
||
const canCancel = isEdit && status !== 'cancelled'; // alles außer schon storniert
|
||
const lockedBanner = isLocked ? (() => {
|
||
const map = {
|
||
sent: ['#fff8f0', '#f0a060', '#c05000', 'Diese Rechnung wurde bereits versendet — Inhalt nicht mehr änderbar. Korrekturen nur per Stornierung.'],
|
||
paid: ['#f0fdf4', '#86efac', '#15803d', 'Diese Rechnung ist bezahlt — Inhalt nicht änderbar.'],
|
||
cancelled: ['#fef2f2', '#fca5a5', '#b91c1c', 'Diese Rechnung wurde storniert — Inhalt nicht änderbar.'],
|
||
};
|
||
const [bg, br, fg, msg] = map[status] || ['#f5f5f5', '#ccc', '#444', `Status: ${status}`];
|
||
return `<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
background:${bg};border:1px solid ${br};
|
||
font-size:var(--text-xs);color:${fg};line-height:1.6">${msg}</div>`;
|
||
})() : '';
|
||
|
||
UI.modal.open({
|
||
title: `${UI.icon('receipt')} ${isEdit ? (isLocked ? 'Rechnung ansehen' : 'Rechnung bearbeiten') : 'Neue Rechnung erstellen'}`,
|
||
body: `
|
||
<form id="${id}" class="flex-col-gap-3">
|
||
|
||
${lockedBanner}
|
||
|
||
${!isEdit && !p.recipient_name ? `
|
||
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||
background:#fff8f0;border:1px solid #f0a060;
|
||
font-size:var(--text-xs);color:#c05000;line-height:1.6">
|
||
Diese Rechnung ist für <strong>sonstige Leistungen</strong> (Beratung, Einmalleistung etc.).<br>
|
||
Für Abo-Verlängerungen bitte den Button <strong>„Rechnung erstellen"</strong> in der Upgrades-Liste verwenden.
|
||
</div>` : ''}
|
||
|
||
<!-- Empfänger -->
|
||
<div class="grid-2">
|
||
<div>
|
||
<label class="form-label text-xs">Empfänger Name *</label>
|
||
<input class="form-control" name="recipient_name" type="text" required
|
||
placeholder="Max Muster" value="${UI.escape(p.recipient_name || '')}">
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">E-Mail</label>
|
||
<input class="form-control" name="recipient_email" type="email"
|
||
placeholder="max@example.com" value="${UI.escape(p.recipient_email || '')}">
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="form-label text-xs">Adresse
|
||
${p.recipient_name && !p.recipient_address
|
||
? `<span style="color:var(--c-warning);font-size:10px"> ⚠ Nutzer hat keine Rechnungsadresse hinterlegt</span>`
|
||
: '<span class="text-muted">(optional)</span>'}
|
||
</label>
|
||
<textarea class="form-control" name="recipient_address" rows="2"
|
||
placeholder="Musterstr. 1 12345 Berlin"
|
||
style="resize:vertical;font-family:inherit">${UI.escape(p.recipient_address || '')}</textarea>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="form-label text-xs">Leistungszeitraum <span class="text-muted">(optional)</span></label>
|
||
<input class="form-control" name="service_period" type="text"
|
||
placeholder="z.B. 15.05.2026 oder einmalige Leistung"
|
||
value="${UI.escape(p.service_period || '')}">
|
||
</div>
|
||
|
||
<!-- Positionen -->
|
||
<div>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
|
||
<label class="form-label" style="font-size:var(--text-xs);margin:0">Positionen *</label>
|
||
<button type="button" id="${id}-add-item"
|
||
style="font-size:var(--text-xs);color:var(--c-primary);background:none;border:none;cursor:pointer;padding:0;font-weight:600">
|
||
+ Position hinzufügen
|
||
</button>
|
||
</div>
|
||
<div id="${id}-items" class="flex-col-gap-2">
|
||
<!-- Items werden dynamisch eingefügt -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rabatt -->
|
||
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
|
||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
|
||
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="${p.discount_pct ?? 0}"
|
||
style="width:80px" id="${id}-discount">
|
||
</div>
|
||
<!-- Live-Vorschau -->
|
||
<div id="${id}-preview" style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||
padding:var(--space-2) var(--space-3);font-size:var(--text-xs);text-align:right">
|
||
<span class="text-muted">Netto: —</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="form-label text-xs">Notizen <span class="text-muted">(optional)</span></label>
|
||
<textarea class="form-control" name="notes" rows="2"
|
||
style="resize:vertical;font-family:inherit"
|
||
placeholder="Interne Notiz / Zahlungshinweis">${UI.escape(p.notes || (!isEdit && !p.recipient_name ? 'Zahlbar innerhalb von 14 Tagen ab Rechnungsdatum.' : ''))}</textarea>
|
||
</div>
|
||
|
||
</form>
|
||
`,
|
||
footer: `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||
${isLocked
|
||
? `<button class="btn btn-secondary w-full" data-modal-close>Schließen</button>`
|
||
: `<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
|
||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||
<button class="btn btn-primary" form="${id}" type="submit" style="min-width:0">
|
||
${UI.icon('receipt')} ${isEdit ? 'Speichern' : 'Erstellen'}
|
||
</button>
|
||
</div>`}
|
||
${canCancel ? `
|
||
<button class="btn btn-ghost" id="${id}-cancel-invoice"
|
||
style="width:100%;color:var(--c-danger);border:1px solid var(--c-danger)">
|
||
${UI.icon('x-circle')} Rechnung stornieren
|
||
</button>` : ''}
|
||
</div>
|
||
`,
|
||
});
|
||
|
||
// Bei gesperrten Rechnungen (sent/paid/cancelled) alle Eingaben & Action-Buttons readonly
|
||
if (isLocked) {
|
||
setTimeout(() => {
|
||
const form = document.getElementById(id);
|
||
if (!form) return;
|
||
form.querySelectorAll('input, textarea').forEach(inp => { inp.disabled = true; });
|
||
// "+ Position hinzufügen" und Item-Lösch-Buttons verstecken
|
||
const addBtn = document.getElementById(`${id}-add-item`);
|
||
if (addBtn) addBtn.style.display = 'none';
|
||
form.querySelectorAll('.inv-item-remove').forEach(b => { b.style.display = 'none'; });
|
||
}, 0);
|
||
}
|
||
|
||
// Stornieren — schließt dieses Modal, öffnet den Storno-Dialog
|
||
document.getElementById(`${id}-cancel-invoice`)?.addEventListener('click', () => {
|
||
// _openStornoModal ruft intern UI.modal.open() → schließt dieses Modal automatisch
|
||
_openStornoModal(invoiceId, invoiceNumber || `#${invoiceId}`, reload);
|
||
});
|
||
|
||
// Items-Container und Hilfsfunktionen
|
||
const itemsContainer = document.getElementById(`${id}-items`);
|
||
const previewEl = document.getElementById(`${id}-preview`);
|
||
const discountEl = document.getElementById(`${id}-discount`);
|
||
|
||
function _addItem(desc = '', qty = 1, price = 0) {
|
||
const itemEl = document.createElement('div');
|
||
itemEl.className = 'adm-inv-item-row';
|
||
itemEl.style.cssText = 'display:grid;grid-template-columns:1fr 60px 100px auto;gap:var(--space-2);align-items:center';
|
||
itemEl.innerHTML = `
|
||
<input class="form-control inv-item-desc text-sm" type="text" placeholder="Beschreibung *"
|
||
value="${UI.escape(desc)}">
|
||
<input class="form-control inv-item-qty" type="number" min="1" value="${qty}"
|
||
style="font-size:var(--text-sm);text-align:right" title="Menge">
|
||
<input class="form-control inv-item-price" type="number" min="0" step="0.01" value="${price.toFixed(2)}"
|
||
style="font-size:var(--text-sm);text-align:right" title="Einzelpreis €">
|
||
<button type="button" class="btn btn-sm btn-ghost inv-item-remove"
|
||
style="color:var(--c-danger);padding:4px 8px;flex-shrink:0" title="Entfernen">
|
||
${UI.icon('x')}
|
||
</button>
|
||
`;
|
||
itemEl.querySelector('.inv-item-remove').addEventListener('click', () => {
|
||
if (itemsContainer.querySelectorAll('.adm-inv-item-row').length > 1) {
|
||
itemEl.remove();
|
||
_updatePreview();
|
||
}
|
||
});
|
||
itemEl.querySelectorAll('input').forEach(inp => inp.addEventListener('input', _updatePreview));
|
||
itemsContainer.appendChild(itemEl);
|
||
_updatePreview();
|
||
}
|
||
|
||
function _updatePreview() {
|
||
let netto = 0;
|
||
itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => {
|
||
const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 0;
|
||
const price = parseFloat(row.querySelector('.inv-item-price').value) || 0;
|
||
netto += qty * price;
|
||
});
|
||
const disc = Math.min(100, Math.max(0, parseFloat(discountEl?.value) || 0));
|
||
const rabatt = netto * disc / 100;
|
||
const brutto = netto - rabatt;
|
||
previewEl.innerHTML = `
|
||
<span class="text-muted">Netto: </span>
|
||
<strong>${netto.toLocaleString('de-DE',{minimumFractionDigits:2})} €</strong>
|
||
${disc > 0 ? ` · <span class="text-danger">-${rabatt.toLocaleString('de-DE',{minimumFractionDigits:2})} € (${disc}%)</span>` : ''}
|
||
· <span style="color:var(--c-success);font-weight:700">Brutto: ${brutto.toLocaleString('de-DE',{minimumFractionDigits:2})} €</span>
|
||
`;
|
||
}
|
||
|
||
// Erste Position — aus Prefill oder Standard
|
||
if (p.items && p.items.length) {
|
||
p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0));
|
||
} else {
|
||
_addItem('Ban Yaro Pro Jahresabo', 1, 29.00);
|
||
}
|
||
|
||
// Weitere Position
|
||
document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem());
|
||
discountEl?.addEventListener('input', _updatePreview);
|
||
|
||
// Form Submit
|
||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
if (isLocked) return; // gesperrte Rechnung — Submit ignorieren (Button ist eh ausgeblendet)
|
||
const fd = new FormData(e.target);
|
||
const items = [];
|
||
itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => {
|
||
const desc = row.querySelector('.inv-item-desc').value.trim();
|
||
const qty = parseFloat(row.querySelector('.inv-item-qty').value) || 1;
|
||
const price = parseFloat(row.querySelector('.inv-item-price').value) || 0;
|
||
if (desc) items.push({ description: desc, quantity: qty, unit_price: price });
|
||
});
|
||
if (!items.length) { UI.toast.warning('Mindestens eine Position angeben.'); return; }
|
||
|
||
const submitBtn = e.target.closest('.modal-content, [id]')
|
||
? document.querySelector(`button[form="${id}"]`)
|
||
: null;
|
||
if (submitBtn) submitBtn.disabled = true;
|
||
|
||
try {
|
||
const payload = {
|
||
recipient_name: fd.get('recipient_name'),
|
||
recipient_email: fd.get('recipient_email') || null,
|
||
recipient_address: fd.get('recipient_address') || null,
|
||
service_period: fd.get('service_period') || null,
|
||
discount_pct: parseFloat(fd.get('discount_pct')) || 0,
|
||
notes: fd.get('notes') || null,
|
||
items,
|
||
};
|
||
if (isEdit) {
|
||
await API.patch(`/admin/invoices/${invoiceId}`, payload);
|
||
} else {
|
||
await API.post('/admin/invoices', payload);
|
||
}
|
||
UI.modal.close();
|
||
UI.toast.success(isEdit ? 'Rechnung gespeichert.' : 'Rechnung erstellt.');
|
||
reload();
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler beim Erstellen.');
|
||
if (submitBtn) submitBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function _openBezahltModal(invoiceId, defaultAmount, reload) {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const id = `inv-pay-${Date.now()}`;
|
||
|
||
UI.modal.open({
|
||
title: `${UI.icon('check-circle')} Als bezahlt markieren`,
|
||
body: `
|
||
<form id="${id}" class="flex-col-gap-3">
|
||
<div>
|
||
<label class="form-label text-xs">Zahlungsdatum *</label>
|
||
<input class="form-control" name="paid_at" type="date" value="${today}" required>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Eingegangener Betrag (€) *</label>
|
||
<input class="form-control" name="paid_amount" id="${id}-amt" type="number" min="0" step="0.01"
|
||
value="${defaultAmount.toFixed(2)}" required>
|
||
</div>
|
||
<div id="${id}-diff" style="display:none;padding:var(--space-2) var(--space-3);
|
||
border-radius:var(--radius-md);background:#fff8f0;border:1px solid #f0a060;
|
||
font-size:var(--text-xs);color:#c05000;line-height:1.6"></div>
|
||
</form>
|
||
`,
|
||
footer: `
|
||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('check-circle')} Als bezahlt markieren</button>
|
||
`,
|
||
});
|
||
|
||
// Differenz live anzeigen
|
||
const amtEl = document.getElementById(`${id}-amt`);
|
||
const diffEl = document.getElementById(`${id}-diff`);
|
||
const _checkDiff = () => {
|
||
const entered = parseFloat(amtEl?.value) || 0;
|
||
const diff = defaultAmount - entered;
|
||
if (Math.abs(diff) < 0.01) { diffEl.style.display = 'none'; return; }
|
||
diffEl.style.display = 'block';
|
||
if (diff > 0) {
|
||
diffEl.innerHTML = `Differenz: <strong>-${diff.toFixed(2)} €</strong> weniger als fakturiert.<br>
|
||
<label style="display:flex;align-items:center;gap:6px;margin-top:4px;cursor:pointer">
|
||
<input type="checkbox" id="${id}-kulanz">
|
||
<span>Als Kulanz/Forderungsverlust abschreiben (Notiz wird automatisch eingetragen)</span>
|
||
</label>`;
|
||
} else {
|
||
diffEl.innerHTML = `Überzahlung: <strong>+${(-diff).toFixed(2)} €</strong> mehr eingegangen.`;
|
||
diffEl.style.background = '#f0fff8';
|
||
diffEl.style.borderColor = '#34d399';
|
||
diffEl.style.color = '#065f46';
|
||
}
|
||
};
|
||
amtEl?.addEventListener('input', _checkDiff);
|
||
|
||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const paidAmount = parseFloat(fd.get('paid_amount'));
|
||
const diff = defaultAmount - paidAmount;
|
||
const kulanz = diff > 0.01 && document.getElementById(`${id}-kulanz`)?.checked;
|
||
|
||
const submitBtn = document.querySelector(`button[form="${id}"]`);
|
||
if (submitBtn) submitBtn.disabled = true;
|
||
try {
|
||
const kulanzNote = kulanz
|
||
? `Forderungsverlust/Kulanz: ${diff.toFixed(2)} EUR nicht eingegangen (${fd.get('paid_at')}). Als Kulanz abgeschrieben.`
|
||
: null;
|
||
await API.post(`/admin/invoices/${invoiceId}/pay`, {
|
||
paid_at: fd.get('paid_at'),
|
||
paid_amount: paidAmount,
|
||
...(kulanzNote ? { notes: kulanzNote } : {}),
|
||
});
|
||
UI.modal.close();
|
||
UI.toast.success(kulanz
|
||
? `Bezahlt (${paidAmount.toFixed(2)} €) · ${diff.toFixed(2)} € als Kulanz notiert.`
|
||
: 'Rechnung als bezahlt markiert.');
|
||
reload();
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler.');
|
||
if (submitBtn) submitBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function _openStornoModal(invoiceId, invoiceNum, reload) {
|
||
const id = `inv-cancel-${Date.now()}`;
|
||
|
||
UI.modal.open({
|
||
title: `${UI.icon('x-circle')} Rechnung stornieren`,
|
||
body: `
|
||
<form id="${id}" class="flex-col-gap-3">
|
||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0">
|
||
Rechnung <strong>${UI.escape(invoiceNum)}</strong> stornieren.
|
||
</p>
|
||
<div>
|
||
<label class="form-label text-xs">Stornierungsgrund *</label>
|
||
<input class="form-control" name="reason" type="text" required
|
||
placeholder="z. B. Kundenwunsch, Doppelabrechnung…">
|
||
</div>
|
||
</form>
|
||
`,
|
||
footer: `
|
||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||
<button class="btn btn-primary" form="${id}" type="submit"
|
||
style="background:var(--c-danger);border-color:var(--c-danger)">
|
||
${UI.icon('x-circle')} Rechnung stornieren
|
||
</button>
|
||
`,
|
||
});
|
||
|
||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const reason = (fd.get('reason') || '').trim();
|
||
if (!reason) { UI.toast.warning('Bitte einen Grund angeben.'); return; }
|
||
const submitBtn = document.querySelector(`button[form="${id}"]`);
|
||
if (submitBtn) submitBtn.disabled = true;
|
||
try {
|
||
await API.post(`/admin/invoices/${invoiceId}/cancel`, { reason });
|
||
UI.modal.close();
|
||
UI.toast.success('Rechnung storniert.');
|
||
reload();
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler.');
|
||
if (submitBtn) submitBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
async function _openDetailModal(invoiceId) {
|
||
let inv;
|
||
try {
|
||
inv = await API.get(`/admin/invoices/${invoiceId}`);
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Detail konnte nicht geladen werden.');
|
||
return;
|
||
}
|
||
|
||
const _fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '—';
|
||
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
|
||
|
||
const statusColors = {
|
||
draft: 'var(--c-text-muted)', sent: 'var(--c-primary)',
|
||
paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)',
|
||
};
|
||
const statusLabels = { draft: 'Entwurf', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' };
|
||
|
||
const itemsHtml = (inv.items || []).map(item => `
|
||
<tr>
|
||
<td style="padding:6px 8px">${UI.escape(item.description)}</td>
|
||
<td style="padding:6px 8px;text-align:right">${item.quantity}</td>
|
||
<td style="padding:6px 8px;text-align:right">${_fmtEur(item.unit_price)}</td>
|
||
<td style="padding:6px 8px;text-align:right;font-weight:600">${_fmtEur(item.total)}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
UI.modal.open({
|
||
title: `${UI.icon('receipt')} ${UI.escape(inv.invoice_number)}`,
|
||
body: `
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-3);font-size:var(--text-sm)">
|
||
<div class="grid-2">
|
||
<div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Empfänger</div>
|
||
<div style="font-weight:600">${UI.escape(inv.recipient_name)}</div>
|
||
${inv.recipient_email ? `<div style="color:var(--c-text-muted);font-size:var(--text-xs)">${UI.escape(inv.recipient_email)}</div>` : ''}
|
||
${inv.recipient_address ? `<div style="color:var(--c-text-secondary);font-size:var(--text-xs);white-space:pre-line;margin-top:2px">${UI.escape(inv.recipient_address)}</div>` : ''}
|
||
</div>
|
||
<div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Status</div>
|
||
<div style="font-weight:700;color:${statusColors[inv.status] || 'inherit'}">${statusLabels[inv.status] || inv.status}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
||
Erstellt: ${_fmtDate(inv.created_at)}<br>
|
||
${inv.sent_at ? `Versendet: ${_fmtDate(inv.sent_at)}<br>` : ''}
|
||
${inv.paid_at ? `Bezahlt: ${_fmtDate(inv.paid_at)} · ${_fmtEur(inv.paid_amount)}<br>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${inv.service_period ? `
|
||
<div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Leistungszeitraum</div>
|
||
<div>${UI.escape(inv.service_period)}</div>
|
||
</div>` : ''}
|
||
|
||
<!-- Positionen -->
|
||
<div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Positionen</div>
|
||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
|
||
<thead>
|
||
<tr style="border-bottom:1px solid var(--c-border);color:var(--c-text-muted)">
|
||
<th style="text-align:left;padding:4px 8px">Beschreibung</th>
|
||
<th style="text-align:right;padding:4px 8px">Menge</th>
|
||
<th style="text-align:right;padding:4px 8px">Preis</th>
|
||
<th style="text-align:right;padding:4px 8px">Gesamt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${itemsHtml}</tbody>
|
||
<tfoot>
|
||
<tr style="border-top:2px solid var(--c-border)">
|
||
<td colspan="3" style="padding:6px 8px;text-align:right;font-weight:600">Gesamt (brutto)</td>
|
||
<td style="padding:6px 8px;text-align:right;font-weight:700;color:var(--c-primary)">${_fmtEur(inv.amount_gross)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
${inv.notes ? `
|
||
<div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Notizen</div>
|
||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
|
||
font-size:var(--text-xs);white-space:pre-wrap">${UI.escape(inv.notes)}</div>
|
||
</div>` : ''}
|
||
</div>
|
||
`,
|
||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||
});
|
||
}
|
||
|
||
async function _loadCashflow(el) {
|
||
let cf;
|
||
try {
|
||
cf = await API.get('/admin/invoices/cashflow');
|
||
} catch (e) {
|
||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Cashflow konnte nicht geladen werden.');
|
||
return;
|
||
}
|
||
|
||
const _fmtEur = v => v != null ? Number(v).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €' : '—';
|
||
|
||
const statusLabels = { draft: 'Entwürfe', sent: 'Versendet', paid: 'Bezahlt', cancelled: 'Storniert' };
|
||
const statusColors = { draft: 'var(--c-text-muted)', sent: 'var(--c-primary)', paid: 'var(--c-success,#16a34a)', cancelled: 'var(--c-danger,#dc2626)' };
|
||
|
||
const countKacheln = Object.entries(cf.counts || {}).map(([s, n]) => `
|
||
<div class="card" style="padding:var(--space-3);text-align:center">
|
||
<div style="font-size:var(--text-xl);font-weight:800;color:${statusColors[s] || 'var(--c-text)'}">${n}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">${statusLabels[s] || s}</div>
|
||
</div>`).join('');
|
||
|
||
const monthRows = (cf.monthly || []).map((m, i) => `
|
||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||
<td class="adm-td">${UI.escape(m.month)}</td>
|
||
<td class="adm-td text-right">${m.count}</td>
|
||
<td class="adm-td" style="text-align:right;font-weight:600">${_fmtEur(m.revenue)}</td>
|
||
</tr>`).join('');
|
||
|
||
// Quartalsbericht-Download
|
||
const currentYear = new Date().getFullYear();
|
||
const years = [currentYear, currentYear - 1].map(y => `<option value="${y}">${y}</option>`).join('');
|
||
|
||
el.innerHTML = `
|
||
<!-- Übersichtskacheln -->
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:var(--space-3);margin-bottom:var(--space-4)">
|
||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success,#16a34a)">${_fmtEur(cf.total_paid)}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Einnahmen (bezahlt)</div>
|
||
</div>
|
||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${_fmtEur(cf.total_outstanding)}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Offene Forderungen</div>
|
||
</div>
|
||
<div class="card" style="padding:var(--space-4);text-align:center">
|
||
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-text)">${_fmtEur(cf.total_year)}</div>
|
||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Jahresumsatz gesamt</div>
|
||
</div>
|
||
${countKacheln}
|
||
</div>
|
||
|
||
<!-- Monatliche Tabelle -->
|
||
<div class="card adm-table-card mb-4">
|
||
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:700;
|
||
text-transform:uppercase;letter-spacing:.05em;color:var(--c-text-secondary);
|
||
border-bottom:1px solid var(--c-border)">Monatliche Übersicht</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead>
|
||
<tr style="background:var(--c-surface-2);text-align:left">
|
||
<th class="adm-th">Monat</th>
|
||
<th class="adm-th text-right">Rechnungen</th>
|
||
<th class="adm-th text-right">Umsatz</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${monthRows || `<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Keine Daten</td></tr>`}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quartalsbericht -->
|
||
<div class="card p-4">
|
||
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
|
||
${UI.icon('file-csv')} Quartalsbericht herunterladen
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-3);align-items:flex-end;flex-wrap:wrap">
|
||
<div>
|
||
<label class="form-label text-xs">Jahr</label>
|
||
<select id="adm-inv-year" class="form-control" style="width:auto">${years}</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label text-xs">Quartal</label>
|
||
<select id="adm-inv-quarter" class="form-control" style="width:auto">
|
||
<option value="1">Q1 (Jan–Mär)</option>
|
||
<option value="2">Q2 (Apr–Jun)</option>
|
||
<option value="3">Q3 (Jul–Sep)</option>
|
||
<option value="4">Q4 (Okt–Dez)</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" id="adm-inv-csv">
|
||
${UI.icon('download-simple')} CSV herunterladen
|
||
</button>
|
||
<button class="btn btn-ghost btn-sm" id="adm-inv-preview-q">
|
||
${UI.icon('eye')} Vorschau
|
||
</button>
|
||
</div>
|
||
<div id="adm-inv-q-result" class="mt-3"></div>
|
||
</div>
|
||
`;
|
||
|
||
// CSV Download
|
||
el.querySelector('#adm-inv-csv')?.addEventListener('click', async () => {
|
||
const year = el.querySelector('#adm-inv-year').value;
|
||
const q = el.querySelector('#adm-inv-quarter').value;
|
||
try {
|
||
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
|
||
if (!data.invoices?.length) { UI.toast.warning('Keine Rechnungen in diesem Quartal.'); return; }
|
||
|
||
// CSV generieren
|
||
const fmtEur = v => v != null ? Number(v).toFixed(2) : '0.00';
|
||
const fmtDate = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '';
|
||
const escape = v => `"${String(v || '').replace(/"/g, '""')}"`;
|
||
|
||
const statusLabel = { paid: 'Bezahlt', sent: 'Versendet', cancelled: 'Storniert (Original)', storno: 'Stornorechnung' };
|
||
const header = 'Nummer;Empfaenger;E-Mail;Datum;Leistungszeitraum;Betrag (eingegangen);Rechnungsbetrag;Status;Versendet am;Zahlungseingang\n';
|
||
const csvRows = data.invoices.map(inv => {
|
||
const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross;
|
||
return [
|
||
inv.invoice_number,
|
||
inv.recipient_name, inv.recipient_email || '',
|
||
fmtDate(inv.created_at), inv.service_period || '',
|
||
fmtEur(effectiveAmt),
|
||
fmtEur(inv.amount_gross),
|
||
statusLabel[inv.status] || inv.status,
|
||
fmtDate(inv.sent_at), fmtDate(inv.paid_at)
|
||
].map(escape).join(';');
|
||
}).join('\n');
|
||
|
||
const blob = new Blob(['' + header + csvRows], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `banyaro-rechnungen-${year}-Q${q}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
UI.toast.success(`CSV mit ${data.invoices.length} Rechnungen heruntergeladen.`);
|
||
} catch (e) {
|
||
UI.toast.error(e.message || 'Fehler beim Laden.');
|
||
}
|
||
});
|
||
|
||
// Quartals-Vorschau
|
||
el.querySelector('#adm-inv-preview-q')?.addEventListener('click', async () => {
|
||
const year = el.querySelector('#adm-inv-year').value;
|
||
const q = el.querySelector('#adm-inv-quarter').value;
|
||
const resultEl = el.querySelector('#adm-inv-q-result');
|
||
resultEl.innerHTML = '<div style="color:var(--c-text-muted);font-size:var(--text-xs)">Lade…</div>';
|
||
try {
|
||
const data = await API.get(`/admin/invoices/quarterly/${year}/${q}`);
|
||
if (!data.invoices?.length) {
|
||
resultEl.innerHTML = `<div class="text-xs-muted">Keine Rechnungen in ${data.period || `Q${q} ${year}`}.</div>`;
|
||
return;
|
||
}
|
||
const _fmtE = v => v != null ? Number(v).toLocaleString('de-DE',{minimumFractionDigits:2}) + ' €' : '—';
|
||
const _fmtD = iso => iso ? new Date(iso).toLocaleDateString('de-DE') : '—';
|
||
const sL = { draft:'Entwurf', sent:'Versendet', paid:'Bezahlt', cancelled:'Storniert (Orig.)', storno:'Stornorechnung' };
|
||
const rows2 = data.invoices.map((inv, i) => {
|
||
const isStorno = inv.status === 'storno';
|
||
const effectiveAmt = (inv.status === 'paid' && inv.paid_amount != null) ? inv.paid_amount : inv.amount_gross;
|
||
const amtColor = isStorno ? 'color:var(--c-danger)' : (effectiveAmt < 0 ? 'color:var(--c-danger)' : '');
|
||
const amtNote = (inv.status === 'paid' && inv.paid_amount != null && Math.abs(inv.paid_amount - inv.amount_gross) >= 0.01)
|
||
? ` <span class="text-xs-muted">(RG: ${_fmtE(inv.amount_gross)})</span>` : '';
|
||
return `
|
||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||
<td class="adm-td" style="font-family:monospace;font-size:var(--text-xs);${isStorno?'color:var(--c-danger)':''}">${UI.escape(inv.invoice_number)}</td>
|
||
<td class="adm-td">${UI.escape(inv.recipient_name)}</td>
|
||
<td class="adm-td" style="text-align:right;font-weight:600;${amtColor}">${_fmtE(effectiveAmt)}${amtNote}</td>
|
||
<td class="adm-td" style="${isStorno?'color:var(--c-danger)':''}">${sL[inv.status]||inv.status}</td>
|
||
<td class="adm-td text-xs-muted">${_fmtD(inv.created_at)}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
resultEl.innerHTML = `
|
||
<div style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);margin-bottom:var(--space-2)">
|
||
${UI.escape(data.period || `Q${q} ${year}`)} — ${data.count} Buchung(en) · Summe: ${_fmtE(data.total_gross)}
|
||
</div>
|
||
<div class="adm-table-scroll">
|
||
<table class="adm-table">
|
||
<thead><tr style="background:var(--c-surface-2)">
|
||
<th class="adm-th">Nummer</th><th class="adm-th">Empfänger</th>
|
||
<th class="adm-th text-right">Betrag</th><th class="adm-th">Status</th>
|
||
<th class="adm-th">Erstellt</th>
|
||
</tr></thead>
|
||
<tbody>${rows2}</tbody>
|
||
<tfoot><tr style="border-top:2px solid var(--c-border)">
|
||
<td colspan="2" class="adm-td" style="font-weight:600">Gesamt</td>
|
||
<td class="adm-td" style="text-align:right;font-weight:700;color:var(--c-primary)">${_fmtE(data.total_gross)}</td>
|
||
<td colspan="2" class="adm-td text-xs-muted">
|
||
Netto: ${_fmtE(data.total_net)} · MwSt: ${_fmtE(data.total_tax)}
|
||
</td>
|
||
</tr></tfoot>
|
||
</table>
|
||
</div>`;
|
||
} catch (e) {
|
||
resultEl.innerHTML = `<div style="color:var(--c-danger);font-size:var(--text-xs)">Fehler: ${UI.escape(e.message)}</div>`;
|
||
}
|
||
});
|
||
}
|
||
|
||
return { init, refresh, onDogChange };
|
||
|
||
})();
|