Unifi-Voucher-Tool/admin/index.php
Friederich Loheide 6da46f040a Alle Frontend-Assets lokal ausliefern statt über CDNs
Inter, Font Awesome, Chart.js, qrcodejs und TinyMCE liegen jetzt unter
assets/vendor/ und werden vom eigenen Server ausgeliefert.

Warum:
- Datenschutz: bisher ging bei jedem Seitenaufruf die IP der Nutzer an
  Google Fonts, cdnjs, jsDelivr und Tiny Cloud
- Funktion: UniFi-Installationen stehen oft in abgeschotteten Netzen –
  dort fehlten bisher Schrift, Icons, Diagramme und Editor

Neu: includes/Ui.php
- Ui::head()/Ui::script() binden die Assets ein und hängen einen
  Versionsstempel an (?v=filemtime), damit Browser nach einem Update
  nicht das alte CSS aus dem Cache nehmen
- Ui::themeScript() setzt das Theme aus der gespeicherten Auswahl oder
  – wenn keine vorliegt – aus prefers-color-scheme; global.js folgt
  Systemwechseln live, solange nichts manuell gewählt wurde
- <meta name="color-scheme"> ergänzt, damit Formularelemente passen

Der TinyMCE-API-Key entfällt: der Editor läuft immer lokal (GPL-Variante),
inklusive deutscher Oberfläche, wenn die App auf Deutsch steht.

Neu: tools/demo/build.py – baut aus dem Projekt eine Demo-Instanz ohne
Datenbank (Stubs für Database/Auth, feste Beispieldaten). Damit lassen
sich Screenshots reproduzierbar erzeugen und alle Seiten einmal rendern
(Smoke-Test), ohne eine MySQL-Instanz aufzusetzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:23:44 +00:00

347 lines
18 KiB
PHP
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.

