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

412 lines
23 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/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;" aria-hidden="true"></i>
<h3><?= __('vouchers_no_sites') ?></h3>
<a href="sites.php" class="btn btn-primary" style="margin-top:20px;"><i class="fas fa-plus" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></i><p><?= __('vouchers_select_hint') ?></p></div>
</div>
</div>
</div>
<?php endif; ?>
</main>
<div id="toast-container" role="status" aria-live="polite"></div>
<script src="../assets/global.js"></script>
<script>
const csrfToken = '<?= $auth->getCsrfToken() ?>';
// Datums- und Zeitformat folgen der gewaehlten Sprache
const LOCALE = '<?= I18n::getLanguage() === 'en' ? 'en-GB' : 'de-DE' ?>';
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" aria-hidden="true"></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" aria-hidden="true"></i> ${syncFirst ? '<?= addslashes(__('btn_refresh')) ?>' : '<?= addslashes(__('btn_refresh')) ?>'}`;
document.getElementById('voucherContent').innerHTML = `<div class="loading"><i class="fas fa-spinner" aria-hidden="true"></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)" aria-hidden="true"></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)" aria-hidden="true"></i><p>Verbindungsfehler: ${e.message}</p></div>`;
document.getElementById('statsContainer').style.display = 'none';
}
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt" aria-hidden="true"></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" aria-hidden="true"></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 table-stack">
<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" aria-hidden="true"></i> <?= __('status_valid') ?></span>`
: v.status==='used'
? `<span class="badge badge-warning"><i class="fas fa-user-check" aria-hidden="true"></i> <?= __('status_used') ?></span>`
: `<span class="badge badge-danger"><i class="fas fa-times" aria-hidden="true"></i> <?= __('status_expired') ?></span>`;
html += `<tr id="voucher-${v._id}">
<td data-label="<?= __('label_created') ?>"><strong>${createDate.toLocaleDateString(LOCALE)}</strong><br><small style="color:var(--text-muted)">${createDate.toLocaleTimeString(LOCALE,{hour:'2-digit',minute:'2-digit'})}</small></td>
<td data-label="<?= __('label_code') ?>"><code onclick="copyToClipboard('${escapeHtml(v.formatted_code||'')}','<?= __('js_copied') ?>')" title="<?= __('js_copy') ?>" style="cursor:pointer">${escapeHtml(v.formatted_code||'')}</code></td>
<td class="voucher-note" data-label="<?= __('label_note') ?>" title="${escapeHtml(v.note||'-')}">${escapeHtml(v.note||'-')}</td>
<td data-label="<?= __('label_status') ?>">${statusBadge}</td>
<td data-label="<?= __('label_usage') ?>"><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 data-label="<?= __('label_expires') ?>">${remaining?`<span style="color:var(--success)"><i class="fas fa-clock" aria-hidden="true"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} <?= __('label_minutes_short') ?></small></td>
<td data-label="<?= __('label_actions') ?>" style="white-space:nowrap;">
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="<?= __('vouchers_resend') ?>" aria-label="<?= __('vouchers_resend') ?>"><i class="fas fa-envelope" aria-hidden="true"></i></button>
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger-soft btn-sm" title="<?= __('btn_delete') ?>" aria-label="<?= __('btn_delete') ?>"><i class="fas fa-trash" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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('<?= __('js_confirm_delete_voucher') ?>')) 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>