Quelltext des aktuellen Standes
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => $current_route, 'full_width' => true, 'extra_css' => ['/assets/css/ssd.css']]); ?>
<script src="<?= $this->asset('/assets/js/ssd.js') ?>"></script>
<script src="<?= $this->asset('/assets/js/voucher-label.js') ?>"></script>
<script>window.VoucherLabelConfig = <?= json_encode([
'printMethod' => $voucher_label['print_method'] ?? 'both',
'logoUrl' => $voucher_label['logo_url'] ?? '',
'geo' => $voucher_label['geo'] ?? [],
'apiBase' => '/unifi/vouchers/dymo-label',
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;</script>
<style>
.unifi-wrap { display: flex; flex-direction: column; }
.vc-grid { display: grid; grid-template-columns: 360px 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 900px) { .vc-grid { grid-template-columns: 1fr; } }
/* ---- Card ---- */
.vc-card { background: var(--color-bg); border: 1px solid var(--gray-200); border-radius:0.25rem; overflow: hidden; }
.vc-card-header { padding: 1rem 1.25rem; background: var(--slate-50); border-bottom: 1px solid var(--gray-200); font-weight: 600; font-size: .95rem; display:flex;align-items:center;gap:.5rem; }
.vc-card-body { padding: 1.25rem; }
/* ---- Formular ---- */
/* ---- Preset-Buttons ---- */
.vc-presets { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
.vc-preset { padding: .3rem .7rem; border: 1px solid var(--gray-300); border-radius: .35rem; font-size: .8rem; cursor: pointer; background: var(--gray-50); color: var(--gray-700); }
.vc-preset:hover, .vc-preset.active { background: var(--color-primary); color: var(--color-on-accent); border-color: var(--color-primary-dark); }
/* ---- Karte mit Kopfzeile + Tabelle (modul-spezifisch) ---- */
.vc-table-wrap { background: var(--color-bg); border: 1px solid var(--gray-200); border-radius:0.25rem; overflow: hidden; }
/* ---- Code-Badge ---- */
.vc-code { font-family: ui-monospace, monospace; font-size: .88rem; background: var(--gray-100); padding: .2rem .5rem; border-radius: .3rem; letter-spacing: .05em; }
.vc-copy-btn.copied { background: var(--green-100); border-color: var(--green-300); color: var(--green-800); }
/* Searchable Dropdown: gemeinsame Basis in /assets/css/ssd.css */
.ssd__trigger { padding: .32rem .75rem; min-width: 180px; }
/* ---- Neue Vouchers ---- */
.vc-new-badge { background: var(--green-100); color: var(--green-800); font-size: .72rem; padding: .1rem .45rem; border-radius: 9999px; margin-left: .4rem; font-weight: 600; }
/* ---- Toast ---- */
.vc-toast { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 10000; display: flex; flex-direction: column; gap: .5rem; }
.vc-toast-item { padding: .7rem 1.15rem; border-radius: .5rem; font-size: .85rem; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,.15); animation: vcSlideIn .25s ease; max-width: 360px; }
.vc-toast-item.ok { background: var(--emerald-600); color: var(--color-on-accent); }
.vc-toast-item.err { background: var(--red-600); color: var(--color-on-accent); }
@keyframes vcSlideIn { from { transform: translateX(120%); } to { transform: translateX(0); } }
/* ---- Modal: Basis aus app.css; hier Zustands-Toggle, Ebene und Breite ---- */
.vc-modal-backdrop { display: none; z-index: 10001; }
.vc-modal-backdrop.open { display: flex; }
.vc-modal-backdrop .modal-card { max-width: 420px; }
/* ---- Diagnose ---- */
.vc-diag { margin-top: .75rem; }
.vc-diag pre { background: var(--slate-900); color: var(--slate-200); padding: .75rem; border-radius: .4rem; font-size: .72rem; overflow: auto; max-height: 260px; }
</style>
<div class="unifi-wrap">
<div class="content-header">
<div>
<h1><?= $this->te('UniFi Voucher') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item"><a href="/unifi"><?= $this->te('UniFi-Integration') ?></a></li>
<li class="breadcrumb-item active"><?= $this->te('Voucher') ?></li>
</ol>
</div>
<div class="content-header-actions">
<a href="/unifi" class="btn btn-default btn-sm"><?= $this->te('← UniFi Übersicht') ?></a>
</div>
</div>
<div class="page-body">
<?php if ($configured && !empty($siteRows)):
$firstSiteId = $siteRows[0]['site_id'];
$firstSiteName = htmlspecialchars($siteRows[0]['site_name'] ?: $siteRows[0]['site_id']);
?>
<div class="card card-outline card-primary mb-3">
<div class="filter-card-body">
<div style="display:flex;gap:.6rem;align-items:center;flex-wrap:wrap;">
<div class="ssd" id="vc-site-ssd" data-ssd-fixed>
<div class="ssd__trigger" onclick="ssdToggle('vc-site-ssd')">
<span class="ssd__value" id="vc-site-ssd-label">🌐 <?= $firstSiteName ?></span>
<span class="ssd__arrow">▼</span>
</div>
<div class="ssd__panel" style="display:none">
<input class="ssd__search" placeholder="<?= $this->te('Site suchen…') ?>" oninput="ssdFilter('vc-site-ssd', this.value)">
<div class="ssd__list">
<?php foreach ($siteRows as $site):
$vCount = count($vouchersBySite[$site['site_id']] ?? []);
$label = htmlspecialchars($site['site_name'] ?: $site['site_id'])
. ($vCount > 0 ? ' (' . $vCount . ')' : '');
?>
<div class="ssd__item <?= $site['site_id'] === $firstSiteId ? 'selected' : '' ?>"
data-value="<?= htmlspecialchars($site['site_id']) ?>"
data-name="<?= htmlspecialchars($site['site_name'] ?: $site['site_id']) ?>">
🌐 <?= $label ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<?php if (!$configured): ?>
<div style="background:var(--color-bg);border:1px solid var(--gray-200);border-radius:0.25rem;padding:2rem;text-align:center;color:var(--gray-500);">
<?= $this->t('<span style="font-size:2.5rem;display:block;margin-bottom:.75rem">⚙</span> <strong>UniFi noch nicht konfiguriert.</strong><br> Bitte zuerst die <a href="/admin/settings/unifi" style="color:var(--color-primary);">Admin-Einstellungen</a> ausfüllen.') ?>
</div>
<?php elseif (empty($siteRows)): ?>
<div style="background:var(--color-bg);border:1px solid var(--gray-200);border-radius:0.25rem;padding:2rem;text-align:center;color:var(--gray-500);">
<?= $this->t('<span style="font-size:2.5rem;display:block;margin-bottom:.75rem">🎪</span> <strong>Noch keine Sites bekannt.</strong><br> Klicke auf der <a href="/unifi" style="color:var(--color-primary);">UniFi-Übersicht</a> auf „Jetzt aktualisieren", um die Geräte zu synchronisieren.') ?>
</div>
<?php else: ?>
<?php
function fmtVoucherCode(string $code): string {
$c = str_replace(['-', ' '], '', $code);
return strlen($c) >= 10 ? substr($c, 0, 5) . '-' . substr($c, 5) : $code;
}
?>
<?php foreach ($siteRows as $site):
$siteId = $site['site_id'];
$vouchers = $vouchersBySite[$siteId] ?? [];
$lastFetched = $lastFetchedBySite[$siteId] ?? null;
?>
<div class="vc-site-panel" id="panel-<?= htmlspecialchars($siteId) ?>"
style="<?= $siteId !== $firstSiteId ? 'display:none' : '' ?>">
<div class="vc-grid">
<!-- Linke Seite: Formular -->
<div class="vc-card">
<div class="vc-card-header"><?= $this->te('🎪 Neuen Voucher erstellen') ?></div>
<div class="vc-card-body">
<!-- Gültigkeitsdauer -->
<div class="form-group">
<label><?= $this->te('Gültigkeitsdauer') ?></label>
<div class="vc-presets">
<button type="button" class="vc-preset" onclick="setExpire(60, this)"><?= $this->te('1 Stunde') ?></button>
<button type="button" class="vc-preset" onclick="setExpire(240, this)"><?= $this->te('4 Stunden') ?></button>
<button type="button" class="vc-preset active" onclick="setExpire(1440, this)"><?= $this->te('1 Tag') ?></button>
<button type="button" class="vc-preset" onclick="setExpire(10080, this)"><?= $this->te('7 Tage') ?></button>
<button type="button" class="vc-preset" onclick="setExpire(43200, this)"><?= $this->te('30 Tage') ?></button>
</div>
<input type="number" class="form-control form-control-sm" id="expire-<?= htmlspecialchars($siteId) ?>" value="1440" min="1" placeholder="<?= $this->te('Minuten') ?>" oninput="clearPreset(this)">
<div class="form-hint"><?= $this->te('Gültigkeitsdauer in Minuten') ?></div>
</div>
<!-- Anzahl -->
<div class="form-group">
<label for="n- "><?= $this->te('Anzahl Vouchers') ?></label>
<input type="number" class="form-control form-control-sm" id="n-<?= htmlspecialchars($siteId) ?>" value="1" min="1" max="100">
<div class="form-hint"><?= $this->te('1 – 100 Vouchers auf einmal erstellen') ?></div>
</div>
<!-- Max. Nutzung -->
<div class="form-group">
<label for="quota- "><?= $this->te('Max. Nutzungen pro Voucher') ?></label>
<select class="form-control form-control-sm" id="quota-<?= htmlspecialchars($siteId) ?>">
<option value="1"><?= $this->te('1× (Einmalig)') ?></option>
<option value="0"><?= $this->te('Unbegrenzt') ?></option>
<option value="2">2×</option>
<option value="5">5×</option>
<option value="10">10×</option>
</select>
</div>
<!-- Bandbreite -->
<div class="form-group">
<label for="down- "><?= $this->te('Download-Limit (Mbps, 0 = unbegrenzt)') ?></label>
<input type="number" class="form-control form-control-sm" id="down-<?= htmlspecialchars($siteId) ?>" value="0" min="0">
</div>
<div class="form-group">
<label for="up- "><?= $this->te('Upload-Limit (Mbps, 0 = unbegrenzt)') ?></label>
<input type="number" class="form-control form-control-sm" id="up-<?= htmlspecialchars($siteId) ?>" value="0" min="0">
</div>
<!-- Notiz -->
<div class="form-group">
<label><?= $this->te('Notiz (optional)') ?></label>
<input type="text" id="note-<?= htmlspecialchars($siteId) ?>" maxlength="64" placeholder="<?= $this->te('z.B. Gast-Konferenz') ?>">
</div>
<button class="btn btn-primary btn-block vc-submit" onclick="createVoucher('<?= htmlspecialchars($siteId) ?>')">
<?= $this->te('➕ Voucher erstellen') ?>
</button>
</div>
</div>
<!-- Rechte Seite: Voucher-Liste -->
<div>
<div id="new-vouchers-<?= htmlspecialchars($siteId) ?>"></div>
<div class="vc-table-wrap">
<!-- Kopfzeile mit Aktualisieren-Button -->
<div style="display:flex;align-items:center;gap:.6rem;padding:.65rem 1rem;background:var(--gray-50);border-bottom:1px solid var(--gray-200);">
<span style="font-size:.82rem;font-weight:600;color:var(--gray-700);flex:1">
<?= $this->te('Gespeicherte Vouchers') ?>
<span id="vccount-<?= htmlspecialchars($siteId) ?>"
style="font-weight:400;color:var(--gray-500);margin-left:.35rem">(<?= count($vouchers) ?>)</span>
</span>
<?php if ($lastFetched): ?>
<span style="font-size:.75rem;color:var(--gray-400)" id="vclast-<?= htmlspecialchars($siteId) ?>">
<?= $this->te('Stand: {zeitpunkt}', ['zeitpunkt' => date('d.m.Y H:i', strtotime($lastFetched))]) ?>
</span>
<?php else: ?>
<span style="font-size:.75rem;color:var(--gray-400)" id="vclast-<?= htmlspecialchars($siteId) ?>"><?= $this->te('Noch nicht abgerufen') ?></span>
<?php endif; ?>
<button class="btn btn-xs btn-default vc-copy-btn" id="vcrefresh-<?= htmlspecialchars($siteId) ?>"
onclick="refreshVouchers('<?= htmlspecialchars($siteId) ?>')">
<?= $this->te('↻ Aktualisieren') ?>
</button>
<button class="btn btn-xs btn-default vc-copy-btn" onclick="copyAllCodes('<?= htmlspecialchars($siteId) ?>')"
title="<?= $this->te('Alle Codes in die Zwischenablage') ?>"><?= $this->te('📋 Codes') ?></button>
<button class="btn btn-xs btn-default vc-copy-btn" onclick="exportCsv('<?= htmlspecialchars($siteId) ?>')"
title="<?= $this->te('Als CSV exportieren') ?>"><?= $this->te('⬇ CSV') ?></button>
<button class="btn btn-xs btn-default vc-copy-btn" onclick="printVouchers('<?= htmlspecialchars($siteId) ?>')"
title="<?= $this->te('Druckansicht der gültigen Vouchers') ?>"><?= $this->te('🖨 Drucken') ?></button>
<button class="btn btn-xs btn-default vc-copy-btn" onclick="diagnoseVouchers('<?= htmlspecialchars($siteId) ?>')"
title="<?= $this->te('Technische Diagnose der Voucher-Endpunkte') ?>"><?= $this->te('🔧 Diagnose') ?></button>
<button class="btn btn-xs btn-danger" id="vcdelall-<?= htmlspecialchars($siteId) ?>"
onclick="deleteAllVouchers('<?= htmlspecialchars($siteId) ?>')"
<?= empty($vouchers) ? 'style="display:none"' : '' ?>>
<?= $this->te('🗑 Alle löschen') ?>
</button>
</div>
<div class="vc-diag" id="vcdiag-<?= htmlspecialchars($siteId) ?>" style="display:none;padding:0 1rem 1rem"></div>
<?php if (empty($vouchers)): ?>
<div class="empty-state" id="vctbody-wrap-<?= htmlspecialchars($siteId) ?>">
<?= $this->te('🎪 Noch keine Vouchers – klicke „Aktualisieren" um die Daten von UniFi zu laden.') ?>
</div>
<?php else: ?>
<div id="vctbody-wrap-<?= htmlspecialchars($siteId) ?>">
<table class="data-table">
<thead>
<tr>
<th scope="col"><?= $this->te('Code') ?></th>
<th scope="col"><?= $this->te('Notiz') ?></th>
<th scope="col"><?= $this->te('Erstellt') ?></th>
<th scope="col"><?= $this->te('Läuft ab') ?></th>
<th scope="col"><?= $this->te('Nutzungen') ?></th>
<th scope="col"><?= $this->te('Status') ?></th>
<th scope="col"></th>
</tr>
</thead>
<tbody id="voucher-tbody-<?= htmlspecialchars($siteId) ?>">
<?php foreach ($vouchers as $v):
$isExpired = $v['expires_at'] && strtotime($v['expires_at']) < time();
$usedUp = $v['quota'] > 0 && $v['used'] >= $v['quota'];
?>
<tr id="vcrow-<?= htmlspecialchars($v['voucher_id']) ?>">
<td>
<span class="vc-code"><?= htmlspecialchars(fmtVoucherCode($v['code'])) ?></span>
<button aria-label="<?= $this->te('Kopieren') ?>" class="btn btn-xs btn-default vc-copy-btn" onclick="copyCode(this, '<?= htmlspecialchars(fmtVoucherCode($v['code'])) ?>')">📋</button>
</td>
<td style="color:var(--gray-500)"><?= htmlspecialchars($v['note'] ?: '–') ?></td>
<td style="font-size:.78rem;color:var(--gray-500)">
<?= $v['created_at'] ? htmlspecialchars(date('d.m.Y H:i', strtotime($v['created_at']))) : '–' ?>
</td>
<td style="font-size:.78rem;<?= $isExpired ? 'color:var(--red-600)' : 'color:var(--gray-500)' ?>">
<?= $v['expires_at'] ? htmlspecialchars(date('d.m.Y H:i', strtotime($v['expires_at']))) : '–' ?>
</td>
<td style="font-size:.82rem">
<?= (int)$v['used'] ?> / <?= $v['quota'] > 0 ? (int)$v['quota'] : '∞' ?>
</td>
<td>
<?php if ($isExpired): ?>
<span style="font-size:.78rem;color:var(--red-600)"><?= $this->te('✕ Abgelaufen') ?></span>
<?php elseif ($usedUp): ?>
<span style="font-size:.78rem;color:var(--amber-600)"><?= $this->te('⚠ Aufgebraucht') ?></span>
<?php else: ?>
<span style="font-size:.78rem;color:var(--emerald-600)"><?= $this->te('✓ Gültig') ?></span>
<?php endif; ?>
</td>
<td style="text-align:right;white-space:nowrap">
<span class="vc-print-slot" data-code="<?= htmlspecialchars(fmtVoucherCode($v['code'])) ?>"></span>
<button class="btn btn-xs btn-danger" title="<?= $this->te('Voucher löschen') ?>"
onclick="deleteVoucher('<?= htmlspecialchars($siteId) ?>', '<?= htmlspecialchars($v['voucher_id']) ?>', this)">🗑</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div><!-- /vc-grid -->
</div><!-- /vc-site-panel -->
<?php endforeach; ?>
<?php endif; ?>
</div><!-- /page-body -->
</div><!-- /unifi-wrap -->
<input type="hidden" id="vc-csrf" value="<?= $this->csrf() ?>">
<!-- Toast-Container -->
<div class="vc-toast" id="vc-toast-container"></div>
<!-- Bestätigungs-Modal -->
<div class="modal-overlay vc-modal-backdrop" id="vc-modal">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="vc-modal-title">
<div class="modal-header"><h2 id="vc-modal-title"><?= $this->te('Bestätigung') ?></h2></div>
<div class="modal-body" id="vc-modal-body"><?= $this->te('Sind Sie sicher?') ?></div>
<div class="modal-footer">
<button type="button" class="btn btn-default" id="vc-modal-cancel"><?= $this->te('Abbrechen') ?></button>
<button type="button" class="btn btn-danger" id="vc-modal-ok"><?= $this->te('Löschen') ?></button>
</div>
</div>
</div>
<script>
(function() {
var csrf = document.getElementById('vc-csrf')?.value || '';
var activePreset = null;
// ---- Toast ----
function toast(msg, type) {
var c = document.getElementById('vc-toast-container');
if (!c) return;
var el = document.createElement('div');
el.className = 'vc-toast-item ' + (type === 'ok' ? 'ok' : 'err');
el.textContent = msg;
c.appendChild(el);
setTimeout(function() { el.remove(); }, 4000);
}
// ---- Bestätigungs-Modal (Promise-basiert, ersetzt confirm()) ----
function confirmModal(opts) {
return new Promise(function(resolve) {
var modal = document.getElementById('vc-modal');
var okBtn = document.getElementById('vc-modal-ok');
var cancel = document.getElementById('vc-modal-cancel');
document.getElementById('vc-modal-title').textContent = opts.title || 'Bestätigung';
document.getElementById('vc-modal-body').textContent = opts.message || 'Sind Sie sicher?';
okBtn.textContent = opts.okLabel || 'Löschen';
modal.classList.add('open');
function cleanup(result) {
modal.classList.remove('open');
okBtn.removeEventListener('click', onOk);
cancel.removeEventListener('click', onCancel);
modal.removeEventListener('click', onBackdrop);
resolve(result);
}
function onOk() { cleanup(true); }
function onCancel() { cleanup(false); }
function onBackdrop(e) { if (e.target === modal) cleanup(false); }
okBtn.addEventListener('click', onOk);
cancel.addEventListener('click', onCancel);
modal.addEventListener('click', onBackdrop);
});
}
// Searchable Dropdown: Mechanik liegt in /assets/js/ssd.js.
// Hier nur die seitenspezifische Reaktion: passendes Site-Panel zeigen.
document.addEventListener('ssd:select', function(e) {
if (!e.detail || e.detail.id !== 'vc-site-ssd') return;
var siteId = e.detail.value;
document.querySelectorAll('.vc-site-panel').forEach(function(p) { p.style.display = 'none'; });
var target = document.getElementById('panel-' + siteId);
if (target) target.style.display = '';
});
// Site-Auswahl über data-Attribute statt Inline-onclick mit interpoliertem
// Namen: Der Name wird beim Klick client-seitig escaped, bevor er als Label
// gesetzt wird.
var vcSiteSsd = document.getElementById('vc-site-ssd');
if (vcSiteSsd) {
vcSiteSsd.addEventListener('click', function(e) {
var item = e.target.closest('.ssd__item');
if (!item || !vcSiteSsd.contains(item)) return;
var val = item.getAttribute('data-value');
var name = item.getAttribute('data-name') || val;
window.ssdSelect('vc-site-ssd', val, '🌐 ' + escH(name));
});
}
// ---- Preset-Buttons ----
window.setExpire = function(minutes, btn) {
if (activePreset) activePreset.classList.remove('active');
btn.classList.add('active');
activePreset = btn;
var input = btn.closest('.vc-card-body')?.querySelector('input[id^="expire-"]');
if (input) input.value = minutes;
};
window.clearPreset = function(input) {
if (activePreset) activePreset.classList.remove('active');
activePreset = null;
};
// ---- Code kopieren ----
window.copyCode = function(btn, code) {
navigator.clipboard.writeText(code).then(function() {
btn.textContent = '✓ Kopiert';
btn.classList.add('copied');
setTimeout(function() {
btn.innerHTML = '📋';
btn.classList.remove('copied');
}, 2000);
});
};
// ---- Vouchers von UniFi laden (Aktualisieren) ----
window.refreshVouchers = function(siteId) {
var btn = document.getElementById('vcrefresh-' + siteId);
if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
var body = new URLSearchParams({ _csrf: csrf, site_id: siteId });
fetch('/unifi/vouchers/refresh', { method: 'POST', body: body })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
renderVoucherTable(siteId, d.vouchers);
var lastEl = document.getElementById('vclast-' + siteId);
if (lastEl) lastEl.textContent = 'Stand: ' + d.fetched;
setCount(siteId, d.count);
toast(d.count + ' Vouchers geladen', 'ok');
} else {
toast('Fehler beim Laden: ' + (d.error || 'Unbekannt'), 'err');
}
})
.catch(function(e) { toast('Netzwerkfehler: ' + e.message, 'err'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = '↻ Aktualisieren'; }
});
};
function renderVoucherTable(siteId, vouchers) {
var wrap = document.getElementById('vctbody-wrap-' + siteId);
if (!wrap) return;
if (!vouchers || vouchers.length === 0) {
wrap.innerHTML = '<div class="empty-state">🎪 Keine Vouchers gefunden.</div>';
return;
}
var now = Date.now() / 1000;
var rows = vouchers.map(function(v) {
var isExpired = v.expires_at && (new Date(v.expires_at).getTime() / 1000) < now;
var usedUp = v.quota > 0 && v.used >= v.quota;
var statusHtml = isExpired
? '<span style="font-size:.78rem;color:#dc2626">✕ Abgelaufen</span>'
: usedUp
? '<span style="font-size:.78rem;color:#d97706">⚠ Aufgebraucht</span>'
: '<span style="font-size:.78rem;color:#059669">✓ Gültig</span>';
var created = v.created_at ? new Date(v.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '–';
var expires = v.expires_at ? new Date(v.expires_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '–';
var quota = v.quota > 0 ? v.quota : '∞';
var displayCode = fmtCode(v.code);
return '<tr id="vcrow-' + escH(v.voucher_id) + '">'
+ '<td><span class="vc-code">' + escH(displayCode) + '</span>'
+ ' <button class="btn btn-xs btn-default vc-copy-btn" onclick="copyCode(this,\'' + escH(displayCode) + '\')">📋</button></td>'
+ '<td style="color:#6b7280">' + escH(v.note || '–') + '</td>'
+ '<td style="font-size:.78rem;color:#6b7280">' + created + '</td>'
+ '<td style="font-size:.78rem;color:' + (isExpired ? '#dc2626' : '#6b7280') + '">' + expires + '</td>'
+ '<td style="font-size:.82rem">' + v.used + ' / ' + quota + '</td>'
+ '<td>' + statusHtml + '</td>'
+ '<td style="text-align:right;white-space:nowrap">' + (window.VoucherLabel ? VoucherLabel.buttons(displayCode) : '') + ' <button class="btn btn-xs btn-danger" title="Voucher löschen" onclick="deleteVoucher(\'' + escH(siteId) + '\',\'' + escH(v.voucher_id) + '\', this)">🗑</button></td>'
+ '</tr>';
}).join('');
wrap.innerHTML = '<table class="data-table"><thead><tr>'
+ '<th>Code</th><th>Notiz</th><th>Erstellt</th><th>Läuft ab</th><th>Nutzungen</th><th>Status</th><th></th>'
+ '</tr></thead><tbody id="voucher-tbody-' + escH(siteId) + '">' + rows + '</tbody></table>';
}
// ---- Einzelnen Voucher löschen ----
window.deleteVoucher = function(siteId, voucherId, btn) {
confirmModal({ title: 'Voucher löschen', message: 'Diesen Voucher wirklich löschen?' }).then(function(ok) {
if (!ok) return;
if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
var body = new URLSearchParams({ _csrf: csrf, site_id: siteId, voucher_id: voucherId });
fetch('/unifi/vouchers/delete', { method: 'POST', body: body })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
var row = document.getElementById('vcrow-' + voucherId);
if (row) row.remove();
adjustCount(siteId, -1);
toast('Voucher gelöscht', 'ok');
} else {
toast('Löschen fehlgeschlagen: ' + (d.error || 'Unbekannt'), 'err');
if (btn) { btn.disabled = false; btn.innerHTML = '🗑'; }
}
})
.catch(function(e) {
toast('Netzwerkfehler: ' + e.message, 'err');
if (btn) { btn.disabled = false; btn.innerHTML = '🗑'; }
});
});
};
// ---- Alle Vouchers einer Site löschen ----
window.deleteAllVouchers = function(siteId) {
confirmModal({
title: 'Alle Vouchers löschen',
message: 'Wirklich ALLE Vouchers dieser Site löschen? Das kann nicht rückgängig gemacht werden.',
okLabel: 'Alle löschen'
}).then(function(ok) {
if (!ok) return;
var btn = document.getElementById('vcdelall-' + siteId);
if (btn) { btn.disabled = true; btn.textContent = '⏳ Lösche…'; }
var body = new URLSearchParams({ _csrf: csrf, site_id: siteId });
fetch('/unifi/vouchers/delete-all', { method: 'POST', body: body })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
renderVoucherTable(siteId, []);
setCount(siteId, 0);
toast(d.deleted + ' gelöscht' + (d.errors > 0 ? ', ' + d.errors + ' fehlgeschlagen' : ''), d.errors > 0 ? 'err' : 'ok');
} else {
toast('Löschen fehlgeschlagen: ' + (d.error || 'Unbekannt'), 'err');
}
})
.catch(function(e) { toast('Netzwerkfehler: ' + e.message, 'err'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = '🗑 Alle löschen'; }
});
});
};
// ---- Diagnose ----
window.diagnoseVouchers = function(siteId) {
var box = document.getElementById('vcdiag-' + siteId);
if (!box) return;
if (box.style.display !== 'none' && box.dataset.loaded === '1') {
box.style.display = 'none';
box.dataset.loaded = '0';
return;
}
box.style.display = 'block';
box.innerHTML = '<div style="color:#6b7280;font-size:.8rem">Lade Diagnose…</div>';
fetch('/unifi/vouchers/diagnose?site_id=' + encodeURIComponent(siteId), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(d) {
box.dataset.loaded = '1';
box.innerHTML = '<pre>' + escH(JSON.stringify(d, null, 2)) + '</pre>';
})
.catch(function(e) {
box.innerHTML = '<div style="color:#dc2626;font-size:.8rem">Fehler: ' + escH(e.message) + '</div>';
});
};
// ---- Helfer: Zeilen der aktuellen Tabelle als Objekte ----
function collectRows(siteId) {
var tbody = document.getElementById('voucher-tbody-' + siteId);
if (!tbody) return [];
var out = [];
tbody.querySelectorAll('tr').forEach(function(tr) {
var codeEl = tr.querySelector('.vc-code');
if (!codeEl) return;
var c = tr.querySelectorAll('td');
out.push({
code: codeEl.textContent.trim(),
note: c[1] ? c[1].textContent.trim() : '',
created: c[2] ? c[2].textContent.trim() : '',
expires: c[3] ? c[3].textContent.trim() : '',
usage: c[4] ? c[4].textContent.trim() : '',
status: c[5] ? c[5].textContent.trim() : ''
});
});
return out;
}
// ---- Alle Codes kopieren ----
window.copyAllCodes = function(siteId) {
var codes = collectRows(siteId).map(function(r) { return r.code; });
if (!codes.length) { toast('Keine Codes vorhanden', 'err'); return; }
navigator.clipboard.writeText(codes.join('\n')).then(function() {
toast(codes.length + ' Codes kopiert', 'ok');
}, function() { toast('Kopieren fehlgeschlagen', 'err'); });
};
// ---- CSV-Export ----
window.exportCsv = function(siteId) {
var rows = collectRows(siteId);
if (!rows.length) { toast('Keine Daten zum Export', 'err'); return; }
function q(s) { return '"' + String(s).replace(/"/g, '""') + '"'; }
var lines = [['Code','Notiz','Erstellt','Läuft ab','Nutzungen','Status'].map(q).join(';')];
rows.forEach(function(r) {
lines.push([r.code, r.note, r.created, r.expires, r.usage, r.status].map(q).join(';'));
});
// BOM für korrekte Umlaute in Excel
var blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'unifi-vouchers-' + siteId + '-' + new Date().toISOString().slice(0,10) + '.csv';
document.body.appendChild(a); a.click();
setTimeout(function() { URL.revokeObjectURL(a.href); a.remove(); }, 0);
toast(rows.length + ' Zeilen exportiert', 'ok');
};
// ---- Druckansicht ----
window.printVouchers = function(siteId) {
var tbody = document.getElementById('voucher-tbody-' + siteId);
if (!tbody) { toast('Keine Vouchers zum Drucken', 'err'); return; }
var cards = '';
tbody.querySelectorAll('tr').forEach(function(tr) {
var codeEl = tr.querySelector('.vc-code');
if (!codeEl) return;
var cells = tr.querySelectorAll('td');
var note = cells[1] ? cells[1].textContent.trim() : '';
var expires = cells[3] ? cells[3].textContent.trim() : '';
cards += '<div class="pv-card">'
+ '<div class="pv-code">' + escH(codeEl.textContent.trim()) + '</div>'
+ (note && note !== '–' ? '<div class="pv-note">' + escH(note) + '</div>' : '')
+ (expires && expires !== '–' ? '<div class="pv-exp">Gültig bis: ' + escH(expires) + '</div>' : '')
+ '</div>';
});
if (!cards) { toast('Keine Vouchers zum Drucken', 'err'); return; }
var w = window.open('', '_blank');
if (!w) { toast('Popup blockiert – bitte erlauben', 'err'); return; }
w.document.write(
'<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>UniFi Voucher</title><style>'
+ 'body{font-family:system-ui,sans-serif;margin:1.5rem;}'
+ 'h1{font-size:1.1rem;margin:0 0 1rem;}'
+ '.pv-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:.75rem;}'
+ '.pv-card{border:1px dashed #94a3b8;border-radius:8px;padding:1rem;text-align:center;page-break-inside:avoid;}'
+ '.pv-code{font-family:ui-monospace,monospace;font-size:1.4rem;font-weight:700;letter-spacing:.08em;}'
+ '.pv-note{color:#475569;font-size:.85rem;margin-top:.35rem;}'
+ '.pv-exp{color:#64748b;font-size:.78rem;margin-top:.25rem;}'
+ '@media print{button{display:none;}}'
+ '</style></head><body>'
+ '<h1>WLAN-Gutscheine</h1>'
+ '<button onclick="window.print()" style="margin-bottom:1rem;padding:.5rem 1rem;cursor:pointer;">Drucken</button>'
+ '<div class="pv-grid">' + cards + '</div></body></html>'
);
w.document.close();
};
function setCount(siteId, n) {
var countEl = document.getElementById('vccount-' + siteId);
if (countEl) countEl.textContent = '(' + n + ')';
var delAll = document.getElementById('vcdelall-' + siteId);
if (delAll) delAll.style.display = n > 0 ? '' : 'none';
document.querySelectorAll('#vc-site-ssd .ssd__item').forEach(function(item) {
if (item.getAttribute('data-value') === siteId) {
var base = item.textContent.replace(/\s*\(\d+\)\s*$/, '').trim();
item.textContent = n > 0 ? base + ' (' + n + ')' : base;
}
});
}
function adjustCount(siteId, delta) {
var countEl = document.getElementById('vccount-' + siteId);
var cur = countEl ? parseInt((countEl.textContent.match(/\d+/) || ['0'])[0]) : 0;
setCount(siteId, Math.max(0, cur + delta));
}
// ---- Voucher erstellen ----
window.createVoucher = function(siteId) {
var getVal = function(id) { return document.getElementById(id + '-' + siteId); };
var expire = parseInt(getVal('expire')?.value || '1440');
var n = parseInt(getVal('n')?.value || '1');
var quota = parseInt(getVal('quota')?.value || '1');
var down = parseInt(getVal('down')?.value || '0');
var up = parseInt(getVal('up')?.value || '0');
var note = getVal('note')?.value || '';
if (expire < 1 || n < 1 || n > 100) {
toast('Ungültige Eingabe: Dauer ≥ 1, Anzahl 1–100.', 'err');
return;
}
var submitBtn = document.querySelector('#panel-' + siteId + ' .vc-submit');
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '⏳ Wird erstellt…'; }
var body = new URLSearchParams({
_csrf: csrf, site_id: siteId,
expire: expire, n: n, quota: quota,
down: down, up: up, note: note
});
fetch('/unifi/vouchers', { method: 'POST', body: body })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
var codes = (d.vouchers || []).filter(function(c) { return c && c.trim() !== ''; });
if (codes.length > 0) {
insertNewVouchers(siteId, codes);
toast(codes.length + ' Voucher erstellt', 'ok');
} else {
toast('Voucher erstellt', 'ok');
}
// Vouchers-Liste automatisch aktualisieren
setTimeout(function() { refreshVouchers(siteId); }, 600);
} else {
toast('Fehler: ' + (d.error || 'Unbekannt'), 'err');
}
})
.catch(function(e) { toast('Netzwerkfehler: ' + e.message, 'err'); })
.finally(function() {
if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = '➕ Voucher erstellen'; }
});
};
function insertNewVouchers(siteId, codes) {
var container = document.getElementById('new-vouchers-' + siteId);
if (!container) return;
var html = '<div class="vc-card" style="margin-bottom:1rem;border-color:#bbf7d0">'
+ '<div class="vc-card-header" style="background:#f0fdf4;color:#166534">✓ Neue Vouchers <span class="vc-new-badge">' + codes.length + '</span></div>'
+ '<div class="vc-card-body">';
codes.forEach(function(code) {
var dc = fmtCode(code);
html += '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;flex-wrap:wrap">'
+ '<span class="vc-code">' + escH(dc) + '</span>'
+ '<button class="btn btn-xs btn-default vc-copy-btn" onclick="copyCode(this, \'' + escH(dc) + '\')">📋 Kopieren</button>'
+ (window.VoucherLabel ? VoucherLabel.buttons(dc) : '')
+ '</div>';
});
html += '</div></div>';
container.innerHTML = html;
container.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function fmtCode(code) {
var c = String(code).replace(/[-\s]/g, '');
return c.length >= 10 ? c.slice(0, 5) + '-' + c.slice(5) : code;
}
function escH(str) {
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
}
// Etiketten-Druck-Buttons in die serverseitig gerenderten Voucher-Zeilen einsetzen
document.querySelectorAll('.vc-print-slot').forEach(function(slot) {
slot.innerHTML = window.VoucherLabel ? VoucherLabel.buttons(slot.dataset.code || '') : '';
});
})();
</script>