646 lines
32 KiB
HTML
646 lines
32 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
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>
|