Unifi-Voucher-Tool/admin/index.php
Friederich Loheide 7f1d93debd Barrierefreiheit und mobile Darstellung
- Kontrast: gedämpfter Text war mit 3,1:1 unter WCAG AA, jetzt 4,9:1
  (hell) bzw. 6,4:1 (dunkel)
- Sprungmarke „Zum Inhalt springen" in Admin-Shell und Voucher-Seite
- aria-label für alle reinen Icon-Schaltflächen (Theme, Menü, Abmelden,
  Zeilenaktionen), aria-current auf dem aktiven Navigationspunkt,
  role="group" für den Sprachumschalter
- 194 dekorative Icons mit aria-hidden versehen, damit Screenreader sie
  nicht vorlesen
- Toast-Container als aria-live-Bereich ausgezeichnet
- prefers-reduced-motion schaltet Animationen und Übergänge ab

Tabellen (Benutzer, Vouchers, Profile, Audit-Log, Dashboard, API-Keys)
werden unter 720 px zu Karten: die Spaltenüberschrift steht per
data-label vor dem Wert, statt horizontal zu scrollen.

Datums- und Zeitformat in der Voucher-Liste folgen jetzt der gewählten
Sprache statt fest de-DE.

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

347 lines
18 KiB
PHP
Raw Permalink 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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></i></div>
<p><?= __('dashboard_no_data') ?></p>
<a href="sites.php" class="btn btn-primary" style="margin-top:15px;"><i class="fas fa-plus" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></i><p><?= __('dashboard_no_vouchers') ?></p></div>
<?php else: ?>
<div class="table-container">
<table class="table table-stack">
<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 data-label="<?= __('label_created') ?>" style="font-size:12px;"><?= date('d.m H:i', strtotime($v['created_at'])) ?></td>
<td data-label="<?= __('label_code') ?>"><code><?= htmlspecialchars($v['voucher_code']) ?></code></td>
<td data-label="<?= __('label_site') ?>"><span class="badge badge-info"><?= htmlspecialchars($v['site_name']??'') ?></span></td>
<td data-label="<?= __('label_status') ?>">
<?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" role="status" aria-live="polite"></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" aria-hidden="true"></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" aria-hidden="true"></i> <?= __('dashboard_live_refresh') ?>';
}
</script>
</body>
</html>