<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/UniFiController.php';
require_once __DIR__ . '/../includes/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
I18n::init();
// AJAX: Statistiken
if (isset($_GET['ajax_stats'])) {
header('Content-Type: application/json');
$syncFirst = isset($_GET['sync']) && $_GET['sync'] == '1';
try {
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active = 1");
$siteData = [];
$totalStats = ['total' => 0, 'valid' => 0, 'used' => 0, 'expired' => 0];
$syncErrors = [];
if ($syncFirst) {
foreach ($sites as $site) {
try {
$ctrl = new UniFiController($site['unifi_controller_url'], $site['unifi_username'], Crypto::decrypt($site['unifi_password']), $site['site_id']);
$ctrl->syncVouchersToDatabase($db, $site['id']);
} catch (Exception $e) {
$syncErrors[$site['id']] = $e->getMessage();
}
}
$db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()");
}
foreach ($sites as $site) {
$ss = $db->fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN status='valid' THEN 1 ELSE 0 END) as valid, SUM(CASE WHEN status='used' THEN 1 ELSE 0 END) as used, SUM(CASE WHEN status='expired' THEN 1 ELSE 0 END) as expired FROM vouchers WHERE site_id=?", [$site['id']]);
$siteData[] = ['site_id' => $site['id'], 'site_name' => $site['name'], 'stats' => ['total' => (int)($ss['total']??0), 'valid' => (int)($ss['valid']??0), 'used' => (int)($ss['used']??0), 'expired' => (int)($ss['expired']??0)], 'error' => $syncErrors[$site['id']] ?? null];
$totalStats['total'] += (int)($ss['total']??0);
$totalStats['valid'] += (int)($ss['valid']??0);
$totalStats['used'] += (int)($ss['used']??0);
$totalStats['expired'] += (int)($ss['expired']??0);
}
$lastSync = $db->getSetting('last_cron_sync', '');
echo json_encode(['success' => true, 'synced' => $syncFirst, 'sites' => $siteData, 'total' => $totalStats, 'last_sync' => $lastSync ? date('d.m.Y H:i:s', strtotime($lastSync)) : null, 'timestamp' => date('H:i:s')]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
exit;
}
$stats = [
'total_sites' => $db->fetchOne("SELECT COUNT(*) as count FROM sites WHERE is_active=1")['count'],
'total_users' => $db->fetchOne("SELECT COUNT(*) as count FROM users WHERE is_active=1")['count'],
'total_vouchers_today' => $db->fetchOne("SELECT COUNT(*) as count FROM vouchers WHERE DATE(created_at)=CURDATE()")['count'],
];
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1");
$voucherStats = $db->fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN status='valid' THEN 1 ELSE 0 END) as valid, SUM(CASE WHEN status='used' THEN 1 ELSE 0 END) as used, SUM(CASE WHEN status='expired' THEN 1 ELSE 0 END) as expired FROM vouchers");
$siteStats = [];
foreach ($sites as $site) {
$siteStats[$site['id']] = $db->fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN status='valid' THEN 1 ELSE 0 END) as valid, SUM(CASE WHEN status='used' THEN 1 ELSE 0 END) as used, SUM(CASE WHEN status='expired' THEN 1 ELSE 0 END) as expired FROM vouchers WHERE site_id=?", [$site['id']]);
}
$lastCronSync = $db->getSetting('last_cron_sync', '');
$chartData = [];
for ($i = 6; $i >= 0; $i--) {
$date = date('Y-m-d', strtotime("-$i days"));
$count = $db->fetchOne("SELECT COUNT(*) as count FROM vouchers WHERE DATE(created_at)=?", [$date])['count'];
$chartData[] = ['date' => date('d.m', strtotime($date)), 'count' => $count];
}
$topUsers = $db->fetchAll("SELECT u.name, u.email, COUNT(v.id) as voucher_count FROM users u LEFT JOIN vouchers v ON u.id=v.user_id WHERE v.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY u.id ORDER BY voucher_count DESC LIMIT 5");
$recentVouchers = $db->fetchAll("SELECT v.*, s.name as site_name, u.name as user_name FROM vouchers v LEFT JOIN sites s ON v.site_id=s.id LEFT JOIN users u ON v.user_id=u.id ORDER BY v.created_at DESC LIMIT 10");
$currentPage = 'dashboard';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('dashboard_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('dashboard_title') ?></h1>
<p class="page-subtitle"><?= __('dashboard_subtitle') ?></p>
</div>
<div class="refresh-indicator">
<span id="liveBadge" class="live-badge">
<span class="dot"></span>
<span id="liveStatus">DB</span>
</span>
<button onclick="refreshData('live')" class="btn btn-secondary btn-sm" id="refreshBtn">
<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>
</button>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_active_sites') ?></div>
<div class="stat-card-icon info"><i class="fas fa-location-dot"></i></div>
</div>
<div class="stat-card-value"><?= $stats['total_sites'] ?></div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_users') ?></div>
<div class="stat-card-icon accent"><i class="fas fa-users"></i></div>
</div>
<div class="stat-card-value"><?= $stats['total_users'] ?></div>
</div>
<div class="stat-card live">
<div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_valid') ?></div>
<div class="stat-card-icon success"><i class="fas fa-circle-check"></i></div>
</div>
<div class="stat-card-value valid" id="liveValid"><?= (int)($voucherStats['valid']??0) ?></div>
<div class="stat-card-sub" id="subValid"><?= $lastCronSync ? date('H:i', strtotime($lastCronSync)) : __('never') ?></div>
</div>
<div class="stat-card live">
<div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_used') ?></div>
<div class="stat-card-icon warning"><i class="fas fa-user-check"></i></div>
</div>
<div class="stat-card-value used" id="liveUsed"><?= (int)($voucherStats['used']??0) ?></div>
</div>
<div class="stat-card live">
<div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_expired') ?></div>
<div class="stat-card-icon danger"><i class="fas fa-circle-xmark"></i></div>
</div>
<div class="stat-card-value expired" id="liveExpired"><?= (int)($voucherStats['expired']??0) ?></div>
</div>
<div class="stat-card live">
<div class="stat-card-header">
<div class="stat-card-title"><?= __('dashboard_total') ?></div>
<div class="stat-card-icon"><i class="fas fa-ticket"></i></div>
</div>
<div class="stat-card-value" id="liveTotal"><?= (int)($voucherStats['total']??0) ?></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title"><?= __('dashboard_vouchers_per_site') ?></h2>
<span class="last-update" id="lastUpdate"><?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span>
</div>
<div class="card-body">
<div class="site-status" id="siteStatusContainer">
<?php foreach ($sites as $site):
$ss = $siteStats[$site['id']] ?? ['total'=>0,'valid'=>0,'used'=>0,'expired'=>0];
?>
<div class="site-status-card" id="site-<?= $site['id'] ?>">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">
<div class="site-status-name"><?= htmlspecialchars($site['name']) ?></div>
<span class="site-voucher-count <?= (int)$ss['total']===0 ? 'zero' : '' ?>" id="site-total-<?= $site['id'] ?>">
<?= (int)$ss['total'] ?>
</span>
</div>
<div class="site-status-stats">
<div class="site-stat"><div class="site-stat-value valid" id="site-valid-<?= $site['id'] ?>"><?= (int)$ss['valid'] ?></div><div class="site-stat-label"><?= __('status_valid') ?></div></div>
<div class="site-stat"><div class="site-stat-value used" id="site-used-<?= $site['id'] ?>"><?= (int)$ss['used'] ?></div><div class="site-stat-label"><?= __('status_used') ?></div></div>
<div class="site-stat"><div class="site-stat-value expired" id="site-expired-<?= $site['id'] ?>"><?= (int)$ss['expired'] ?></div><div class="site-stat-label"><?= __('status_expired') ?></div></div>
</div>
<div class="site-error" id="site-error-<?= $site['id'] ?>" style="display:none;"></div>
</div>
<?php endforeach; ?>
<?php if (empty($sites)): ?>
<div class="empty-state" style="grid-column:1/-1;">
<div class="empty-icon"><i class="fas fa-location-dot"></i></div>
<p><?= __('dashboard_no_data') ?></p>
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus"></i> <?= __('sites_add') ?></a>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title"><?= __('dashboard_trend') ?></h2></div>
<div class="card-body">
<div class="chart-container"><canvas id="voucherChart"></canvas></div>
</div>
</div>
<div class="two-col-grid">
<div class="card">
<div class="card-header"><h2 class="card-title"><?= __('dashboard_top_users') ?></h2></div>
<div class="card-body">
<?php if (empty($topUsers)): ?>
<div class="empty-state"><i class="fas fa-users"></i><p><?= __('dashboard_no_data') ?></p></div>
<?php else: ?>
<ul class="top-users-list">
<?php foreach ($topUsers as $u): ?>
<li>
<div class="user-info-row">
<div class="user-avatar-sm"><?= strtoupper(mb_substr($u['name'],0,1)) ?></div>
<div>
<div style="font-weight:600;font-size:14px;color:var(--text-primary);"><?= htmlspecialchars($u['name']) ?></div>
<div style="font-size:12px;color:var(--text-muted);"><?= htmlspecialchars($u['email']) ?></div>
</div>
</div>
<span class="user-count"><?= $u['voucher_count'] ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title"><?= __('dashboard_recent') ?></h2><a href="vouchers.php" class="btn btn-secondary btn-sm"><i class="fas fa-arrow-up-right-from-square"></i> Live</a></div>
<div class="card-body" style="padding:0;">
<?php if (empty($recentVouchers)): ?>
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
<?php else: ?>
<div class="table-container">
<table class="table">
<thead><tr><th><?= __('label_created') ?></th><th><?= __('label_code') ?></th><th><?= __('label_site') ?></th><th><?= __('label_status') ?></th></tr></thead>
<tbody>
<?php foreach ($recentVouchers as $v): ?>
<tr>
<td style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
<td><code><?= htmlspecialchars($v['voucher_code']) ?></code></td>
<td><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
<td>
<?php $st=$v['status']??'valid'; ?>
<span class="badge badge-<?= $st==='valid'?'success':($st==='used'?'warning':'danger') ?>"><?= __('status_'.$st) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</main>
<div id="toast-container"></div>
<script src="../assets/global.js"></script>
<script>
const rootStyles = getComputedStyle(document.documentElement);
const accentColor = rootStyles.getPropertyValue('--accent').trim();
const surfaceColor = rootStyles.getPropertyValue('--bg-card').trim();
const gridColor = rootStyles.getPropertyValue('--border-color').trim();
const mutedColor = rootStyles.getPropertyValue('--text-muted').trim();
const ctx = document.getElementById('voucherChart').getContext('2d');
const accentFill = ctx.createLinearGradient(0, 0, 0, 280);
accentFill.addColorStop(0, accentColor + '33');
accentFill.addColorStop(1, accentColor + '00');
const chartData = <?= json_encode($chartData) ?>;
new Chart(ctx, {
type: 'line',
data: {
labels: chartData.map(d => d.date),
datasets: [{
label: 'Vouchers',
data: chartData.map(d => d.count),
borderColor: accentColor,
backgroundColor: accentFill,
tension: 0.4, fill: true,
pointBackgroundColor: accentColor, pointBorderColor: surfaceColor,
pointBorderWidth: 2, pointRadius: 4, pointHoverRadius: 6
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, border: { display: false }, grid: { color: gridColor, drawTicks: false }, ticks: { stepSize: 1, color: mutedColor, padding: 10, font: { size: 11 } } },
x: { border: { display: false }, grid: { display: false }, ticks: { color: mutedColor, padding: 8, font: { size: 11 } } }
}
}
});
let isLoading = false;
async function refreshData(mode='db') {
if (isLoading) return;
isLoading = true;
const liveBadge = document.getElementById('liveBadge');
const liveStatus = document.getElementById('liveStatus');
const refreshBtn = document.getElementById('refreshBtn');
liveBadge.className = 'live-badge loading';
liveStatus.textContent = '...';
refreshBtn.disabled = true;
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const url = `index.php?ajax_stats=1${mode==='live'?'&sync=1':''}`;
const result = await fetch(url).then(r=>r.json());
if (result.success) {
document.getElementById('liveValid').textContent = result.total.valid;
document.getElementById('liveUsed').textContent = result.total.used;
document.getElementById('liveExpired').textContent = result.total.expired;
document.getElementById('liveTotal').textContent = result.total.total;
document.getElementById('subValid').textContent = mode==='live' ? result.timestamp : (result.last_sync||'');
result.sites.forEach(site => {
const totalBadge = document.getElementById(`site-total-${site.site_id}`);
const validEl = document.getElementById(`site-valid-${site.site_id}`);
const usedEl = document.getElementById(`site-used-${site.site_id}`);
const expiredEl = document.getElementById(`site-expired-${site.site_id}`);
const errorEl = document.getElementById(`site-error-${site.site_id}`);
if (!totalBadge) return;
if (site.error) {
totalBadge.innerHTML = '!'; totalBadge.className = 'site-voucher-count zero';
validEl.textContent = usedEl.textContent = expiredEl.textContent = '-';
errorEl.textContent = site.error; errorEl.style.display = 'block';
} else {
totalBadge.textContent = site.stats.total;
totalBadge.className = 'site-voucher-count' + (site.stats.total===0?' zero':'');
validEl.textContent = site.stats.valid;
usedEl.textContent = site.stats.used;
expiredEl.textContent = site.stats.expired;
errorEl.style.display = 'none';
}
});
document.getElementById('lastUpdate').textContent = mode==='live' ? result.timestamp : (result.last_sync||'');
liveBadge.className = 'live-badge'; liveStatus.textContent = mode==='live' ? '✓ Live' : 'DB';
} else { throw new Error(result.message); }
} catch(e) {
liveBadge.className = 'live-badge error'; liveStatus.textContent = 'Fehler';
}
isLoading = false;
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= __('dashboard_live_refresh') ?>';
}
</script>
</body>
</html>