Admin: Social-Media-Tab mit Manager-Übersicht, Plattform-Auswertung und Post-Nachweis, SW by-v370

This commit is contained in:
rene 2026-04-25 10:27:39 +02:00
parent e2bb1a4b2d
commit 4f58a784c7
3 changed files with 182 additions and 1 deletions

View file

@ -728,6 +728,65 @@ async def wiki_fetch_photos(limit: int = 50, user=Depends(require_mod)):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod) # DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get("/social")
async def admin_social_stats(user=Depends(require_mod)):
"""Social-Media-Übersicht für Admins — alle Manager, alle Plattformen."""
with db() as conn:
# Pro Manager: Name + Counts
managers = conn.execute("""
SELECT u.id, u.name,
COUNT(sc.id) AS total,
COUNT(CASE WHEN sc.status='published' THEN 1 END) AS published,
COUNT(CASE WHEN sc.status='idea' THEN 1 END) AS ideas,
COUNT(CASE WHEN sc.status='scheduled' THEN 1 END) AS scheduled,
COUNT(CASE WHEN sc.post_url IS NOT NULL
AND sc.post_url != ''
AND sc.status='published' THEN 1 END) AS with_link
FROM users u
JOIN social_content sc ON sc.created_by = u.id
GROUP BY u.id
ORDER BY published DESC
""").fetchall()
# Veröffentlichte Posts nach Plattform
by_platform = conn.execute("""
SELECT platform, COUNT(*) AS n
FROM social_content
WHERE status='published'
GROUP BY platform
ORDER BY n DESC
""").fetchall()
# Veröffentlichte Posts nach Monat (letzte 6 Monate)
by_month = conn.execute("""
SELECT strftime('%Y-%m', published_at) AS monat, COUNT(*) AS n
FROM social_content
WHERE status='published' AND published_at IS NOT NULL
GROUP BY monat
ORDER BY monat DESC
LIMIT 6
""").fetchall()
# Letzte veröffentlichte Posts mit Link (für Abrechnung/Nachweis)
recent_published = conn.execute("""
SELECT sc.id, sc.topic, sc.platform, sc.category,
sc.published_at, sc.post_url, sc.ai_score,
u.name AS manager
FROM social_content sc
LEFT JOIN users u ON u.id = sc.created_by
WHERE sc.status = 'published'
ORDER BY sc.published_at DESC
LIMIT 50
""").fetchall()
return {
"managers": [dict(r) for r in managers],
"by_platform": [dict(r) for r in by_platform],
"by_month": [dict(r) for r in by_month],
"recent_published": [dict(r) for r in recent_published],
}
@router.delete("/wiki/zuchter/{zuchter_id}", status_code=204) @router.delete("/wiki/zuchter/{zuchter_id}", status_code=204)
async def admin_delete_zuchter(zuchter_id: int, user=Depends(require_mod)): async def admin_delete_zuchter(zuchter_id: int, user=Depends(require_mod)):
with db() as conn: with db() as conn:

View file

