Unifi-Voucher-Tool/admin/reports.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

175 lines
7.6 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/I18n.php';
$auth = new Auth();
$auth->requireAdmin();
I18n::init();
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
// Zeitraum (Tage)
$days = max(1, min(365, (int)($_GET['days'] ?? 30)));
// CSV-Export
if (isset($_GET['export'])) {
$auth->requireAdmin();
header('Content-Type: text/csv; charset=utf-8');
$fn = 'report-' . $_GET['export'] . '-' . date('Y-m-d') . '.csv';
header('Content-Disposition: attachment; filename="' . $fn . '"');
$out = fopen('php://output', 'w');
fprintf($out, "\xEF\xBB\xBF"); // UTF-8 BOM für Excel
if ($_GET['export'] === 'per_site') {
fputcsv($out, ['Site', 'Gesamt', 'Gültig', 'Verwendet', 'Abgelaufen']);
$rows = $db->fetchAll(
"SELECT s.name,
COUNT(v.id) total,
SUM(v.status='valid') valid,
SUM(v.status='used') used,
SUM(v.status='expired') expired
FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id
GROUP BY s.id ORDER BY total DESC"
);
foreach ($rows as $r) fputcsv($out, [$r['name'], (int)$r['total'], (int)$r['valid'], (int)$r['used'], (int)$r['expired']]);
} elseif ($_GET['export'] === 'per_user') {
fputcsv($out, ['Benutzer', 'E-Mail', 'Voucher erstellt']);
$rows = $db->fetchAll(
"SELECT u.name, u.email, COUNT(v.id) c FROM users u
LEFT JOIN vouchers v ON v.user_id=u.id GROUP BY u.id ORDER BY c DESC"
);
foreach ($rows as $r) fputcsv($out, [$r['name'], $r['email'], (int)$r['c']]);
} else { // daily
fputcsv($out, ['Datum', 'Erstellte Voucher']);
$rows = $db->fetchAll(
"SELECT DATE(created_at) d, COUNT(*) c FROM vouchers
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY DATE(created_at) ORDER BY d", [$days]
);
foreach ($rows as $r) fputcsv($out, [$r['d'], (int)$r['c']]);
}
fclose($out);
exit;
}
// Kennzahlen
$totals = $db->fetchOne(
"SELECT COUNT(*) total,
SUM(status='valid') valid, SUM(status='used') used, SUM(status='expired') expired
FROM vouchers"
);
$inPeriod = (int)($db->fetchOne(
"SELECT COUNT(*) c FROM vouchers WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)", [$days]
)['c'] ?? 0);
$perSite = $db->fetchAll(
"SELECT s.name,
COUNT(v.id) total, SUM(v.status='valid') valid, SUM(v.status='used') used, SUM(v.status='expired') expired
FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id GROUP BY s.id ORDER BY total DESC"
);
$perUser = $db->fetchAll(
"SELECT u.name, COUNT(v.id) c FROM users u LEFT JOIN vouchers v ON v.user_id=u.id
GROUP BY u.id HAVING c > 0 ORDER BY c DESC LIMIT 10"
);
$daily = $db->fetchAll(
"SELECT DATE(created_at) d, COUNT(*) c FROM vouchers
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY DATE(created_at) ORDER BY d", [$days]
);
$chartLabels = array_map(fn($r) => date('d.m', strtotime($r['d'])), $daily);
$chartData = array_map(fn($r) => (int)$r['c'], $daily);
$csrf = $auth->getCsrfToken();
$currentPage = 'reports';
$adminBase = '';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('rep_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::script('assets/vendor/chartjs/chart.umd.min.js', '../') ?>
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title"><?= __('rep_title') ?></h1>
<p class="page-subtitle"><?= __('rep_subtitle') ?></p>
</div>
</div>
<div class="toolbar no-print">
<form method="get" style="display:flex;gap:8px;align-items:center;">
<label style="margin:0;"><?= __('rep_period') ?></label>
<select class="input" name="days" onchange="this.form.submit()">
<?php foreach ([7,30,90,365] as $d): ?>
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> <?= __('rep_days') ?></option>
<?php endforeach; ?>
</select>
</form>
<a class="btn btn-secondary" href="?export=daily&days=<?= $days ?>"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_daily') ?></a>
<a class="btn btn-secondary" href="?export=per_site"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_site') ?></a>
<a class="btn btn-secondary" href="?export=per_user"><i class="fas fa-download" aria-hidden="true"></i> <?= __('rep_csv_user') ?></a>
<button class="btn btn-secondary" onclick="window.print()"><i class="fas fa-print" aria-hidden="true"></i> <?= __('rep_print') ?></button>
</div>
<div class="grid4">
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l"><?= __('rep_total') ?></div></div>
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l"><?= __('status_valid') ?></div></div>
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l"><?= __('status_used') ?></div></div>
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l"><?= str_replace('{days}', (string)$days, __('rep_in_period')) ?></div></div>
</div>
<div class="card">
<h2><?= str_replace('{days}', (string)$days, __('rep_chart_title')) ?></h2>
<canvas id="chart" height="90"></canvas>
</div>
<div class="card">
<h2><?= __('rep_per_site') ?></h2>
<table><tr><th><?= __('label_site') ?></th><th><?= __('label_total') ?></th><th><?= __('status_valid') ?></th><th><?= __('status_used') ?></th><th><?= __('status_expired') ?></th></tr>
<?php foreach ($perSite as $r): ?>
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
<?php endforeach; ?>
</table>
</div>
<div class="card">
<h2><?= __('rep_top_users') ?></h2>
<table><tr><th><?= __('label_user') ?></th><th><?= __('rep_col_created') ?></th></tr>
<?php foreach ($perUser as $r): ?>
<tr><td><?= htmlspecialchars($r['name'] ?? '') ?></td><td><?= (int)$r['c'] ?></td></tr>
<?php endforeach; ?>
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);"><?= __('rep_no_data') ?></td></tr><?php endif; ?>
</table>
</div>
</main>
<script src="../assets/global.js"></script>
<script>
const styles = getComputedStyle(document.documentElement);
const accent = styles.getPropertyValue('--accent').trim();
const grid = styles.getPropertyValue('--border-color').trim();
const muted = styles.getPropertyValue('--text-muted').trim();
new Chart(document.getElementById('chart'), {
type:'line',
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor: accent, backgroundColor: accent + '22', fill:true, tension:.35, pointRadius:0, pointHoverRadius:4, borderWidth:2 }] },
options:{
plugins:{legend:{display:false}},
scales:{
y:{ beginAtZero:true, border:{display:false}, grid:{color:grid, drawTicks:false}, ticks:{precision:0, color:muted, padding:10, font:{size:11}} },
x:{ border:{display:false}, grid:{display:false}, ticks:{color:muted, padding:8, font:{size:11}} }
}
}
});
</script>
</body>
</html>