Unifi-Voucher-Tool/admin/vouchers.php
Friederich Loheide 498c7e28e0
Some checks failed
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
CI / PHP Lint (pull_request) Has been cancelled
CI / PHP Lint-1 (pull_request) Has been cancelled
CI / Unit Tests & Static Analysis (pull_request) Has been cancelled
Redesign: gemeinsames Design-System für Frontend und Backend
Frontend, Login/Installer/Updater und der komplette Admin-Bereich nutzen
jetzt ein einziges Stylesheet (assets/global.css) statt pro Seite
dupliziertem Inline-CSS.

Design-System
- Tokens für Flächen, Text, Linien, Marke, Status, Radien, Schatten und
  Layout-Maße; Dark Mode ausschließlich über Tokens (keine !important-
  Overrides mehr)
- Komponenten: Buttons, Formularfelder, Cards, Tabellen, Badges, Alerts,
  Tabs, Pagination, Modals, Toasts, Statistik-Kacheln, Empty States
- Schrift Inter mit System-Fallback

Oberfläche
- Admin-Shell neu: durchgehende Sidebar mit Marke, gruppierter Navigation
  und Benutzerbereich; schlanke Topbar mit Breadcrumb
- Dashboard: ruhige KPI-Kacheln mit Icon-Chips, Charts an Theme-Farben
  gekoppelt
- Öffentliche Voucher-Seite: App-Topbar, klare Formularstruktur und
  Ticket-Darstellung des erstellten Codes inkl. QR-Code
- Login/Passwort/2FA: zweispaltiges Auth-Layout bzw. Fokus-Karten
- Updater und Wartungsmodus im gleichen Look (Wartungsseite bleibt
  bewusst eigenständig ohne externe Abhängigkeiten)
- Emoji-Icons in der UI durch Font-Awesome-Icons ersetzt

Nebenbei behoben
- Falscher SRI-Hash blockierte qrcode.min.js – QR-Codes wurden auf der
  Voucher-Seite und bei der 2FA-Einrichtung nie gerendert
- assets/global.css wurde in mehreren Admin-Seiten über einen falschen
  Pfad eingebunden ($adminBase = '' statt '../')
- TinyMCE lädt ohne API-Key jetzt die GPL-Variante von cdnjs – kein
  "valid API key required"-Banner mehr im Einstellungs-Editor
- Tabellen in Cards scrollen horizontal statt zu überlaufen