@ -14,6 +14,7 @@ window.Page_admin = (() => {
{ id: 'nutzer', label: 'Nutzer', icon: 'users' }, { id: 'nutzer', label: 'Nutzer', icon: 'users' },
{ id: 'moderation', label: 'Moderation', icon: 'shield-check' }, { id: 'moderation', label: 'Moderation', icon: 'shield-check' },
{ id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' }, { id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' },
{ id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' }, { id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'jobs', label: 'Jobs', icon: 'clock' },
@ -81,6 +82,7 @@ window.Page_admin = (() => {
case 'nutzer': await _renderUsers(el); break; case 'nutzer': await _renderUsers(el); break;
case 'moderation': await _renderModeration(el); break; case 'moderation': await _renderModeration(el); break;
case 'forum': await _renderForum(el); break; case 'forum': await _renderForum(el); break;
case 'social': await _renderSocial(el); break;
case 'analytics': await _renderAnalytics(el); break; case 'analytics': await _renderAnalytics(el); break;
case 'system': await _renderSystem(el); break; case 'system': await _renderSystem(el); break;
case 'jobs': await _renderJobs(el); break; case 'jobs': await _renderJobs(el); break;
@ -91,6 +93,126 @@ window.Page_admin = (() => {
} }
} }
// ------------------------------------------------------------------
// TAB: SOCIAL MEDIA
async function _renderSocial(el) {
const d = await API.get('/admin/social');
const _PL = { instagram: '📸 Instagram', tiktok: '🎵 TikTok', both: '📱 Beide' };
const _fmt = iso => iso ? new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'}) : '';
// Manager-Tabelle
const managerRows = d.managers.map(m => `
<tr>
<td style="font-weight:600">${_esc(m.name)}</td>
<td style="text-align:right">${m.published}</td>
<td style="text-align: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 style="text-align:right">${m.scheduled}</td>
<td style="text-align:right">${m.ideas}</td>
<td style="text-align:right;color:var(--c-text-muted)">${m.total}</td>
</tr>`).join('');
// Plattform-Balken
const maxPlat = Math.max(...d.by_platform.map(p => p.n), 1);
const platBars = d.by_platform.map(p => `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
<div style="width:120px;flex-shrink:0;font-size:var(--text-sm)">${_PL[p.platform]||p.platform}</div>
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
<div style="width:${Math.round(p.n/maxPlat*100)}%;height:100%;
background:var(--c-primary);border-radius:var(--radius-full)"></div>
</div>
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${p.n}</div>
</div>`).join('');
// Monats-Timeline
const maxMonth = Math.max(...d.by_month.map(m => m.n), 1);
const monthBars = [...d.by_month].reverse().map(m => `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-2)">
<div style="width:55px;flex-shrink:0;font-size:11px;color:var(--c-text-muted)">${m.monat}</div>
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-full);height:8px;overflow:hidden">
<div style="width:${Math.round(m.n/maxMonth*100)}%;height:100%;
background:var(--c-success);border-radius:var(--radius-full)"></div>
</div>
<div style="width:28px;text-align:right;font-weight:600;font-size:var(--text-sm)">${m.n}</div>
</div>`).join('');
// Veröffentlichte Posts (mit/ohne Link)
const postRows = d.recent_published.map(p => `
<tr>
<td style="color:var(--c-text-muted);white-space:nowrap">${_fmt(p.published_at)}</td>
<td style="font-weight:500;max-width:200px;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap">${_esc(p.topic)}</td>
<td>${_PL[p.platform]||p.platform||''}</td>
<td style="font-size:10px;color:var(--c-text-muted)">${_esc(p.category||'')}</td>
<td>${p.ai_score ? '⭐'.repeat(p.ai_score) : ''}</td>
<td style="font-weight:500">${_esc(p.manager||'')}</td>
<td>${p.post_url
? `<a href="${_esc(p.post_url)}" target="_blank" rel="noopener"
style="font-size:11px;color:var(--c-primary)">🔗 Link</a>`
: `<span style="font-size:11px;color:var(--c-text-muted)"></span>`}</td>
</tr>`).join('');
el.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4);margin-bottom:var(--space-4)">
<!-- Plattform -->
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
Veröffentlicht nach Plattform
</div>
${platBars || '<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Posts</div>'}
</div>
<!-- Timeline -->
<div class="card" style="padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:700;margin-bottom:var(--space-3)">
Posts pro Monat
</div>
${monthBars || '<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Posts</div>'}
</div>
</div>
<!-- Manager -->
${d.managers.length ? `
<div class="card adm-table-card" style="margin-bottom:var(--space-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 style="text-align:right">Veröffentlicht</th>
<th style="text-align:right">Mit Link</th>
<th style="text-align:right">Geplant</th>
<th style="text-align:right">Ideen</th>
<th style="text-align: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 // TAB: ANALYTICS
async function _renderAnalytics(el) { async function _renderAnalytics(el) {

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v369'; const CACHE_VERSION = 'by-v370';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten