Files
ifb-schulapp/public/admin.html
T

646 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Klassenportal">
<title>Klassenportal · Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
:root {
--blue: #2563eb; --blue-d: #1d4ed8; --blue-50: #eff6ff;
--slate-50: #f8fafc; --slate-100: #f1f5f9; --slate-200: #e2e8f0;
--slate-400: #94a3b8; --slate-500: #64748b; --slate-600: #475569; --slate-900: #0f172a;
--green: #16a34a; --red: #dc2626; --amber: #d97706;
--shadow: 0 1px 3px rgba(0,0,0,.07), 0 4px 12px rgba(0,0,0,.04);
--shadow-lg: 0 4px 20px rgba(0,0,0,.10), 0 1px 4px rgba(0,0,0,.06);
--r: 12px; --r-sm: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--slate-50); color: var(--slate-900); min-height: 100vh; display: flex; flex-direction: column; }
header { background: #fff; border-bottom: 1px solid var(--slate-200); padding: 0 28px; height: 58px; display: flex; align-items: center; gap: 14px; position: sticky; top: 0; z-index: 100; }
.brand { display: flex; flex-direction: column; }
.brand-school { font-size: 10px; color: var(--slate-400); font-weight: 600; letter-spacing: .6px; text-transform: uppercase; }
.brand-class { font-size: 20px; font-weight: 800; color: var(--blue); letter-spacing: -1px; line-height: 1.1; }
.h-spacer { flex: 1; }
.back-link { font-size: 13px; color: var(--slate-500); text-decoration: none; font-weight: 500; padding: 6px 12px; border-radius: var(--r-sm); border: 1.5px solid var(--slate-200); }
.back-link:hover { background: var(--slate-100); }
.admin-badge { background: #fef3c7; color: #92400e; font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 99px; border: 1px solid #fde68a; }
.page-title { padding: 24px 28px 0; }
.page-title h1 { font-size: 22px; font-weight: 800; }
.page-title p { font-size: 13px; color: var(--slate-500); margin-top: 4px; }
/* TABS */
.tabs { display: flex; gap: 4px; padding: 16px 28px 0; border-bottom: 1px solid var(--slate-200); background: var(--slate-50); position: sticky; top: 58px; z-index: 90; }
.tab { padding: 8px 16px; font-size: 13px; font-weight: 600; border: none; background: none; cursor: pointer; color: var(--slate-500); border-bottom: 2px solid transparent; margin-bottom: -1px; font-family: inherit; border-radius: var(--r-sm) var(--r-sm) 0 0; transition: color .15s; }
.tab:hover { color: var(--blue); background: var(--slate-100); }
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
.tab-badge { background: var(--red); color: #fff; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 99px; margin-left: 5px; }
.content { padding: 24px 28px; flex: 1; }
/* SECTION */
.section { display: none; }
.section.active { display: block; }
/* FILTER BAR */
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
.filter-bar input, .filter-bar select { padding: 7px 12px; border: 1.5px solid var(--slate-200); border-radius: var(--r-sm); font-size: 13px; font-family: inherit; outline: none; background: #fff; }
.filter-bar input:focus, .filter-bar select:focus { border-color: var(--blue); }
.filter-bar input { flex: 1; min-width: 180px; }
.filter-count { font-size: 12px; color: var(--slate-400); font-weight: 500; margin-left: auto; }
/* TABLE */
.tbl-wrap { overflow-x: auto; border-radius: var(--r); border: 1px solid var(--slate-200); background: #fff; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th { background: var(--slate-50); padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 700; color: var(--slate-500); letter-spacing: .4px; text-transform: uppercase; border-bottom: 1px solid var(--slate-200); white-space: nowrap; }
tbody tr { border-bottom: 1px solid var(--slate-100); transition: background .1s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--slate-50); }
tbody td { padding: 10px 14px; vertical-align: middle; }
.td-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* BADGES */
.badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 99px; white-space: nowrap; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-red { background: #fee2e2; color: #991b1b; }
.badge-orange { background: #fef3c7; color: #92400e; }
.badge-blue { background: #dbeafe; color: #1d4ed8; }
.badge-gray { background: var(--slate-100); color: var(--slate-500); }
.badge-purple { background: #f3e8ff; color: #6b21a8; }
/* BUTTONS */
.btn { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: var(--r-sm); border: none; cursor: pointer; font-family: inherit; white-space: nowrap; transition: opacity .15s; }
.btn:hover { opacity: .85; }
.btn-green { background: #dcfce7; color: #166534; }
.btn-red { background: #fee2e2; color: #991b1b; }
.btn-orange { background: #fef3c7; color: #92400e; }
.btn-blue { background: var(--blue-50); color: var(--blue); }
.btn-gray { background: var(--slate-100); color: var(--slate-600); }
.btn-purple { background: #f3e8ff; color: #6b21a8; }
/* STATS ROW */
.stats-row { display: flex; gap: 14px; margin-bottom: 20px; flex-wrap: wrap; }
.stat-card { background: #fff; border: 1px solid var(--slate-200); border-radius: var(--r); padding: 14px 18px; flex: 1; min-width: 120px; }
.stat-val { font-size: 24px; font-weight: 800; color: var(--blue); }
.stat-label { font-size: 12px; color: var(--slate-500); margin-top: 2px; }
/* TICKET */
.ticket-msg { font-size: 12px; color: var(--slate-500); max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* LOG */
.log-action { font-family: monospace; font-size: 12px; background: var(--slate-100); padding: 2px 6px; border-radius: 4px; }
.log-details { font-size: 11px; color: var(--slate-400); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* EMPTY */
.empty { text-align: center; color: var(--slate-300); font-size: 13px; padding: 32px; font-weight: 500; }
/* TOAST */
#toasts { position: fixed; bottom: 24px; right: 24px; z-index: 400; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { background: #1e293b; color: #fff; font-size: 13px; font-weight: 500; padding: 10px 16px; border-radius: var(--r-sm); box-shadow: var(--shadow-lg); opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; pointer-events: none; max-width: 280px; }
.toast.show { opacity: 1; transform: none; }
.toast.success { background: #166534; }
.toast.error { background: #991b1b; }
/* CONFIRM MODAL */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 300; display: flex; align-items: center; justify-content: center; padding: 16px; }
.modal { background: #fff; border-radius: var(--r); padding: 24px; width: 100%; max-width: 380px; box-shadow: var(--shadow-lg); }
.modal h3 { font-size: 16px; font-weight: 700; margin-bottom: 10px; }
.modal p { font-size: 13px; color: var(--slate-600); margin-bottom: 18px; }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
.btn-cancel { padding: 8px 16px; border: 1.5px solid var(--slate-200); border-radius: var(--r-sm); background: none; cursor: pointer; font-size: 13px; font-family: inherit; color: var(--slate-600); }
.btn-confirm { padding: 8px 16px; background: var(--red); color: #fff; border: none; border-radius: var(--r-sm); font-size: 13px; font-weight: 600; font-family: inherit; cursor: pointer; }
/* TICKET DETAIL */
.ticket-detail { background: var(--slate-50); border: 1px solid var(--slate-200); border-radius: var(--r-sm); padding: 12px 14px; margin-top: 8px; font-size: 13px; white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; }
/* Icons */
.lucide { display: inline-block; vertical-align: -0.125em; flex-shrink: 0; width: 1em; height: 1em; stroke-width: 2; }
.tab .lucide { width: 14px; height: 14px; }
</style>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</head>
<body>
<header>
<div class="brand">
<div class="brand-school">Klassenportal</div>
<div class="brand-class">Klassenportal</div>
</div>
<div class="h-spacer"></div>
<span class="admin-badge">Admin-Panel</span>
<a href="/" class="back-link">← Dashboard</a>
</header>
<div class="page-title">
<h1>Admin-Dashboard</h1>
<p id="admin-username-line">Eingeloggt als …</p>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('users')"><i data-lucide="users" aria-hidden="true"></i> Benutzer <span id="tab-badge-users" class="tab-badge" style="display:none"></span></button>
<button class="tab" onclick="switchTab('pending')"><i data-lucide="clock" aria-hidden="true"></i> Ausstehend <span id="tab-badge-pending" class="tab-badge" style="display:none"></span></button>
<button class="tab" onclick="switchTab('tickets')"><i data-lucide="life-buoy" aria-hidden="true"></i> Tickets <span id="tab-badge-tickets" class="tab-badge" style="display:none"></span></button>
<button class="tab" onclick="switchTab('usage')"><i data-lucide="bar-chart-2" aria-hidden="true"></i> Datennutzung</button>
<button class="tab" onclick="switchTab('logs')"><i data-lucide="clipboard" aria-hidden="true"></i> Aktionslog</button>
</div>
<div class="content">
<!-- USERS TAB -->
<div class="section active" id="section-users">
<div class="stats-row" id="stats-row"></div>
<div class="filter-bar">
<input type="search" id="user-search" placeholder="Suche nach Benutzername / E-Mail …" oninput="filterUsers()">
<select id="user-role-filter" onchange="filterUsers()">
<option value="">Alle Rollen</option>
<option value="student">Student</option>
<option value="teacher">Lehrer</option>
<option value="admin">Admin</option>
</select>
<select id="user-status-filter" onchange="filterUsers()">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="pending">Ausstehend</option>
<option value="banned">Gesperrt</option>
<option value="rejected">Abgelehnt</option>
</select>
<span class="filter-count" id="user-count"></span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>ID</th><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th>Bestätigt</th><th>Registriert</th><th>Aktionen</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</div>
<!-- PENDING TAB -->
<div class="section" id="section-pending">
<p style="font-size:13px;color:var(--slate-500);margin-bottom:16px">Lehrerkkonten warten auf Genehmigung.</p>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>ID</th><th>Benutzername</th><th>E-Mail</th><th>Registriert</th><th>Aktionen</th>
</tr></thead>
<tbody id="pending-tbody"></tbody>
</table>
</div>
</div>
<!-- TICKETS TAB -->
<div class="section" id="section-tickets">
<div class="filter-bar">
<select id="ticket-status-filter" onchange="filterTickets()">
<option value="">Alle Status</option>
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="closed">Geschlossen</option>
</select>
<span class="filter-count" id="ticket-count"></span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>ID</th><th>Von</th><th>Betreff</th><th>Status</th><th>Erstellt</th><th>Aktionen</th>
</tr></thead>
<tbody id="tickets-tbody"></tbody>
</table>
</div>
</div>
<!-- USAGE TAB -->
<div class="section" id="section-usage">
<div class="tbl-wrap">
<table>
<thead><tr>
<th>Benutzername</th><th>Rolle</th><th>Stundenplan</th><th>Hausaufgaben</th><th>Noten</th><th>Fehlzeiten</th><th>Todos</th><th>Countdowns</th><th>Links</th><th>Tickets</th>
</tr></thead>
<tbody id="usage-tbody"></tbody>
</table>
</div>
</div>
<!-- LOGS TAB -->
<div class="section" id="section-logs">
<div class="tbl-wrap">
<table>
<thead><tr>
<th>Zeit</th><th>Admin</th><th>Aktion</th><th>Ziel-ID</th><th>Details</th>
</tr></thead>
<tbody id="logs-tbody"></tbody>
</table>
</div>
</div>
</div>
<!-- CONFIRM MODAL -->
<div class="overlay" id="confirm-overlay" style="display:none">
<div class="modal">
<h3 id="confirm-title">Bestätigung</h3>
<p id="confirm-msg"></p>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeConfirm()">Abbrechen</button>
<button class="btn-confirm" id="confirm-ok">Bestätigen</button>
</div>
</div>
</div>
<!-- TICKET DETAIL MODAL -->
<div class="overlay" id="ticket-overlay" style="display:none" onclick="if(event.target===this)closeTicket()">
<div class="modal" style="max-width:540px">
<h3 id="ticket-modal-title" style="word-break:break-word"></h3>
<p id="ticket-modal-meta" style="font-size:12px;color:var(--slate-500);margin-bottom:8px"></p>
<div id="ticket-modal-thread" style="display:flex;flex-direction:column;gap:8px;max-height:320px;overflow-y:auto;padding-right:2px;margin-bottom:10px"></div>
<div id="ticket-modal-reply-area" style="display:flex;gap:8px;margin-bottom:4px">
<textarea id="ticket-modal-reply" placeholder="Als Admin antworten…" rows="2" maxlength="5000"
style="flex:1;border:1.5px solid var(--slate-200);border-radius:var(--r-sm);padding:8px 10px;font-size:13px;font-family:inherit;resize:none;outline:none"></textarea>
<button class="btn btn-blue" style="align-self:flex-end;padding:7px 14px" onclick="sendAdminReply()">Senden</button>
</div>
<div class="modal-actions" style="margin-top:8px">
<button class="btn-cancel" onclick="closeTicket()">Schließen</button>
</div>
</div>
</div>
<div id="toasts"></div>
<script>
function esc(s){ return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function toast(msg, type='success'){
const el=document.createElement('div');
el.className=`toast ${type}`; el.textContent=msg;
document.getElementById('toasts').appendChild(el);
requestAnimationFrame(()=>requestAnimationFrame(()=>el.classList.add('show')));
setTimeout(()=>{ el.classList.remove('show'); setTimeout(()=>el.remove(),300); },3200);
}
async function api(method, path, body){
const r=await fetch('/api/'+path,{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
if(!r.ok){ const d=await r.json().catch(()=>({})); throw new Error(d.error||r.status); }
return r.json();
}
// ── AUTH GUARD ─────────────────────────────────────────────────
let currentAdminId = null;
async function authGuard(){
const r = await fetch('/api/me');
if(!r.ok){ location.href='/login'; return false; }
const d = await r.json();
if(d.role !== 'admin'){ location.href='/'; return false; }
currentAdminId = d.id;
document.getElementById('admin-username-line').textContent = `Eingeloggt als ${d.username}`;
return true;
}
// ── TABS ───────────────────────────────────────────────────────
let currentTab = 'users';
function switchTab(name){
document.querySelectorAll('.tab').forEach((t,i)=>{
const tabs=['users','pending','tickets','usage','logs'];
t.classList.toggle('active', tabs[i]===name);
});
document.querySelectorAll('.section').forEach(s=>s.classList.remove('active'));
document.getElementById('section-'+name).classList.add('active');
currentTab=name;
}
// ── DATA STORES ────────────────────────────────────────────────
let allUsers=[], allTickets=[], allLogs=[], allUsage=[];
// ── USERS ──────────────────────────────────────────────────────
async function loadUsers(){
allUsers = await api('GET','admin/users');
renderStats();
renderUsers();
renderPending();
}
function renderStats(){
const total=allUsers.length;
const active=allUsers.filter(u=>u.status==='active').length;
const pending=allUsers.filter(u=>u.status==='pending').length;
const banned=allUsers.filter(u=>u.status==='banned').length;
document.getElementById('stats-row').innerHTML=`
<div class="stat-card"><div class="stat-val">${total}</div><div class="stat-label">Benutzer gesamt</div></div>
<div class="stat-card"><div class="stat-val" style="color:var(--green)">${active}</div><div class="stat-label">Aktiv</div></div>
<div class="stat-card"><div class="stat-val" style="color:var(--amber)">${pending}</div><div class="stat-label">Ausstehend</div></div>
<div class="stat-card"><div class="stat-val" style="color:var(--red)">${banned}</div><div class="stat-label">Gesperrt</div></div>
`;
const pb=document.getElementById('tab-badge-pending');
pb.textContent=pending; pb.style.display=pending?'':'none';
}
function filterUsers(){
const q=document.getElementById('user-search').value.toLowerCase();
const role=document.getElementById('user-role-filter').value;
const status=document.getElementById('user-status-filter').value;
const filtered=allUsers.filter(u=>
(!q || u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)) &&
(!role || u.role===role) &&
(!status || u.status===status)
);
document.getElementById('user-count').textContent=`${filtered.length} von ${allUsers.length}`;
renderUsersRows(filtered, document.getElementById('users-tbody'));
}
function renderUsers(){
filterUsers();
}
function renderUsersRows(users, tbody){
if(!users.length){ tbody.innerHTML=`<tr><td colspan="8" class="empty">Keine Benutzer gefunden</td></tr>`; return; }
tbody.innerHTML=users.map(u=>`
<tr>
<td style="color:var(--slate-400);font-size:12px">${u.id}</td>
<td><strong>${esc(u.username)}</strong></td>
<td style="font-size:12px;color:var(--slate-500)">${esc(u.email)}</td>
<td>${roleBadge(u.role)}</td>
<td>${statusBadge(u.status)}</td>
<td>${verifiedBadge(u.email_verified)}</td>
<td style="font-size:12px;color:var(--slate-400)">${fmtDate(u.created_at)}</td>
<td><div class="td-actions">${userActions(u)}</div></td>
</tr>`).join('');
}
function verifiedBadge(v){
return v
? `<span class="badge badge-green">✓ bestätigt</span>`
: `<span class="badge badge-gray">nicht bestätigt</span>`;
}
function roleBadge(r){
const m={admin:'badge-purple',teacher:'badge-blue',student:'badge-green'};
return `<span class="badge ${m[r]||'badge-gray'}">${esc(r)}</span>`;
}
function statusBadge(s){
const m={active:'badge-green',pending:'badge-orange',banned:'badge-red',rejected:'badge-gray'};
return `<span class="badge ${m[s]||'badge-gray'}">${esc(s)}</span>`;
}
function userActions(u){
const parts=[];
if(u.status==='active') parts.push(`<button class="btn btn-red" onclick="banUser(${u.id},'${esc(u.username)}')">Sperren</button>`);
if(u.status==='banned') parts.push(`<button class="btn btn-green" onclick="unbanUser(${u.id},'${esc(u.username)}')">Entsperren</button>`);
if(!u.email_verified) parts.push(`<button class="btn btn-green" onclick="verifyUser(${u.id},'${esc(u.username)}')">E-Mail bestätigen</button>`);
else parts.push(`<button class="btn btn-gray" onclick="unverifyUser(${u.id},'${esc(u.username)}')">Bestätigung entfernen</button>`);
if(u.role!=='admin') parts.push(`<button class="btn btn-purple" onclick="makeAdmin(${u.id},'${esc(u.username)}')">Zu Admin machen</button>`);
else if(u.id!==currentAdminId) parts.push(`<button class="btn btn-gray" onclick="demoteAdmin(${u.id},'${esc(u.username)}')">Admin entfernen</button>`);
parts.push(`<button class="btn btn-gray" onclick="deleteUser(${u.id},'${esc(u.username)}')">Löschen</button>`);
return parts.join('');
}
function renderPending(){
const pending=allUsers.filter(u=>u.status==='pending'&&u.role==='teacher');
const tbody=document.getElementById('pending-tbody');
if(!pending.length){ tbody.innerHTML=`<tr><td colspan="5" class="empty">Keine ausstehenden Anfragen</td></tr>`; return; }
tbody.innerHTML=pending.map(u=>`
<tr>
<td style="color:var(--slate-400);font-size:12px">${u.id}</td>
<td><strong>${esc(u.username)}</strong></td>
<td style="font-size:12px;color:var(--slate-500)">${esc(u.email)}</td>
<td style="font-size:12px;color:var(--slate-400)">${fmtDate(u.created_at)}</td>
<td><div class="td-actions">
<button class="btn btn-green" onclick="approveTeacher(${u.id},'${esc(u.username)}')">Genehmigen</button>
<button class="btn btn-red" onclick="rejectTeacher(${u.id},'${esc(u.username)}')">Ablehnen</button>
</div></td>
</tr>`).join('');
}
async function banUser(id, name){
confirm2(`Benutzer "${name}" sperren?`, `Der Benutzer kann sich bis zur Entsperrung nicht anmelden.`, async()=>{
await api('PATCH',`admin/users/${id}`,{status:'banned'});
toast(`${name} gesperrt`,'success'); loadUsers();
});
}
async function unbanUser(id, name){
confirm2(`Benutzer "${name}" entsperren?`, `Der Benutzer kann sich wieder anmelden.`, async()=>{
await api('PATCH',`admin/users/${id}`,{status:'active'});
toast(`${name} entsperrt`,'success'); loadUsers();
});
}
async function deleteUser(id, name){
confirm2(`Benutzer "${name}" löschen?`, `Alle Daten dieses Benutzers werden unwiderruflich gelöscht.`, async()=>{
await api('DELETE',`admin/users/${id}`);
toast(`${name} gelöscht`,'success'); loadUsers();
});
}
async function verifyUser(id, name){
await api('PATCH',`admin/users/${id}`,{email_verified:1});
toast(`${name} E-Mail bestätigt`,'success'); loadUsers();
}
async function makeAdmin(id, name){
confirm2(`"${name}" zu Admin machen?`, `Der Benutzer erhält vollen Administratorzugriff.`, async()=>{
await api('PATCH',`admin/users/${id}`,{role:'admin'});
toast(`${name} ist jetzt Admin`,'success'); loadUsers();
});
}
async function demoteAdmin(id, name){
confirm2(`Admin-Rechte von "${name}" entfernen?`, `Der Benutzer wird zum Schüler herabgestuft.`, async()=>{
await api('PATCH',`admin/users/${id}`,{role:'student'});
toast(`${name} ist kein Admin mehr`,'success'); loadUsers();
});
}
async function unverifyUser(id, name){
confirm2(`Bestätigung für "${name}" entfernen?`, `Der Benutzer kann sich bis zur erneuten Bestätigung nicht mehr anmelden.`, async()=>{
await api('PATCH',`admin/users/${id}`,{email_verified:0});
toast(`${name} Bestätigung entfernt`,'success'); loadUsers();
});
}
async function approveTeacher(id, name){
await api('POST',`admin/teachers/${id}/approve`);
toast(`${name} genehmigt`,'success'); loadUsers();
}
async function rejectTeacher(id, name){
confirm2(`Lehrer "${name}" ablehnen?`, `Die Registrierung wird abgelehnt.`, async()=>{
await api('POST',`admin/teachers/${id}/reject`);
toast(`${name} abgelehnt`,'success'); loadUsers();
});
}
// ── TICKETS ────────────────────────────────────────────────────
async function loadTickets(){
allTickets = await api('GET','admin/tickets');
filterTickets();
const open=allTickets.filter(t=>t.status==='open').length;
const tb=document.getElementById('tab-badge-tickets');
tb.textContent=open; tb.style.display=open?'':'none';
}
function filterTickets(){
const status=document.getElementById('ticket-status-filter').value;
const filtered=status?allTickets.filter(t=>t.status===status):allTickets;
document.getElementById('ticket-count').textContent=`${filtered.length} Ticket${filtered.length!==1?'s':''}`;
const tbody=document.getElementById('tickets-tbody');
if(!filtered.length){ tbody.innerHTML=`<tr><td colspan="6" class="empty">Keine Tickets</td></tr>`; return; }
tbody.innerHTML=filtered.map(t=>`
<tr>
<td style="color:var(--slate-400);font-size:12px">#${t.id}</td>
<td><strong>${esc(t.username)}</strong><br><span style="font-size:11px;color:var(--slate-400)">${esc(t.email)}</span></td>
<td>
<div style="font-weight:600">${esc(t.subject)}</div>
<div class="ticket-msg">${esc(t.message)}</div>
</td>
<td>${ticketStatusBadge(t.status)}</td>
<td style="font-size:12px;color:var(--slate-400)">${fmtDate(t.created_at)}</td>
<td><div class="td-actions">
<button class="btn btn-blue" onclick="viewTicket(${t.id})">Anzeigen</button>
${t.status!=='closed'?`<button class="btn btn-gray" onclick="closeTicketAction(${t.id})">Schließen</button>`:''}
${t.status==='open'?`<button class="btn btn-orange" onclick="inProgressTicket(${t.id})">In Bearbeitung</button>`:''}
</div></td>
</tr>`).join('');
}
function ticketStatusBadge(s){
const m={open:'badge-red',in_progress:'badge-orange',closed:'badge-green'};
const l={open:'Offen',in_progress:'In Bearbeitung',closed:'Geschlossen'};
return `<span class="badge ${m[s]||'badge-gray'}">${l[s]||esc(s)}</span>`;
}
let activeAdminTicket = null;
async function viewTicket(id){
const t=allTickets.find(x=>x.id===id); if(!t)return;
activeAdminTicket=t;
document.getElementById('ticket-modal-title').textContent=t.subject;
document.getElementById('ticket-modal-meta').textContent=`Von ${esc(t.username)} (${esc(t.email)}) · ${fmtDateTime(t.created_at)}`;
document.getElementById('ticket-modal-reply').value='';
document.getElementById('ticket-modal-reply-area').style.display=t.status==='closed'?'none':'flex';
document.getElementById('ticket-modal-thread').innerHTML='<div style="text-align:center;color:var(--slate-400);font-size:13px;padding:8px">Laden…</div>';
document.getElementById('ticket-overlay').style.display='flex';
try{
const msgs=await api('GET',`tickets/${id}/messages`);
renderAdminThread(t,msgs);
}catch(e){
document.getElementById('ticket-modal-thread').innerHTML=`<div style="color:var(--red);font-size:12px">${esc(e.message)}</div>`;
}
}
function renderAdminThread(ticket, messages){
const wrap=document.getElementById('ticket-modal-thread');
const rows=[];
rows.push(`<div style="display:flex;flex-direction:column;align-items:flex-start">
<div style="background:var(--slate-100);border-radius:10px 10px 10px 4px;padding:8px 12px;font-size:13px;max-width:85%;white-space:pre-wrap;word-break:break-word">${esc(ticket.message)}</div>
<div style="font-size:10px;color:var(--slate-400);margin-top:3px">${esc(ticket.username)} · ${fmtDateTime(ticket.created_at)}</div>
</div>`);
messages.forEach(m=>{
const isAdmin=m.role==='admin';
rows.push(`<div style="display:flex;flex-direction:column;align-items:${isAdmin?'flex-end':'flex-start'}">
<div style="background:${isAdmin?'#2563eb':'var(--slate-100)'};color:${isAdmin?'#fff':'var(--slate-900)'};border-radius:${isAdmin?'10px 10px 4px 10px':'10px 10px 10px 4px'};padding:8px 12px;font-size:13px;max-width:85%;white-space:pre-wrap;word-break:break-word">${esc(m.message)}</div>
<div style="font-size:10px;color:var(--slate-400);margin-top:3px;text-align:${isAdmin?'right':'left'}">${isAdmin?'🛡️ Admin':''}${esc(m.username)} · ${fmtDateTime(m.created_at)}</div>
</div>`);
});
wrap.innerHTML=rows.join('');
wrap.scrollTop=wrap.scrollHeight;
}
async function sendAdminReply(){
if(!activeAdminTicket)return;
const inp=document.getElementById('ticket-modal-reply');
const message=inp.value.trim();
if(!message){ toast('Nachricht darf nicht leer sein','error'); return; }
try{
await api('POST',`tickets/${activeAdminTicket.id}/messages`,{message});
inp.value='';
await loadTickets();
const updated=allTickets.find(x=>x.id===activeAdminTicket.id);
if(updated) activeAdminTicket=updated;
const msgs=await api('GET',`tickets/${activeAdminTicket.id}/messages`);
renderAdminThread(activeAdminTicket,msgs);
toast('Antwort gesendet');
}catch(e){ toast(e.message,'error'); }
}
function closeTicket(){ document.getElementById('ticket-overlay').style.display='none'; activeAdminTicket=null; }
async function closeTicketAction(id){
await api('PATCH',`admin/tickets/${id}`,{status:'closed'});
toast('Ticket geschlossen'); loadTickets();
}
async function inProgressTicket(id){
await api('PATCH',`admin/tickets/${id}`,{status:'in_progress'});
toast('Status aktualisiert'); loadTickets();
}
// ── USAGE ──────────────────────────────────────────────────────
async function loadUsage(){
allUsage = await api('GET','admin/usage');
const tbody=document.getElementById('usage-tbody');
if(!allUsage.length){ tbody.innerHTML=`<tr><td colspan="10" class="empty">Keine Daten</td></tr>`; return; }
const tables=['timetable','homework','grades','absences','todos','countdowns','quicklinks','support_tickets'];
tbody.innerHTML=allUsage.map(u=>`
<tr>
<td><strong>${esc(u.username)}</strong></td>
<td>${roleBadge(u.role)}</td>
${tables.map(t=>`<td style="text-align:center;font-size:13px;color:${u.counts[t]>0?'var(--slate-900)':'var(--slate-300)'}">${u.counts[t]}</td>`).join('')}
</tr>`).join('');
}
// ── LOGS ───────────────────────────────────────────────────────
async function loadLogs(){
allLogs = await api('GET','admin/logs');
const tbody=document.getElementById('logs-tbody');
if(!allLogs.length){ tbody.innerHTML=`<tr><td colspan="5" class="empty">Keine Aktionen protokolliert</td></tr>`; return; }
tbody.innerHTML=allLogs.map(l=>`
<tr>
<td style="font-size:12px;color:var(--slate-400);white-space:nowrap">${fmtDateTime(l.created_at)}</td>
<td><strong>${esc(l.admin_username)}</strong></td>
<td><span class="log-action">${esc(l.action)}</span></td>
<td style="text-align:center;font-size:12px;color:var(--slate-500)">${l.target_id??''}</td>
<td><span class="log-details" title="${esc(l.details??'')}">${esc(l.details??'')}</span></td>
</tr>`).join('');
}
// ── CONFIRM MODAL ──────────────────────────────────────────────
let confirmCallback=null;
function confirm2(title, msg, cb){
document.getElementById('confirm-title').textContent=title;
document.getElementById('confirm-msg').textContent=msg;
confirmCallback=cb;
document.getElementById('confirm-overlay').style.display='flex';
}
function closeConfirm(){ document.getElementById('confirm-overlay').style.display='none'; confirmCallback=null; }
document.getElementById('confirm-ok').addEventListener('click',async()=>{
if(!confirmCallback)return;
const cb=confirmCallback; closeConfirm();
try{ await cb(); } catch(e){ toast(e.message,'error'); }
});
// ── UTILS ──────────────────────────────────────────────────────
function fmtDate(s){ return s?new Date(s).toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}):''; }
function fmtDateTime(s){ if(!s)return''; const d=new Date(s); return d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'})+' '+d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}); }
// ── INIT ───────────────────────────────────────────────────────
(async()=>{
lucide.createIcons();
const ok = await authGuard();
if(!ok)return;
await Promise.all([loadUsers(), loadTickets()]);
// Lazy-load usage + logs on tab switch
document.querySelectorAll('.tab').forEach((t,i)=>{
const tabs=['users','pending','tickets','usage','logs'];
t.addEventListener('click',()=>{
if(tabs[i]==='usage') loadUsage();
if(tabs[i]==='logs') loadLogs();
});
});
})();
</script>
</body>
</html>