Screenshots in docs/screenshots neu erstellt, README aktualisiert (neuer
Abschnitt "Design-System", Version 2.5.0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:33:52 +00:00

410 lines
22 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/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();
// CSV-Export
if (isset($_GET['export_csv']) && isset($_GET['site_id'])) {
if (!$auth->validateCsrfToken($_GET['token'] ?? '')) { http_response_code(403); exit(__('error_csrf')); }
$siteId = (int)$_GET['site_id'];
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
if (!$site) { http_response_code(404); exit; }
$rows = $db->fetchAll("SELECT voucher_code,voucher_name,max_uses,expire_minutes,status,used_count,created_at,expires_at FROM vouchers WHERE site_id=? ORDER BY created_at DESC", [$siteId]);
$filename = 'vouchers_' . preg_replace('/[^a-z0-9]/i','_',$site['name']) . '_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output','w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($out,['Code','Name','Max. Geräte','Gültigkeit (Min)','Status','Genutzt','Erstellt','Läuft ab'],';');
foreach ($rows as $r) {
fputcsv($out,[$r['voucher_code'],$r['voucher_name'],$r['max_uses'],$r['expire_minutes'],$r['status'],$r['used_count'],$r['created_at'],$r['expires_at']??''],';');
}
fclose($out); exit;
}
// AJAX: Vouchers laden
if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
header('Content-Type: application/json');
try {
$siteId = (int)$_GET['site_id'];
$syncFirst = isset($_GET['sync']) && $_GET['sync']=='1';
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
if ($syncFirst) {
try {
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']);
$ctrl->syncVouchersToDatabase($db,$siteId);
$db->execute("INSERT INTO settings (setting_key,setting_value) VALUES ('last_cron_sync',NOW()) ON DUPLICATE KEY UPDATE setting_value=NOW()");
} catch (Exception $e) { error_log("Sync error: ".$e->getMessage()); }
}
$dbVouchers = $db->fetchAll("SELECT * FROM vouchers WHERE site_id=? ORDER BY created_at DESC", [$siteId]);
$vouchers = [];
foreach ($dbVouchers as $v) {
$vouchers[] = [
'_id' => $v['unifi_voucher_id'] ?? $v['id'],
'code' => str_replace('-','',$v['voucher_code']),
'formatted_code' => $v['voucher_code'],
'note' => $v['voucher_name'],
'quota' => (int)$v['max_uses'],
'used' => (int)($v['used_count']??0),
'duration' => (int)$v['expire_minutes'],
'create_time' => strtotime($v['created_at']),
'expire_time' => $v['expires_at'] ? strtotime($v['expires_at']) : 0,
'status' => $v['status']??'valid',
'db_id' => $v['id'],
];
}
$lastSync = $db->getSetting('last_cron_sync','');
echo json_encode(['success'=>true,'vouchers'=>$vouchers,'site_name'=>$site['name'],'count'=>count($vouchers),'synced'=>$syncFirst,'last_sync'=>$lastSync?date('d.m.Y H:i:s',strtotime($lastSync)):null]);
} catch (Exception $e) {
echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]);
}
exit;
}
// AJAX: Voucher löschen
if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST['site_id'])) {
header('Content-Type: application/json');
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { echo json_encode(['success'=>false,'message'=>__('error_csrf')]); exit; }
try {
$voucherId = $_POST['voucher_id'];
$siteId = (int)$_POST['site_id'];
$site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]);
if (!$site) { echo json_encode(['success'=>false,'message'=>__('error_site_not_found')]); exit; }
$ctrl = new UniFiController($site['unifi_controller_url'],$site['unifi_username'],Crypto::decrypt($site['unifi_password']),$site['site_id']);
if ($ctrl->deleteVoucher($voucherId)) {
$db->execute("DELETE FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$voucherId,$siteId]);
echo json_encode(['success'=>true,'message'=>'Voucher erfolgreich gelöscht!']);
} else {
echo json_encode(['success'=>false,'message'=>'Voucher konnte nicht gelöscht werden']);
}
} catch (Exception $e) {
echo json_encode(['success'=>false,'message'=>'Fehler: '.$e->getMessage()]);
}
exit;
}
// Voucher-Code per E-Mail (erneut) versenden
if (isset($_POST['ajax_resend']) && isset($_POST['voucher_id']) && isset($_POST['site_id'])) {
header('Content-Type: application/json');
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { echo json_encode(['success'=>false,'message'=>__('error_csrf')]); exit; }
require_once __DIR__ . '/../includes/Mailer.php';
$email = trim($_POST['email'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success'=>false,'message'=>'Ungültige E-Mail-Adresse']); exit; }
$siteId = (int)$_POST['site_id'];
$site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [$siteId]);
$v = $db->fetchOne("SELECT * FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$_POST['voucher_id'], $siteId]);
if (!$site || !$v) { echo json_encode(['success'=>false,'message'=>'Voucher nicht gefunden']); exit; }
$mailer = new Mailer();
$code = strpos($v['voucher_code'], '-') !== false ? $v['voucher_code'] : implode('-', str_split($v['voucher_code'], 5));
$ok = $mailer->sendVoucherEmail($email, $code, $site['name'], (int)$v['max_uses']);
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_resend', 'voucher', $v['id'], "Code an $email gesendet");
echo json_encode(['success'=>$ok, 'message'=>$ok ? 'E-Mail versendet.' : 'Versand fehlgeschlagen.']);
exit;
}
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
$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','');
$currentPage = 'vouchers';
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('vouchers_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?php include __DIR__ . '/../includes/admin_nav.php'; ?>
<div class="page-header">
<div>
<h1 class="page-title">
<?= __('vouchers_title') ?>
<span class="live-indicator"><span class="dot"></span>LIVE</span>
</h1>
<p class="page-subtitle"><?= __('vouchers_subtitle') ?></p>
</div>
</div>
<?php if (empty($sites)): ?>
<div class="no-sites-warning">
<i class="fas fa-triangle-exclamation" style="font-size:26px;margin-bottom:12px;display:block;"></i>
<h3><?= __('vouchers_no_sites') ?></h3>
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus"></i> <?= __('nav_sites') ?></a>
</div>
<?php else: ?>
<div class="card">
<div class="card-header">
<h2 class="card-title"><?= __('vouchers_select_site') ?></h2>
<span style="font-size:12px;color:var(--text-muted);"><?= __('vouchers_last_sync') ?>: <?= $lastCronSync ? date('d.m.Y H:i', strtotime($lastCronSync)) : __('never') ?></span>
</div>
<div class="card-body">
<div class="site-selector">
<select id="siteSelect" onchange="loadVouchers(false)">
<option value=""><?= __('vouchers_select_hint') ?></option>
<?php foreach ($sites as $site):
$ss = $siteStats[$site['id']] ?? ['total'=>0];
?>
<option value="<?= $site['id'] ?>" data-name="<?= htmlspecialchars($site['name']) ?>">
<?= htmlspecialchars($site['name']) ?> (<?= (int)$ss['total'] ?> Vouchers)
</option>
<?php endforeach; ?>
</select>
<input type="search" id="searchInput" class="search-bar" placeholder="<?= __('vouchers_search') ?>" oninput="filterAndRender()" style="display:none;">
<button id="refreshBtn" class="btn btn-secondary" onclick="loadVouchers(true)" disabled>
<i class="fas fa-sync-alt"></i> <?= __('btn_refresh') ?>
</button>
</div>
</div>
</div>
<div id="statsContainer" style="display:none;">
<div class="stats-grid">
<div class="stat-card"><div class="stat-label"><?= __('dashboard_total') ?></div><div class="stat-value" id="statTotal">0</div></div>
<div class="stat-card"><div class="stat-label"><?= __('status_valid') ?></div><div class="stat-value valid" id="statValid">0</div></div>
<div class="stat-card"><div class="stat-label"><?= __('status_used') ?></div><div class="stat-value used" id="statUsed">0</div></div>
<div class="stat-card"><div class="stat-label"><?= __('status_expired') ?></div><div class="stat-value expired" id="statExpired">0</div></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title" id="voucherListTitle">Vouchers</h2>
<a id="csvExportBtn" style="display:none;" class="btn btn-secondary btn-sm" href="#">
<i class="fas fa-download"></i> <?= __('btn_export_csv') ?>
</a>
</div>
<div class="card-body" style="padding:0;">
<div id="voucherContent">
<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= __('vouchers_select_hint') ?></p></div>
</div>
</div>
</div>
<?php endif; ?>
</main>
<div id="toast-container"></div>
<script src="../assets/global.js"></script>
<script>
const csrfToken = '<?= $auth->getCsrfToken() ?>';
let currentSiteId = null;
let allVouchers = [];
let currentFilter = 'all';
let currentPage = 1;
let searchQuery = '';
const PAGE_SIZE = 50;
async function loadVouchers(syncFirst=false) {
const select = document.getElementById('siteSelect');
const siteId = select.value;
const refreshBtn = document.getElementById('refreshBtn');
const searchInput = document.getElementById('searchInput');
if (!siteId) {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_select_hint')) ?></p></div>`;
document.getElementById('statsContainer').style.display = 'none';
searchInput.style.display = 'none';
refreshBtn.disabled = true;
return;
}
currentSiteId = siteId;
refreshBtn.disabled = false;
refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner"></i><span>${syncFirst ? 'Synchronisiere...' : 'Lade...'}</span></div>`;
try {
const result = await fetch(`vouchers.php?ajax_get_vouchers=1&site_id=${siteId}${syncFirst?'&sync=1':''}`).then(r=>r.json());
if (result.success) {
allVouchers = result.vouchers;
currentPage = 1;
searchQuery = '';
if (searchInput) { searchInput.style.display = ''; searchInput.value = ''; }
document.getElementById('voucherListTitle').textContent = `Vouchers ${result.site_name} (${result.count})`;
updateStats();
filterAndRender();
document.getElementById('statsContainer').style.display = 'block';
const csvBtn = document.getElementById('csvExportBtn');
csvBtn.style.display = 'inline-flex';
csvBtn.href = `vouchers.php?export_csv=1&site_id=${siteId}&token=${csrfToken}`;
if (syncFirst) showToast('success', '<?= addslashes(__('btn_refresh')) ?>', `${result.count} Vouchers geladen`);
} else {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>${result.message}</p></div>`;
document.getElementById('statsContainer').style.display = 'none';
}
} catch(e) {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-circle" style="color:var(--danger)"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
document.getElementById('statsContainer').style.display = 'none';
}
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> <?= addslashes(__('btn_refresh')) ?>';
}
function updateStats() {
document.getElementById('statTotal').textContent = allVouchers.length;
document.getElementById('statValid').textContent = allVouchers.filter(v=>v.status==='valid').length;
document.getElementById('statUsed').textContent = allVouchers.filter(v=>v.status==='used').length;
document.getElementById('statExpired').textContent = allVouchers.filter(v=>v.status==='expired').length;
}
function setFilter(filter) {
currentFilter = filter;
currentPage = 1;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter===filter));
filterAndRender();
}
function filterAndRender() {
searchQuery = (document.getElementById('searchInput')?.value||'').toLowerCase();
currentPage = 1;
renderVouchers();
}
function setPage(page) { currentPage = page; renderVouchers(); }
function renderVouchers() {
let vouchers = allVouchers;
if (currentFilter !== 'all') vouchers = vouchers.filter(v => v.status === currentFilter);
if (searchQuery) vouchers = vouchers.filter(v =>
(v.formatted_code||'').toLowerCase().includes(searchQuery) ||
(v.note||'').toLowerCase().includes(searchQuery)
);
const totalPages = Math.ceil(vouchers.length / PAGE_SIZE);
if (currentPage > totalPages && totalPages > 0) currentPage = totalPages;
const pageVouchers = vouchers.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE);
if (vouchers.length === 0) {
document.getElementById('voucherContent').innerHTML = `<div class="empty-state"><i class="fas fa-ticket-alt"></i><p><?= addslashes(__('vouchers_none')) ?></p></div>`;
return;
}
const validCnt = allVouchers.filter(v=>v.status==='valid').length;
const usedCnt = allVouchers.filter(v=>v.status==='used').length;
const expiredCnt = allVouchers.filter(v=>v.status==='expired').length;
let html = `<div style="padding:14px 22px;border-bottom:1px solid var(--border-color);display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<div class="filter-row">
<button class="filter-btn ${currentFilter==='all'?'active':''}" data-filter="all" onclick="setFilter('all')"><?= __('vouchers_filter_all') ?> (${allVouchers.length})</button>
<button class="filter-btn ${currentFilter==='valid'?'active':''}" data-filter="valid" onclick="setFilter('valid')"><?= __('vouchers_filter_valid') ?> (${validCnt})</button>
<button class="filter-btn ${currentFilter==='used'?'active':''}" data-filter="used" onclick="setFilter('used')"><?= __('vouchers_filter_used') ?> (${usedCnt})</button>
<button class="filter-btn ${currentFilter==='expired'?'active':''}" data-filter="expired" onclick="setFilter('expired')"><?= __('vouchers_filter_expired') ?> (${expiredCnt})</button>
</div>
</div>
<div class="table-container"><table class="table">
<thead><tr>
<th><?= __('label_created') ?></th>
<th><?= __('label_code') ?></th>
<th><?= __('label_note') ?></th>
<th><?= __('label_status') ?></th>
<th><?= __('label_usage') ?></th>
<th><?= __('label_expires') ?></th>
<th><?= __('label_actions') ?></th>
</tr></thead><tbody>`;
pageVouchers.forEach(v => {
const createDate = new Date(v.create_time*1000);
const remaining = v.status==='valid' && v.expire_time ? (() => {
const diff = v.expire_time - Math.floor(Date.now()/1000);
if (diff <= 0) return '';
const h = Math.floor(diff/3600), m = Math.floor((diff%3600)/60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
})() : '';
const usagePct = v.quota > 0 ? Math.min(100, (v.used/v.quota)*100) : 0;
const statusBadge = v.status==='valid'
? `<span class="badge badge-success"><i class="fas fa-check"></i> <?= __('status_valid') ?></span>`
: v.status==='used'
? `<span class="badge badge-warning"><i class="fas fa-user-check"></i> <?= __('status_used') ?></span>`
: `<span class="badge badge-danger"><i class="fas fa-times"></i> <?= __('status_expired') ?></span>`;
html += `<tr id="voucher-${v._id}">
<td><strong>${createDate.toLocaleDateString('de-DE')}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}</small></td>
<td><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','Kopiert!')" title="Kopieren" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
<td class="voucher-note" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
<td>${statusBadge}</td>
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
<td style="white-space:nowrap;">
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
</td>
</tr>`;
});
html += `</tbody></table></div>`;
if (totalPages > 1) {
html += `<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 22px;border-top:1px solid var(--border-color);">
<span style="font-size:13px;color:var(--text-muted);">${<?= json_encode(__('vouchers_page_of')) ?>
.replace('{current}',currentPage).replace('{total}',totalPages)} (${vouchers.length})</span>
<div style="display:flex;gap:5px;">
<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage-1})" ${currentPage<=1?'disabled':''}><i class="fas fa-chevron-left"></i></button>`;
for (let p=Math.max(1,currentPage-2); p<=Math.min(totalPages,currentPage+2); p++) {
html += `<button class="btn btn-sm ${p===currentPage?'btn-primary':'btn-secondary'}" onclick="setPage(${p})">${p}</button>`;
}
html += `<button class="btn btn-secondary btn-sm" onclick="setPage(${currentPage+1})" ${currentPage>=totalPages?'disabled':''}><i class="fas fa-chevron-right"></i></button>
</div></div>`;
}
document.getElementById('voucherContent').innerHTML = html;
}
function escapeHtml(t) { const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
async function resendVoucher(voucherId, code) {
const email = prompt('Code ' + code + ' senden an (E-Mail):');
if (!email) return;
const fd = new FormData();
fd.append('ajax_resend','1'); fd.append('voucher_id',voucherId);
fd.append('site_id', currentSiteId); fd.append('email', email);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch('vouchers.php', {method:'POST', body:fd});
const d = await r.json();
(window.showToast ? showToast(d.message, d.success?'success':'error') : alert(d.message));
} catch(e){ alert('Fehler: '+e.message); }
}
async function deleteVoucher(voucherId) {
if (!confirm('Voucher wirklich löschen?')) return;
const row = document.getElementById(`voucher-${voucherId}`);
if (row) row.classList.add('deleting');
try {
const fd = new FormData();
fd.append('ajax_delete','1'); fd.append('voucher_id',voucherId);
fd.append('site_id',currentSiteId); fd.append('csrf_token',csrfToken);
const result = await fetch('vouchers.php',{method:'POST',body:fd}).then(r=>r.json());
if (result.success) {
allVouchers = allVouchers.filter(v=>v._id!==voucherId);
updateStats(); filterAndRender();
showToast('success','<?= addslashes(__('btn_delete')) ?>',result.message);
} else {
if (row) row.classList.remove('deleting');
showToast('error','Fehler',result.message);
}
} catch(e) {
if (row) row.classList.remove('deleting');
showToast('error','Fehler','Unerwarteter Fehler');
}
}
</script>
</body>
</html>