Upload via GUI (258 Dateien)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/** Auslieferung der App-Dateien – nur für angemeldete Mitarbeiter. */
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
|
||||
$info = app_download_info((string) ($_GET['platform'] ?? ''));
|
||||
if (!$info) {
|
||||
http_response_code(404);
|
||||
exit('Diese App ist nicht hinterlegt.');
|
||||
}
|
||||
|
||||
session_write_close(); // lange Downloads sollen die Sitzung nicht blockieren
|
||||
@set_time_limit(0);
|
||||
while (ob_get_level() > 0) { @ob_end_clean(); }
|
||||
|
||||
header('Content-Type: ' . $info['mime']);
|
||||
header('Content-Disposition: attachment; filename="' . $info['filename'] . '"');
|
||||
header('Content-Length: ' . $info['size']);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: private, no-store');
|
||||
readfile($info['path']);
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin_or_permission('view_audit_log');
|
||||
$pdo = get_db();
|
||||
|
||||
// Leeren ist bewusst strenger gegated als das reine Ansehen (view_audit_log kann auch per
|
||||
// individueller Berechtigung an Nicht-Admins vergeben sein) – nur echte Admins dürfen das
|
||||
// Protokoll löschen. Der Löschvorgang selbst wird direkt danach neu protokolliert, damit
|
||||
// nicht spurlos "aufgeräumt" werden kann.
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'clear_log') {
|
||||
verify_csrf();
|
||||
if ($staff['role'] !== 'admin') {
|
||||
flash('error', 'Nur Admins können das Admin-Protokoll leeren.');
|
||||
redirect('/staff/audit_log.php');
|
||||
}
|
||||
$countBefore = (int) $pdo->query('SELECT COUNT(*) FROM admin_audit_log')->fetchColumn();
|
||||
$pdo->exec('DELETE FROM admin_audit_log');
|
||||
audit_log($staff['id'], "Admin-Protokoll geleert ({$countBefore} Einträge entfernt)");
|
||||
flash('success', 'Admin-Protokoll geleert.');
|
||||
redirect('/staff/audit_log.php');
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($_GET['p'] ?? 1));
|
||||
$perPage = 50;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$total = (int) $pdo->query('SELECT COUNT(*) FROM admin_audit_log')->fetchColumn();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT al.*, s.name AS staff_name FROM admin_audit_log al
|
||||
LEFT JOIN staff s ON s.id = al.staff_id
|
||||
ORDER BY al.created_at DESC LIMIT ? OFFSET ?"
|
||||
);
|
||||
$stmt->bindValue(1, $perPage, PDO::PARAM_INT);
|
||||
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$entries = $stmt->fetchAll();
|
||||
|
||||
$totalPages = max(1, (int) ceil($total / $perPage));
|
||||
|
||||
$pageTitle = 'Admin-Protokoll';
|
||||
$activePage = 'audit_log';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<h1>Admin-Protokoll</h1>
|
||||
<p class="muted">Protokoll administrativer Änderungen (Mitarbeiter, Stammdaten, Einstellungen) – getrennt vom Aktivitätsprotokoll einzelner Tickets.</p>
|
||||
</div>
|
||||
<?php if ($staff['role'] === 'admin' && $entries): ?>
|
||||
<form method="post" onsubmit="return confirm('Admin-Protokoll wirklich vollständig und unwiderruflich leeren?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="clear_log">
|
||||
<button type="submit" class="btn btn-outline" style="flex-shrink:0">🗑️ Protokoll leeren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<?php if (!$entries): ?>
|
||||
<div class="empty-state">Noch keine Einträge.</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Zeitpunkt</th><th>Mitarbeiter</th><th>Aktion</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($entries as $e): ?>
|
||||
<tr>
|
||||
<td class="muted" style="white-space:nowrap"><?= format_dt($e['created_at']) ?></td>
|
||||
<td><?= e($e['staff_name'] ?? 'System') ?></td>
|
||||
<td><?= e($e['action']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div style="margin-top:14px; font-size:.85rem">
|
||||
Seite <?= $page ?> von <?= $totalPages ?>
|
||||
<?php if ($page > 1): ?> · <a href="?p=<?= $page - 1 ?>">← Neuer</a><?php endif; ?>
|
||||
<?php if ($page < $totalPages): ?> · <a href="?p=<?= $page + 1 ?>">Älter →</a><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
/**
|
||||
* staff/availability.php
|
||||
* Verfügbarkeitsstatus setzen + Schichtplan verwalten (Admin)
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
// ── AJAX: Status setzen ───────────────────────────────────────────────────────
|
||||
// Status-AJAX: POST mit 'status' Feld aber ohne 'action' Feld
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status']) && !isset($_POST['action'])) {
|
||||
header('Content-Type: application/json');
|
||||
// CSRF prüfen
|
||||
$csrfOk = hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'] ?? '');
|
||||
if (!$csrfOk) { http_response_code(403); echo json_encode(['error'=>'CSRF-Fehler', 'got'=>$_POST['csrf_token']??'', 'expected'=>$_SESSION['csrf_token']??'']); exit; }
|
||||
|
||||
$newStatus = $_POST['status'] ?? '';
|
||||
$note = trim($_POST['note'] ?? '');
|
||||
$allowed = ['available', 'busy', 'break', 'offline'];
|
||||
|
||||
if (!in_array($newStatus, $allowed, true)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Ungültiger Status']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$availabilitySql = $drv === 'sqlite'
|
||||
? 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?)
|
||||
ON CONFLICT(staff_id) DO UPDATE SET
|
||||
status=excluded.status, note=excluded.note, updated_at=excluded.updated_at'
|
||||
: 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status=VALUES(status), note=VALUES(note), updated_at=VALUES(updated_at)';
|
||||
|
||||
try {
|
||||
$pdo->prepare($availabilitySql)->execute([$staff['id'], $newStatus, $note ?: null, db_now()]);
|
||||
echo json_encode(['ok' => true, 'status' => $newStatus]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('staff_availability: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => 'Status konnte nicht gespeichert werden.']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Schichtplan (Admin-Seite) ─────────────────────────────────────────────────
|
||||
$admin = $staff['role'] === 'admin' ? $staff : null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'save_shifts' && $admin) {
|
||||
$targetId = (int)($_POST['staff_id'] ?? 0);
|
||||
$__nameStmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?');
|
||||
$__nameStmt->execute([$targetId]);
|
||||
$editAgentName = $__nameStmt->fetchColumn() ?: "#$targetId";
|
||||
if (!$targetId) {
|
||||
flash('error', 'Ungültiger Mitarbeiter.');
|
||||
redirect('/staff/availability.php');
|
||||
}
|
||||
// Alte Schichten löschen
|
||||
$pdo->prepare('DELETE FROM staff_shifts WHERE staff_id = ?')->execute([$targetId]);
|
||||
// Neue speichern
|
||||
$days = $_POST['days'] ?? [];
|
||||
foreach ($days as $day => $times) {
|
||||
if (empty($times['active'])) continue;
|
||||
$start = $times['start'] ?? '08:00';
|
||||
$end = $times['end'] ?? '17:00';
|
||||
// Keine Zeitvalidierung – Nutzer ist verantwortlich
|
||||
$pdo->prepare(
|
||||
'INSERT INTO staff_shifts (staff_id, weekday, start_time, end_time, is_active) VALUES (?,?,?,?,1)'
|
||||
)->execute([$targetId, (int)$day, $start, $end]);
|
||||
}
|
||||
audit_log($admin['id'], "Schichtplan für Mitarbeiter #$targetId gespeichert");
|
||||
flash('success', 'Schichtplan für ' . e($editAgentName ?? "#$targetId") . ' gespeichert.');
|
||||
redirect('/staff/availability.php?edit=' . $targetId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daten laden ───────────────────────────────────────────────────────────────
|
||||
$agents = $pdo->query(
|
||||
"SELECT s.*, sa.status AS avail_status, sa.note AS avail_note, sa.updated_at AS avail_updated
|
||||
FROM staff s
|
||||
LEFT JOIN staff_availability sa ON sa.staff_id = s.id
|
||||
WHERE s.is_active = 1
|
||||
ORDER BY s.name"
|
||||
)->fetchAll();
|
||||
|
||||
// Schichten für alle Mitarbeiter
|
||||
$allShifts = $pdo->query(
|
||||
'SELECT * FROM staff_shifts WHERE is_active = 1 ORDER BY staff_id, weekday, start_time'
|
||||
)->fetchAll();
|
||||
$shiftsByStaff = [];
|
||||
foreach ($allShifts as $sh) {
|
||||
$shiftsByStaff[$sh['staff_id']][$sh['weekday']][] = $sh;
|
||||
}
|
||||
|
||||
// Für den Edit-Modus
|
||||
$editStaffId = (int)($_GET['edit'] ?? 0);
|
||||
$editShifts = $editStaffId ? ($shiftsByStaff[$editStaffId] ?? []) : [];
|
||||
|
||||
$pageTitle = 'Verfügbarkeit & Schichten';
|
||||
$activePage = 'availability';
|
||||
require __DIR__ . '/_header.php';
|
||||
|
||||
$statusLabels = [
|
||||
'available' => ['Verfügbar', '#22c55e', '🟢'],
|
||||
'busy' => ['Beschäftigt', '#f59e0b', '🟡'],
|
||||
'break' => ['Pause', '#6366f1', '🟣'],
|
||||
'offline' => ['Offline', '#94a3b8', '⚫'],
|
||||
];
|
||||
$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>Verfügbarkeit & Schichten</h1>
|
||||
</div>
|
||||
|
||||
<!-- Hidden CSRF form für AJAX -->
|
||||
<form id="csrf-form" style="display:none"><?= csrf_field() ?></form>
|
||||
|
||||
<!-- ── Eigener Status ─────────────────────────────────────────────────────────── -->
|
||||
<?php
|
||||
$myAvail = null;
|
||||
foreach ($agents as $a) { if ($a['id'] === $staff['id']) { $myAvail = $a; break; } }
|
||||
$myStatus = $myAvail['avail_status'] ?? 'available';
|
||||
?>
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Mein Status</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
||||
<?php foreach ($statusLabels as $sk => [$sl, $sc, $se]): ?>
|
||||
<button type="button" onclick="setMyStatus('<?= $sk ?>')"
|
||||
class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>"
|
||||
data-status="<?= $sk ?>"
|
||||
style="display:flex;align-items:center;gap:7px;padding:8px 16px;border:2px solid <?= $myStatus === $sk ? $sc : 'var(--border)' ?>;border-radius:8px;background:<?= $myStatus === $sk ? $sc.'18' : 'transparent' ?>;cursor:pointer;font-size:.85rem;font-weight:<?= $myStatus === $sk ? '600' : '400' ?>;color:<?= $myStatus === $sk ? $sc : 'var(--ink-soft)' ?>;transition:all .15s">
|
||||
<span><?= $se ?></span> <?= $sl ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
<div style="flex:1;min-width:160px">
|
||||
<input type="text" id="avail-note" value="<?= e($myAvail['avail_note'] ?? '') ?>"
|
||||
placeholder="Notiz (optional, z.B. «In Besprechung bis 14:00»)"
|
||||
style="width:100%;font-size:.83rem"
|
||||
oninput="scheduleStatusUpdate()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Team-Übersicht ─────────────────────────────────────────────────────────── -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:14px">Team-Übersicht</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">
|
||||
<?php foreach ($agents as $ag):
|
||||
$as = $ag['avail_status'] ?? 'offline';
|
||||
[$asl, $asc, $ase] = $statusLabels[$as] ?? ['Unbekannt','#94a3b8','⚫'];
|
||||
$hasShift = isset($shiftsByStaff[$ag['id']]);
|
||||
?>
|
||||
<div id="agent-status-<?= $ag['id'] ?>" style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">
|
||||
<div style="width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:.9rem;font-weight:700;flex-shrink:0;position:relative">
|
||||
<?= mb_strtoupper(mb_substr($ag['name'], 0, 1)) ?>
|
||||
<span class="status-dot" style="position:absolute;bottom:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:<?= $asc ?>;border:2px solid var(--surface)"></span>
|
||||
</div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div style="font-size:.85rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<?= e($ag['name']) ?>
|
||||
<?php if ($ag['is_on_vacation']): ?><span style="font-size:.7rem">🌴</span><?php endif; ?>
|
||||
</div>
|
||||
<div style="font-size:.75rem;font-weight:500"><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
|
||||
<div class="status-note" style="font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= e($ag['avail_note'] ?? '') ?></div>
|
||||
</div>
|
||||
<?php if ($admin): ?>
|
||||
<a href="?edit=<?= $ag['id'] ?>" title="Schichtplan bearbeiten"
|
||||
style="color:var(--muted);font-size:.78rem;text-decoration:none;flex-shrink:0">⚙️</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Schichtplan-Editor (Admin) ─────────────────────────────────────────────── -->
|
||||
<?php if ($admin && $editStaffId): ?>
|
||||
<?php
|
||||
$editAgent = null;
|
||||
foreach ($agents as $a) { if ($a['id'] === $editStaffId) { $editAgent = $a; break; } }
|
||||
?>
|
||||
<?php if ($editAgent): ?>
|
||||
<div class="card">
|
||||
<h3>Schichtplan: <?= e($editAgent['name']) ?></h3>
|
||||
<p class="muted" style="font-size:.83rem;margin-bottom:16px">
|
||||
Definiere an welchen Tagen und zu welchen Zeiten dieser Mitarbeiter verfügbar ist.
|
||||
Tickets werden außerhalb der Schicht nicht automatisch zugewiesen.
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_shifts">
|
||||
<input type="hidden" name="staff_id" value="<?= $editStaffId ?>">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align:left;padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Tag</th>
|
||||
<th style="padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Aktiv</th>
|
||||
<th style="padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Von</th>
|
||||
<th style="padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php for ($d = 1; $d <= 7; $d++):
|
||||
$dayShift = $editShifts[$d][0] ?? null;
|
||||
$active = $dayShift !== null;
|
||||
$start = $active ? substr($dayShift['start_time'], 0, 5) : '08:00';
|
||||
$end = $active ? substr($dayShift['end_time'], 0, 5) : '17:00';
|
||||
?>
|
||||
<tr style="border-bottom:1px solid var(--border-soft)">
|
||||
<td style="padding:10px 12px;font-size:.88rem;font-weight:500"><?= $weekdays[$d-1] ?></td>
|
||||
<td style="padding:10px 12px;text-align:center">
|
||||
<input type="checkbox" name="days[<?= $d ?>][active]" value="1" style="width:auto"
|
||||
<?= $active ? 'checked' : '' ?>
|
||||
onchange="toggleDay(<?= $d ?>, this.checked)">
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<input type="time" name="days[<?= $d ?>][start]" value="<?= e($start) ?>"
|
||||
id="day-start-<?= $d ?>" style="font-size:.83rem;padding:5px 8px;width:130px"
|
||||
<?= !$active ? 'disabled style="opacity:.4"' : '' ?> required>
|
||||
</td>
|
||||
<td style="padding:10px 12px">
|
||||
<input type="time" name="days[<?= $d ?>][end]" value="<?= e($end) ?>"
|
||||
id="day-end-<?= $d ?>" style="font-size:.83rem;padding:5px 8px;width:130px"
|
||||
<?= !$active ? 'disabled style="opacity:.4"' : '' ?> required>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endfor; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="btn-row" style="margin-top:16px">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<a href="/staff/availability.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function getCsrf() {
|
||||
return document.querySelector('#csrf-form input[name=csrf_token]')?.value
|
||||
|| document.querySelector('input[name=csrf_token]')?.value || '';
|
||||
}
|
||||
|
||||
var currentStatus = '<?= e($myStatus) ?>';
|
||||
var statusUpdateTimer;
|
||||
|
||||
function setMyStatus(s) {
|
||||
currentStatus = s;
|
||||
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
var active = btn.dataset.status === s;
|
||||
var colors = {
|
||||
available: ['#22c55e','#22c55e18'],
|
||||
busy: ['#f59e0b','#f59e0b18'],
|
||||
break: ['#6366f1','#6366f118'],
|
||||
offline: ['#94a3b8','#94a3b818'],
|
||||
};
|
||||
var c = colors[btn.dataset.status] || ['var(--border)','transparent'];
|
||||
btn.style.borderColor = active ? c[0] : 'var(--border)';
|
||||
btn.style.background = active ? c[1] : 'transparent';
|
||||
btn.style.color = active ? c[0] : 'var(--ink-soft)';
|
||||
btn.style.fontWeight = active ? '600' : '400';
|
||||
});
|
||||
sendStatusUpdate();
|
||||
}
|
||||
|
||||
function scheduleStatusUpdate() {
|
||||
clearTimeout(statusUpdateTimer);
|
||||
statusUpdateTimer = setTimeout(sendStatusUpdate, 800);
|
||||
}
|
||||
|
||||
function sendStatusUpdate() {
|
||||
var note = document.getElementById('avail-note')?.value || '';
|
||||
var fd = new FormData();
|
||||
fd.append('status', currentStatus);
|
||||
fd.append('note', note);
|
||||
fd.append('csrf_token', getCsrf());
|
||||
fetch('/staff/availability.php', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin'
|
||||
}).then(function(r) {
|
||||
return r.json();
|
||||
}).then(function(data) {
|
||||
if (data.error) console.error('Status-Update Fehler:', data.error);
|
||||
}).catch(function(err) {
|
||||
console.error('Status-Update fehlgeschlagen:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Team-Status Live-Update ──────────────────────────────────────────────
|
||||
function refreshTeamStatus() {
|
||||
fetch('/staff/availability_status.php', { credentials: 'same-origin' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
data.forEach(function(ag) {
|
||||
var el = document.getElementById('agent-status-' + ag.id);
|
||||
if (!el) return;
|
||||
var colors = { available:'#22c55e', busy:'#f59e0b', break:'#6366f1', offline:'#94a3b8' };
|
||||
var labels = { available:'Verfügbar', busy:'Beschäftigt', break:'Pause', offline:'Offline' };
|
||||
var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline');
|
||||
var label = ag.is_on_vacation ? '🌴 Urlaub' : labels[s];
|
||||
var color = colors[s] || '#94a3b8';
|
||||
el.querySelector('.status-dot').style.background = color;
|
||||
el.querySelector('.status-label').textContent = label;
|
||||
el.querySelector('.status-label').style.color = color;
|
||||
var noteEl = el.querySelector('.status-note');
|
||||
if (noteEl) noteEl.textContent = ag.note || '';
|
||||
});
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
// Alle 15 Sekunden Team-Status aktualisieren
|
||||
setInterval(refreshTeamStatus, 15000);
|
||||
|
||||
// Nach eigenem Status-Update sofort refreshen
|
||||
var _origSendStatus = sendStatusUpdate;
|
||||
sendStatusUpdate = function() {
|
||||
_origSendStatus();
|
||||
setTimeout(refreshTeamStatus, 500);
|
||||
};
|
||||
|
||||
function toggleDay(d, active) {
|
||||
document.getElementById('day-start-' + d).disabled = !active;
|
||||
document.getElementById('day-end-' + d).disabled = !active;
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
/**
|
||||
* staff/availability_status.php
|
||||
* Gibt den aktuellen Verfügbarkeitsstatus aller aktiven Mitarbeiter als JSON zurück.
|
||||
* Wird alle 15 Sekunden vom Frontend abgefragt.
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: no-cache');
|
||||
|
||||
try {
|
||||
$rows = $pdo->query(
|
||||
"SELECT s.id, s.name, s.is_on_vacation,
|
||||
COALESCE(sa.status, 'offline') AS status,
|
||||
sa.note
|
||||
FROM staff s
|
||||
LEFT JOIN staff_availability sa ON sa.staff_id = s.id
|
||||
WHERE s.is_active = 1
|
||||
ORDER BY s.name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
echo json_encode(array_map(fn($r) => [
|
||||
'id' => (int)$r['id'],
|
||||
'status' => $r['status'],
|
||||
'note' => $r['note'] ?? '',
|
||||
'is_on_vacation' => (bool)$r['is_on_vacation'],
|
||||
], $rows));
|
||||
} catch (Throwable $e) {
|
||||
echo json_encode([]);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/backup.php';
|
||||
require_once __DIR__ . '/../includes/backup_restore.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
// ── Backup-Setting Helfer ────────────────────────────────────────────────────
|
||||
function bget(string $key): ?string {
|
||||
global $pdo;
|
||||
$s = $pdo->prepare('SELECT value FROM backup_settings WHERE key_name=?');
|
||||
$s->execute([$key]);
|
||||
$r = $s->fetch();
|
||||
return $r ? $r['value'] : null;
|
||||
}
|
||||
function bset_save(string $key, ?string $value): void {
|
||||
global $pdo;
|
||||
$sql = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'
|
||||
? 'INSERT INTO backup_settings (key_name, value, updated_at) VALUES (?,?,?)
|
||||
ON CONFLICT(key_name) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at'
|
||||
: 'INSERT INTO backup_settings (key_name, value, updated_at) VALUES (?,?,?)
|
||||
ON DUPLICATE KEY UPDATE value=VALUES(value), updated_at=VALUES(updated_at)';
|
||||
$pdo->prepare($sql)->execute([$key, $value, db_now()]);
|
||||
}
|
||||
|
||||
// ── POST-Handler ─────────────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create_backup') {
|
||||
$result = create_backup();
|
||||
if ($result['ok']) {
|
||||
// FTP-Upload wenn konfiguriert
|
||||
$ftpHost = bget('ftp_host');
|
||||
$ftpUser = bget('ftp_user');
|
||||
$ftpPass = bget('ftp_pass');
|
||||
$ftpPath = bget('ftp_path') ?: '/';
|
||||
$ftpPort = (int)(bget('ftp_port') ?: 21);
|
||||
$ftpTls = bget('ftp_tls') === '1';
|
||||
$ftpMsg = '';
|
||||
if ($ftpHost && $ftpUser && function_exists('ftp_connect')) {
|
||||
$ftp = $ftpTls ? @ftp_ssl_connect($ftpHost, $ftpPort, 10) : @ftp_connect($ftpHost, $ftpPort, 10);
|
||||
if ($ftp && @ftp_login($ftp, $ftpUser, $ftpPass)) {
|
||||
ftp_pasv($ftp, true);
|
||||
$remote = rtrim($ftpPath, '/') . '/' . $result['filename'];
|
||||
$ftpMsg = ftp_put($ftp, $remote, $result['path'], FTP_BINARY)
|
||||
? ' – FTP-Upload erfolgreich.' : ' – FTP-Upload fehlgeschlagen.';
|
||||
ftp_close($ftp);
|
||||
} else {
|
||||
$ftpMsg = ' – FTP-Verbindung fehlgeschlagen.';
|
||||
}
|
||||
}
|
||||
// Lokale Rotation
|
||||
$keep = (int)(bget('rotate_keep') ?: 0);
|
||||
if ($keep > 0) {
|
||||
$bdir = realpath(__DIR__ . '/../backups');
|
||||
$files = array_merge(glob($bdir.'/backup_*.zip')?:[], glob($bdir.'/backup_*.tar.gz')?:[]);
|
||||
usort($files, fn($a,$b) => filemtime($b)-filemtime($a));
|
||||
foreach (array_slice($files, $keep) as $old) @unlink($old);
|
||||
}
|
||||
$verified = backup_verify_and_record($result['path']);
|
||||
audit_log($staff['id'], "Manuelles Backup: {$result['filename']} ({$result['size']} MB)$ftpMsg");
|
||||
flash($verified['ok'] ? 'success' : 'error', ($verified['ok'] ? "✅ Backup erstellt und geprüft: {$result['filename']} ({$result['size']} MB){$ftpMsg}"
|
||||
: "Backup erstellt, aber NICHT wiederherstellbar: " . implode(' ', $verified['errors'])));
|
||||
} else {
|
||||
flash('error', 'Backup fehlgeschlagen: ' . $result['error']);
|
||||
}
|
||||
redirect('/staff/backup.php');
|
||||
}
|
||||
|
||||
if ($action === 'verify_backup') {
|
||||
$path = backup_path_for((string) ($_POST['filename'] ?? ''));
|
||||
if (!$path) { flash('error', 'Backup nicht gefunden.'); redirect('/staff/backup.php'); }
|
||||
$v = backup_verify_and_record($path);
|
||||
$i = $v['info'];
|
||||
if ($v['ok']) {
|
||||
flash('success', '✅ ' . basename($path) . ' ist vollständig und wiederherstellbar (' . strtoupper((string) ($i['driver'] ?? '')) . '-Datenbank, ' . (int) ($i['tables'] ?? 0) . ' Tabellen'
|
||||
. (isset($i['tickets']) ? ', ' . $i['tickets'] . ' Tickets' : '') . ', ' . (int) ($i['files'] ?? 0) . ' Dateien).' . ($v['warnings'] ? ' Hinweis: ' . implode(' ', $v['warnings']) : ''));
|
||||
} else {
|
||||
flash('error', basename($path) . ' ist NICHT wiederherstellbar: ' . implode(' ', $v['errors']));
|
||||
}
|
||||
redirect('/staff/backup.php');
|
||||
}
|
||||
|
||||
if ($action === 'restore_backup') {
|
||||
$path = backup_path_for((string) ($_POST['filename'] ?? ''));
|
||||
if (!$path) { flash('error', 'Backup nicht gefunden.'); redirect('/staff/backup.php'); }
|
||||
if (trim((string) ($_POST['confirm'] ?? '')) !== 'WIEDERHERSTELLEN') {
|
||||
flash('error', 'Zur Bestätigung bitte WIEDERHERSTELLEN eintippen. Es wurde nichts verändert.');
|
||||
redirect('/staff/backup.php');
|
||||
}
|
||||
$dirs = array_values(array_intersect(array_keys(backup_restorable_dirs()), (array) ($_POST['dirs'] ?? [])));
|
||||
$res = backup_restore($pdo, $path, ['database' => !empty($_POST['database']), 'dirs' => $dirs]);
|
||||
if ($res['ok']) {
|
||||
audit_log($staff['id'], 'Backup wiederhergestellt: ' . basename($path) . " ({$res['tables']} Tabellen, {$res['files']} Dateien, Sicherheits-Backup: " . ($res['safety'] ?? '–') . ')');
|
||||
flash('success', '✅ Wiederhergestellt: ' . basename($path) . " ({$res['tables']} Tabellen, {$res['rows']} Datensätze, {$res['files']} Dateien). Der Stand von vorher steht im Sicherheits-Backup " . ($res['safety'] ?? '–') . '. ' . implode(' ', $res['notes'])
|
||||
. ' Falls du abgemeldet wirst: Der Mitarbeiter-Stand entspricht jetzt dem Backup, bitte neu anmelden.');
|
||||
} else {
|
||||
flash('error', $res['error']);
|
||||
}
|
||||
redirect('/staff/backup.php');
|
||||
}
|
||||
|
||||
if ($action === 'delete_backup') {
|
||||
$file = basename($_POST['filename'] ?? '');
|
||||
$bdir = realpath(__DIR__ . '/../backups');
|
||||
$path = $bdir . '/' . $file;
|
||||
if ($file && $bdir && file_exists($path)
|
||||
&& strpos(realpath($path), $bdir) === 0
|
||||
&& preg_match('/^backup_[\d\-_]+\.(zip|tar\.gz)$/', $file)) {
|
||||
unlink($path);
|
||||
audit_log($staff['id'], "Backup gelöscht: $file");
|
||||
flash('success', "Backup $file gelöscht.");
|
||||
} else {
|
||||
flash('error', 'Backup nicht gefunden.');
|
||||
}
|
||||
redirect('/staff/backup.php');
|
||||
}
|
||||
|
||||
if ($action === 'save_schedule') {
|
||||
bset_save('schedule', $_POST['schedule'] ?? 'disabled');
|
||||
bset_save('rotate_keep', (string)(int)($_POST['rotate_keep'] ?? 0));
|
||||
audit_log($staff['id'], 'Backup-Zeitplan gespeichert');
|
||||
flash('success', 'Zeitplan gespeichert.');
|
||||
redirect('/staff/backup.php#zeitplan');
|
||||
}
|
||||
|
||||
if ($action === 'save_ftp') {
|
||||
bset_save('ftp_host', trim($_POST['ftp_host'] ?? '') ?: null);
|
||||
bset_save('ftp_port', trim($_POST['ftp_port'] ?? '21'));
|
||||
bset_save('ftp_user', trim($_POST['ftp_user'] ?? '') ?: null);
|
||||
bset_save('ftp_tls', !empty($_POST['ftp_tls']) ? '1' : '0');
|
||||
bset_save('ftp_path', trim($_POST['ftp_path'] ?? '/'));
|
||||
bset_save('ftp_keep', (string)(int)($_POST['ftp_keep'] ?? 5));
|
||||
if (!empty($_POST['ftp_pass'])) {
|
||||
bset_save('ftp_pass', $_POST['ftp_pass']);
|
||||
}
|
||||
audit_log($staff['id'], 'FTP-Backup-Einstellungen gespeichert');
|
||||
flash('success', 'FTP-Einstellungen gespeichert.');
|
||||
redirect('/staff/backup.php#ftp');
|
||||
}
|
||||
|
||||
if ($action === 'test_ftp') {
|
||||
$host = bget('ftp_host'); $user = bget('ftp_user'); $pass = bget('ftp_pass');
|
||||
$port = (int)(bget('ftp_port') ?: 21); $tls = bget('ftp_tls') === '1';
|
||||
if (!$host || !$user) { flash('error', 'FTP nicht konfiguriert.'); }
|
||||
elseif (!function_exists('ftp_connect')) { flash('error', 'PHP FTP-Erweiterung nicht verfügbar.'); }
|
||||
else {
|
||||
$ftp = $tls ? @ftp_ssl_connect($host, $port, 10) : @ftp_connect($host, $port, 10);
|
||||
if ($ftp && @ftp_login($ftp, $user, $pass)) {
|
||||
ftp_close($ftp);
|
||||
flash('success', "✅ FTP-Verbindung zu $host erfolgreich.");
|
||||
} else {
|
||||
flash('error', "FTP-Verbindung zu $host fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
redirect('/staff/backup.php#ftp');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daten laden ───────────────────────────────────────────────────────────────
|
||||
$backupDir = realpath(__DIR__ . '/../backups') ?: (dirname(__DIR__) . '/backups');
|
||||
$backupFiles = [];
|
||||
if (is_dir($backupDir)) {
|
||||
foreach (array_merge(glob($backupDir.'/backup_*.zip')?:[], glob($backupDir.'/backup_*.tar.gz')?:[]) as $f) {
|
||||
$backupFiles[] = ['name'=>basename($f),'size'=>round(filesize($f)/1024/1024,2),'date'=>filemtime($f)];
|
||||
}
|
||||
usort($backupFiles, fn($a,$b) => $b['date']-$a['date']);
|
||||
}
|
||||
$totalSize = array_sum(array_column($backupFiles, 'size'));
|
||||
$lastAutoRun = get_setting('cron_process_backups_last_run');
|
||||
$lastAutoBkp = bget('last_auto_backup');
|
||||
|
||||
$pageTitle = 'Backup';
|
||||
$activePage = 'backup';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>💾 Backup</h1>
|
||||
<form method="post" id="backup-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="create_backup">
|
||||
<button type="submit" class="btn">💾 Backup jetzt erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Statistiken -->
|
||||
<div class="stat-grid" style="margin-bottom:20px">
|
||||
<div class="stat-card"><div class="num"><?= count($backupFiles) ?></div><div class="lbl">Backups vorhanden</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $totalSize ?> MB</div><div class="lbl">Gesamtgröße</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $backupFiles ? date('d.m.Y', $backupFiles[0]['date']) : '–' ?></div><div class="lbl">Letztes Backup</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $lastAutoBkp ? date('d.m.Y', strtotime($lastAutoBkp)) : '–' ?></div><div class="lbl">Letztes Auto-Backup</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Liste -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<h3>Vorhandene Backups</h3>
|
||||
<?php $lastCheck = json_decode((string) get_setting('backup_last_check', ''), true); if (is_array($lastCheck)): ?>
|
||||
<p class="muted" style="font-size:.82rem; margin-top:-6px">Letzte Prüfung: <?= e(date('d.m.Y H:i', strtotime($lastCheck['at']))) ?> – <?= e($lastCheck['file']) ?>:
|
||||
<strong style="color:<?= $lastCheck['ok'] ? '#15803d' : '#dc2626' ?>"><?= $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?></strong></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!$backupFiles): ?>
|
||||
<div class="empty-state">Noch keine Backups vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Dateiname</th><th>Größe</th><th>Erstellt</th><th style="width:160px"></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($backupFiles as $b): ?>
|
||||
<tr>
|
||||
<td style="font-family:monospace; font-size:.82rem"><?= e($b['name']) ?></td>
|
||||
<td class="muted"><?= $b['size'] ?> MB</td>
|
||||
<td class="muted"><?= date('d.m.Y H:i', $b['date']) ?></td>
|
||||
<td style="display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start">
|
||||
<a href="/staff/backup_download.php?file=<?= urlencode($b['name']) ?>" class="btn secondary" style="padding:5px 10px; font-size:.78rem">⬇️ Download</a>
|
||||
<?php if (str_ends_with($b['name'], '.zip')): ?>
|
||||
<form method="post" style="margin:0"><?= csrf_field() ?><input type="hidden" name="action" value="verify_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
|
||||
<button type="submit" class="btn secondary" style="padding:5px 10px; font-size:.78rem" title="Prüft, ob sich das Backup wiederherstellen lässt (verändert nichts)">✔️ Prüfen</button></form>
|
||||
<details style="position:relative"><summary class="btn secondary" style="padding:5px 10px; font-size:.78rem; list-style:none; cursor:pointer">♻️ Wiederherstellen</summary>
|
||||
<form method="post" style="position:absolute; right:0; z-index:5; width:340px; padding:14px; background:var(--card-bg,#fff); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.18)">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="restore_backup"><input type="hidden" name="filename" value="<?= e($b['name']) ?>">
|
||||
<p style="font-size:.82rem; margin:0 0 8px"><strong>Achtung:</strong> Der aktuelle Stand der Datenbank wird durch den Stand dieses Backups ersetzt. Vorher wird automatisch ein Sicherheits-Backup erstellt. Programmdateien und die Konfiguration bleiben unverändert.</p>
|
||||
<label style="display:block; font-weight:400"><input type="checkbox" name="database" value="1" checked style="width:auto"> Datenbank</label>
|
||||
<?php foreach (backup_restorable_dirs() as $dk => $dl): ?><label style="display:block; font-weight:400"><input type="checkbox" name="dirs[]" value="<?= e($dk) ?>" checked style="width:auto"> <?= e($dl) ?></label><?php endforeach; ?>
|
||||
<label style="margin-top:8px; font-size:.8rem">Zur Bestätigung <code>WIEDERHERSTELLEN</code> eintippen</label>
|
||||
<input type="text" name="confirm" autocomplete="off" required>
|
||||
<button type="submit" class="btn" style="margin-top:8px; background:var(--danger)">Jetzt wiederherstellen</button>
|
||||
</form></details>
|
||||
<?php endif; ?>
|
||||
<form method="post" onsubmit="return confirm('Backup löschen?')" style="margin:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_backup">
|
||||
<input type="hidden" name="filename" value="<?= e($b['name']) ?>">
|
||||
<button type="submit" class="btn secondary" style="padding:5px 10px; font-size:.78rem; border-color:var(--danger); color:var(--danger)">🗑️</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:20px">
|
||||
|
||||
<!-- Zeitplan -->
|
||||
<div class="card" id="zeitplan">
|
||||
<h3>⏰ Automatischer Zeitplan</h3>
|
||||
<p class="muted" style="font-size:.85rem; margin-bottom:16px">
|
||||
Cron-Job einrichten: <code>php <?= e(dirname(__DIR__)) ?>/cron/process_backups.php</code><br>
|
||||
<?php if ($lastAutoRun): ?><span style="font-size:.78rem">Letzter Lauf: <?= format_dt($lastAutoRun) ?></span><?php endif; ?>
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_schedule">
|
||||
<label>Backup-Intervall</label>
|
||||
<select name="schedule">
|
||||
<?php foreach (['disabled'=>'Deaktiviert','daily'=>'Täglich','weekly'=>'Wöchentlich','monthly'=>'Monatlich'] as $v=>$l): ?>
|
||||
<option value="<?= $v ?>" <?= bget('schedule')===$v?'selected':'' ?>><?= $l ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label style="margin-top:14px">Backup-Rotation (lokale Kopien behalten)</label>
|
||||
<select name="rotate_keep">
|
||||
<option value="0" <?= bget('rotate_keep')==='0'?'selected':'' ?>>Alle behalten</option>
|
||||
<?php foreach ([3,5,7,10,14,30] as $n): ?>
|
||||
<option value="<?= $n ?>" <?= bget('rotate_keep')===(string)$n?'selected':'' ?>><?= $n ?> Backups behalten</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="muted" style="font-size:.78rem; margin-top:6px">Ältere Backups werden automatisch gelöscht wenn das Limit erreicht wird.</p>
|
||||
<div class="btn-row" style="margin-top:14px">
|
||||
<button type="submit" class="btn">Zeitplan speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- FTP -->
|
||||
<div class="card" id="ftp">
|
||||
<h3>📡 FTP-Upload <span class="muted" style="font-size:.78rem; font-weight:400">(optional)</span></h3>
|
||||
<p class="muted" style="font-size:.85rem; margin-bottom:16px">Backups werden nach der Erstellung automatisch per FTP übertragen.</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_ftp">
|
||||
<div style="display:grid; grid-template-columns:1fr auto; gap:8px; align-items:flex-end; margin-bottom:10px">
|
||||
<div>
|
||||
<label>FTP-Host</label>
|
||||
<input type="text" name="ftp_host" value="<?= e(bget('ftp_host') ?? '') ?>" placeholder="ftp.beispiel.de">
|
||||
</div>
|
||||
<div style="width:80px">
|
||||
<label>Port</label>
|
||||
<input type="number" name="ftp_port" value="<?= e(bget('ftp_port') ?? '21') ?>" min="1" max="65535">
|
||||
</div>
|
||||
</div>
|
||||
<label>Benutzername</label>
|
||||
<input type="text" name="ftp_user" value="<?= e(bget('ftp_user') ?? '') ?>" placeholder="ftp-benutzer" autocomplete="off">
|
||||
<label style="margin-top:10px">Passwort</label>
|
||||
<input type="password" name="ftp_pass" placeholder="<?= bget('ftp_pass') ? '••••••••' : 'Passwort eingeben' ?>" autocomplete="new-password">
|
||||
<label style="margin-top:10px">Zielverzeichnis</label>
|
||||
<input type="text" name="ftp_path" value="<?= e(bget('ftp_path') ?? '/') ?>" placeholder="/backups/deskhub">
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px">
|
||||
<div>
|
||||
<label>FTP-Rotation (Kopien auf Server)</label>
|
||||
<select name="ftp_keep">
|
||||
<option value="0" <?= bget('ftp_keep')==='0'?'selected':'' ?>>Alle behalten</option>
|
||||
<?php foreach ([3,5,7,10] as $n): ?>
|
||||
<option value="<?= $n ?>" <?= bget('ftp_keep')===(string)$n?'selected':'' ?>><?= $n ?> behalten</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex; align-items:flex-end; padding-bottom:2px">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:400; cursor:pointer">
|
||||
<input type="checkbox" name="ftp_tls" value="1" <?= bget('ftp_tls')==='1'?'checked':'' ?> style="width:auto">
|
||||
FTPS (TLS)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row" style="margin-top:16px; gap:8px">
|
||||
<button type="submit" class="btn">FTP speichern</button>
|
||||
<button type="submit" form="ftp-test-form" class="btn secondary">Verbindung testen</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" id="ftp-test-form" style="display:none">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="test_ftp">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Hinweise -->
|
||||
<div class="card">
|
||||
<h3>ℹ️ Hinweise</h3>
|
||||
<ul style="padding-left:20px; line-height:2; font-size:.88rem; color:var(--muted)">
|
||||
<li>Backups enthalten alle Dateien inkl. <strong style="color:var(--ink)">Datenbank</strong> und <strong style="color:var(--ink)">Konfiguration</strong></li>
|
||||
<li>Backups werden automatisch vor jedem Update erstellt</li>
|
||||
<li>Für automatische Backups den Cron einrichten:<br><code>0 2 * * * php <?= e(dirname(__DIR__)) ?>/cron/process_backups.php</code></li>
|
||||
<li>Backups liegen in <code>backups/</code> und sind von außen nicht zugänglich</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('backup-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = this.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Backup wird erstellt...';
|
||||
this.submit();
|
||||
});
|
||||
</script>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin();
|
||||
|
||||
$file = basename($_GET['file'] ?? '');
|
||||
$backupDir = realpath(__DIR__ . '/../backups');
|
||||
|
||||
if (!$file || !$backupDir) {
|
||||
http_response_code(400); die('Ungültige Anfrage.');
|
||||
}
|
||||
|
||||
// Nur backup_*.zip erlauben
|
||||
if (!preg_match('/^backup_[\d\-_]+\.(zip|tar\.gz)$/', $file)) {
|
||||
http_response_code(403); die('Ungültiger Dateiname.');
|
||||
}
|
||||
|
||||
$path = $backupDir . '/' . $file;
|
||||
|
||||
// Sicherheitscheck: Datei muss im backups/-Ordner liegen
|
||||
if (!file_exists($path) || strpos(realpath($path), $backupDir) !== 0) {
|
||||
http_response_code(404); die('Backup nicht gefunden.');
|
||||
}
|
||||
|
||||
audit_log($staff['id'], "Backup heruntergeladen: $file");
|
||||
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="' . $file . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: private, no-cache');
|
||||
readfile($path);
|
||||
exit;
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create') {
|
||||
$matchType = ($_POST['match_type'] ?? 'email') === 'domain' ? 'domain' : 'email';
|
||||
$value = trim(strtolower($_POST['value'] ?? ''));
|
||||
$reason = trim($_POST['reason'] ?? '');
|
||||
if (!$value) { $errors[] = 'Bitte einen Wert angeben.'; }
|
||||
elseif ($matchType === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Ungültige E-Mail-Adresse.'; }
|
||||
elseif ($matchType === 'domain' && (!str_contains($value, '.') || str_contains($value, '@'))) { $errors[] = 'Nur Domain angeben, z.B. spam.de (ohne @).'; }
|
||||
if (!$errors) {
|
||||
$chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
|
||||
$chk->execute([$value]);
|
||||
if (!$chk->fetchColumn()) {
|
||||
$pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
|
||||
->execute([$matchType, $value, $reason ?: null, $staff['id'], db_now()]);
|
||||
// Tickets schließen
|
||||
$col = $matchType === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . "";
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
->execute([db_now(), db_now(), $value]);
|
||||
audit_log($staff['id'], "Sperrliste: $value hinzugefügt ($matchType)");
|
||||
}
|
||||
flash('success', "$value gesperrt.");
|
||||
redirect('/staff/banlist.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'bulk_add') {
|
||||
$raw = trim($_POST['bulk_values'] ?? '');
|
||||
$reason = trim($_POST['bulk_reason'] ?? '');
|
||||
$lines = array_filter(array_map('trim', preg_split('/[\r\n,;]+/', $raw)));
|
||||
$added = 0; $skipped = 0;
|
||||
foreach ($lines as $val) {
|
||||
$val = strtolower($val);
|
||||
$type = str_contains($val, '@') ? 'email' : 'domain';
|
||||
if ($type === 'email' && !filter_var($val, FILTER_VALIDATE_EMAIL)) { $skipped++; continue; }
|
||||
if ($type === 'domain' && !str_contains($val, '.')) { $skipped++; continue; }
|
||||
$chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
|
||||
$chk->execute([$val]);
|
||||
if ($chk->fetchColumn()) { $skipped++; continue; }
|
||||
$pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
|
||||
->execute([$type, $val, $reason ?: null, $staff['id'], db_now()]);
|
||||
// Tickets schließen
|
||||
$col = $type === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . "";
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
->execute([db_now(), db_now(), $val]);
|
||||
$added++;
|
||||
}
|
||||
audit_log($staff['id'], "Sperrliste Massen-Import: $added hinzugefügt, $skipped übersprungen");
|
||||
flash('success', "$added Einträge gesperrt, $skipped übersprungen.");
|
||||
redirect('/staff/banlist.php');
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)$_POST['id'];
|
||||
$val = $pdo->prepare('SELECT value FROM banlist WHERE id=?');
|
||||
$val->execute([$id]);
|
||||
$v = $val->fetchColumn();
|
||||
$pdo->prepare('DELETE FROM banlist WHERE id=?')->execute([$id]);
|
||||
audit_log($staff['id'], "Sperrliste: $v entfernt");
|
||||
flash('success', "$v entsperrt.");
|
||||
redirect('/staff/banlist.php');
|
||||
}
|
||||
}
|
||||
|
||||
$entries = $pdo->query(
|
||||
'SELECT b.*, s.name AS added_by_name FROM banlist b LEFT JOIN staff s ON s.id = b.created_by ORDER BY b.created_at DESC'
|
||||
)->fetchAll();
|
||||
|
||||
$pageTitle = 'Sperrliste';
|
||||
$activePage = 'banlist';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>Sperrliste</h1>
|
||||
</div>
|
||||
|
||||
<p class="muted" style="margin-bottom:20px">E-Mail-Adressen oder Domains die keine Tickets mehr erstellen dürfen. Beim Sperren werden alle offenen Tickets automatisch geschlossen.</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex; gap:20px; align-items:flex-start; flex-wrap:wrap">
|
||||
|
||||
<!-- Liste -->
|
||||
<div style="flex:2; min-width:400px">
|
||||
<div class="card">
|
||||
<h3>Gesperrte Einträge (<?= count($entries) ?>)</h3>
|
||||
<table>
|
||||
<thead><tr><th>Typ</th><th>Wert</th><th>Grund</th><th>Gesperrt am</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($entries as $b): ?>
|
||||
<tr>
|
||||
<td><span class="badge" style="background:<?= $b['match_type']==='domain'?'#a855f7':'#64748b' ?>"><?= $b['match_type']==='domain'?'Domain':'E-Mail' ?></span></td>
|
||||
<td style="font-weight:600"><?= e($b['value']) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($b['reason'] ?: '–') ?></td>
|
||||
<td class="muted" style="font-size:.78rem"><?= format_dt($b['created_at']) ?><?= $b['added_by_name'] ? '<br>'.e($b['added_by_name']) : '' ?></td>
|
||||
<td>
|
||||
<form method="post" onsubmit="return confirm('Wirklich entsperren?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $b['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;font-size:.82rem">Entsperren</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$entries): ?>
|
||||
<tr><td colspan="5" class="muted">Sperrliste ist leer.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulare -->
|
||||
<div style="flex:1; min-width:280px; display:flex; flex-direction:column; gap:16px">
|
||||
|
||||
<!-- Einzeln -->
|
||||
<div class="card">
|
||||
<h3>Einzeln sperren</h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="create">
|
||||
<label>Typ</label>
|
||||
<select name="match_type">
|
||||
<option value="email">E-Mail-Adresse</option>
|
||||
<option value="domain">Domain (alle @domain.de)</option>
|
||||
</select>
|
||||
<label style="margin-top:10px">E-Mail / Domain</label>
|
||||
<input type="text" name="value" placeholder="spam@beispiel.de oder spam-domain.de" required>
|
||||
<label style="margin-top:10px">Grund (optional)</label>
|
||||
<input type="text" name="reason" placeholder="z.B. wiederholter Spam">
|
||||
<div class="btn-row"><button type="submit" class="btn">Sperren</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Massen-Import -->
|
||||
<div class="card">
|
||||
<h3>Mehrere sperren</h3>
|
||||
<p class="muted" style="font-size:.82rem; margin-bottom:12px">Eine E-Mail oder Domain pro Zeile (oder kommagetrennt). Typ wird automatisch erkannt.</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="bulk_add">
|
||||
<label>E-Mails / Domains</label>
|
||||
<textarea name="bulk_values" rows="8" placeholder="spam1@beispiel.de spam2@beispiel.de spam-domain.de ..." required style="font-family:monospace;font-size:.82rem"></textarea>
|
||||
<label style="margin-top:10px">Grund für alle (optional)</label>
|
||||
<input type="text" name="bulk_reason" placeholder="z.B. Import aus alter Sperrliste">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" onclick="return confirm('Alle eingegebenen Adressen sperren und Tickets schließen?')">Alle sperren</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin_or_permission('manage_business_hours');
|
||||
$pdo = get_db();
|
||||
|
||||
$weekdayNames = [1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 0 => 'Sonntag'];
|
||||
$weekdayOrder = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'save_hours') {
|
||||
$enabled = !empty($_POST['business_hours_enabled']) ? '1' : '0';
|
||||
set_setting('business_hours_enabled', $enabled);
|
||||
|
||||
$stmt = $pdo->prepare('UPDATE business_hours SET is_open=?, open_time=?, close_time=? WHERE weekday=?');
|
||||
foreach ($weekdayOrder as $wd) {
|
||||
$isOpen = !empty($_POST["open_{$wd}"]) ? 1 : 0;
|
||||
$openTime = trim($_POST["from_{$wd}"] ?? '09:00') . ':00';
|
||||
$closeTime = trim($_POST["to_{$wd}"] ?? '17:00') . ':00';
|
||||
$stmt->execute([$isOpen, $openTime, $closeTime, $wd]);
|
||||
}
|
||||
audit_log($staff['id'], 'Geschäftszeiten aktualisiert');
|
||||
flash('success', 'Geschäftszeiten gespeichert.');
|
||||
redirect('/staff/business_hours.php');
|
||||
}
|
||||
|
||||
if ($action === 'add_holiday') {
|
||||
$date = trim($_POST['holiday_date'] ?? '');
|
||||
$name = trim($_POST['holiday_name'] ?? '');
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$errors[] = 'Bitte ein gültiges Datum angeben.';
|
||||
} elseif ($name === '') {
|
||||
$errors[] = 'Bitte einen Namen für den Feiertag angeben.';
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)')->execute([$date, $name]);
|
||||
audit_log($staff['id'], "Feiertag hinzugefügt: {$name} ({$date})");
|
||||
flash('success', 'Feiertag hinzugefügt.');
|
||||
redirect('/staff/business_hours.php?tab=holidays');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete_holiday') {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('DELETE FROM business_holidays WHERE id = ?')->execute([$id]);
|
||||
redirect('/staff/business_hours.php?tab=holidays');
|
||||
}
|
||||
|
||||
if ($action === 'import_holidays') {
|
||||
$stateCode = $_POST['state_code'] ?? '';
|
||||
$importYear = (int) ($_POST['import_year'] ?? 0);
|
||||
if (!isset(GERMAN_STATE_NAMES[$stateCode])) {
|
||||
$errors[] = 'Bitte ein gültiges Bundesland auswählen.';
|
||||
} elseif ($importYear < 2000 || $importYear > 2100) {
|
||||
$errors[] = 'Bitte ein gültiges Jahr auswählen.';
|
||||
} else {
|
||||
$newHolidays = german_public_holidays($stateCode, $importYear);
|
||||
$existingDates = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
|
||||
$insertStmt = $pdo->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)');
|
||||
$added = 0;
|
||||
$skipped = 0;
|
||||
foreach ($newHolidays as $h) {
|
||||
if (isset($existingDates[$h['date']])) { $skipped++; continue; }
|
||||
$insertStmt->execute([$h['date'], $h['name']]);
|
||||
$existingDates[$h['date']] = true;
|
||||
$added++;
|
||||
}
|
||||
$stateName = GERMAN_STATE_NAMES[$stateCode];
|
||||
audit_log($staff['id'], "Feiertage {$importYear} für {$stateName} importiert ({$added} hinzugefügt, {$skipped} bereits vorhanden)");
|
||||
flash('success', "{$added} Feiertag(e) für {$stateName} {$importYear} hinzugefügt" . ($skipped ? ", {$skipped} waren bereits vorhanden." : '.'));
|
||||
redirect('/staff/business_hours.php?tab=holidays');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$hoursEnabled = get_setting('business_hours_enabled', '0') === '1';
|
||||
$hoursByWeekday = [];
|
||||
foreach ($pdo->query('SELECT * FROM business_hours')->fetchAll() as $r) $hoursByWeekday[(int) $r['weekday']] = $r;
|
||||
$holidays = $pdo->query('SELECT * FROM business_holidays ORDER BY holiday_date')->fetchAll();
|
||||
|
||||
$pageTitle = 'Geschäftszeiten';
|
||||
$activePage = 'business_hours';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<h1>Geschäftszeiten</h1>
|
||||
<p class="muted">Legt fest, innerhalb welcher Zeiten die SLA-Fristen (Reaktion/Lösung) laufen. Bei deaktivierten Geschäftszeiten gelten die Fristen wie bisher rund um die Uhr. <strong>Achtung:</strong> Wenige Stunden pro Tag (z. B. nur abends) lassen die Fälligkeit in <a href="/staff/priorities.php">Prioritäten & SLA-Zeiten</a> entsprechend weiter in die Zukunft rücken – eine 72-Std-Lösungsfrist kann bei 3 offenen Stunden/Tag mehrere Wochen dauern.</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php
|
||||
$startTab = (($_GET['tab'] ?? '') === 'holidays' || in_array($_POST['action'] ?? '', ['add_holiday', 'import_holidays'], true)) ? 'holidays' : 'hours';
|
||||
?>
|
||||
<div class="card" style="padding:0">
|
||||
<?php ui_tab_bar(['hours' => 'Wochenzeiten', 'holidays' => 'Feiertage (' . count($holidays) . ')']); ?>
|
||||
|
||||
<div class="tab-pane" data-pane="hours" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Öffnungszeiten je Wochentag</h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_hours">
|
||||
|
||||
<label style="display:flex; align-items:center; gap:6px; font-weight:600; margin-bottom:14px">
|
||||
<input type="checkbox" name="business_hours_enabled" style="width:auto" <?= $hoursEnabled ? 'checked' : '' ?>>
|
||||
Geschäftszeiten für SLA-Berechnung verwenden
|
||||
</label>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Tag</th><th>Geöffnet</th><th>Von</th><th>Bis</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($weekdayOrder as $wd):
|
||||
$h = $hoursByWeekday[$wd] ?? ['is_open' => 0, 'open_time' => '09:00:00', 'close_time' => '17:00:00'];
|
||||
?>
|
||||
<tr>
|
||||
<td><?= $weekdayNames[$wd] ?></td>
|
||||
<td><input type="checkbox" name="open_<?= $wd ?>" style="width:auto" <?= $h['is_open'] ? 'checked' : '' ?>></td>
|
||||
<td><input type="time" name="from_<?= $wd ?>" value="<?= substr($h['open_time'], 0, 5) ?>" style="max-width:130px"></td>
|
||||
<td><input type="time" name="to_<?= $wd ?>" value="<?= substr($h['close_time'], 0, 5) ?>" style="max-width:130px"></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Änderungen speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="holidays" style="padding:16px 20px;display:none">
|
||||
<h3 style="margin-top:0">Feiertage</h3>
|
||||
<p class="muted">An diesen Tagen laufen keine SLA-Fristen, unabhängig vom Wochentag.</p>
|
||||
|
||||
<div style="background:var(--primary-lt);border-radius:8px;padding:12px 14px;margin-bottom:14px">
|
||||
<form method="post" style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="import_holidays">
|
||||
<div>
|
||||
<label for="state_code" style="font-size:.78rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Bundesland</label>
|
||||
<select id="state_code" name="state_code" required>
|
||||
<option value="">– auswählen –</option>
|
||||
<?php foreach (GERMAN_STATE_NAMES as $code => $name): ?>
|
||||
<option value="<?= e($code) ?>"><?= e($name) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="import_year" style="font-size:.78rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Jahr</label>
|
||||
<select id="import_year" name="import_year" required>
|
||||
<?php $curYear = (int) date('Y'); foreach ([$curYear, $curYear + 1, $curYear + 2] as $yr): ?>
|
||||
<option value="<?= $yr ?>"><?= $yr ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn secondary" style="white-space:nowrap">Feiertage automatisch übernehmen</button>
|
||||
</form>
|
||||
<p class="muted" style="font-size:.76rem;margin:8px 0 0">Trägt die gesetzlichen Feiertage des gewählten Bundeslandes für das gewählte Jahr ein (inkl. beweglicher Feiertage wie Ostern). Bereits vorhandene Daten werden übersprungen, nichts wird doppelt angelegt.</p>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Datum</th><th>Name</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($holidays as $h): ?>
|
||||
<tr>
|
||||
<td><?= e(date('d.m.Y', strtotime($h['holiday_date']))) ?></td>
|
||||
<td><?= e($h['name']) ?></td>
|
||||
<td>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Feiertag entfernen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_holiday">
|
||||
<input type="hidden" name="id" value="<?= $h['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">✕</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$holidays): ?>
|
||||
<tr><td colspan="3" class="muted">Noch keine Feiertage eingetragen.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="post" style="margin-top:14px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="add_holiday">
|
||||
<label for="holiday_date">Datum</label>
|
||||
<input type="date" id="holiday_date" name="holiday_date" required>
|
||||
<label for="holiday_name">Name</label>
|
||||
<input type="text" id="holiday_name" name="holiday_name" placeholder="z.B. Tag der Deutschen Einheit" required>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn secondary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<?php ui_tab_assets($startTab); ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$departments = $pdo->query('SELECT * FROM departments ORDER BY name')->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$body = sanitize_staff_html(trim($_POST['body'] ?? ''));
|
||||
$deptId = $_POST['department_id'] !== '' ? (int) $_POST['department_id'] : null;
|
||||
|
||||
if ($title === '') $errors[] = 'Bitte einen Titel angeben.';
|
||||
if (trim(strip_tags($body)) === '') $errors[] = 'Bitte einen Text angeben.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)')
|
||||
->execute([$title, $body, $deptId, db_now()]);
|
||||
audit_log($admin['id'], "Textbaustein erstellt: {$title}");
|
||||
flash('success', 'Textbaustein erstellt.');
|
||||
} else {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('UPDATE canned_responses SET title=?, body=?, department_id=? WHERE id=?')
|
||||
->execute([$title, $body, $deptId, $id]);
|
||||
audit_log($admin['id'], "Textbaustein aktualisiert: {$title}");
|
||||
flash('success', 'Textbaustein aktualisiert.');
|
||||
}
|
||||
redirect('/staff/canned_responses.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('DELETE FROM canned_responses WHERE id = ?')->execute([$id]);
|
||||
audit_log($admin['id'], "Textbaustein gelöscht: #{$id}");
|
||||
flash('success', 'Textbaustein gelöscht.');
|
||||
redirect('/staff/canned_responses.php');
|
||||
}
|
||||
}
|
||||
|
||||
$responses = $pdo->query(
|
||||
'SELECT cr.*, d.name AS department_name FROM canned_responses cr
|
||||
LEFT JOIN departments d ON d.id = cr.department_id
|
||||
ORDER BY cr.title'
|
||||
)->fetchAll();
|
||||
|
||||
$editId = $_GET['edit'] ?? null;
|
||||
$editResponse = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM canned_responses WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editResponse = $stmt->fetch();
|
||||
}
|
||||
$showForm = isset($_GET['new']) || $editResponse;
|
||||
|
||||
$pageTitle = 'Vorlagen';
|
||||
$activePage = 'canned';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<h1>Vorlagen / Textbausteine</h1>
|
||||
<p class="muted">Mitarbeiter können diese Textbausteine beim Beantworten eines Tickets per Klick in die Antwort einfügen.</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex; gap:24px; align-items:flex-start">
|
||||
<div style="flex:2; min-width:0">
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<div class="card">
|
||||
<h3><?= $editResponse ? 'Textbaustein bearbeiten' : 'Neuer Textbaustein' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editResponse ? 'update' : 'create' ?>">
|
||||
<?php if ($editResponse): ?><input type="hidden" name="id" value="<?= $editResponse['id'] ?>"><?php endif; ?>
|
||||
<label for="title">Titel</label>
|
||||
<input type="text" id="title" name="title" value="<?= e($editResponse['title'] ?? '') ?>" placeholder="z.B. Begrüßung" required>
|
||||
<label for="department_id">Abteilung (optional)</label>
|
||||
<select name="department_id" id="department_id">
|
||||
<option value="">Alle Abteilungen</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= ($editResponse['department_id'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="body">Text</label>
|
||||
<div style="margin-bottom:6px">
|
||||
<span style="font-size:.75rem;color:var(--muted)">Verfügbare Variablen — klicken zum Einfügen:</span>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">
|
||||
<?php foreach ([
|
||||
'{{kundename}}','{{vorname}}','{{email}}','{{ticketnummer}}',
|
||||
'{{betreff}}','{{abteilung}}','{{prioritaet}}','{{status}}',
|
||||
'{{mitarbeiter}}','{{datum}}','{{uhrzeit}}','{{signatur}}',
|
||||
] as $var): ?>
|
||||
<button type="button" onclick="insertVar(<?= json_encode($var) ?>)"
|
||||
style="font-size:.72rem;padding:2px 8px;border:1px solid var(--primary);border-radius:20px;background:transparent;color:var(--primary);cursor:pointer;font-family:monospace">
|
||||
<?= e($var) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="body" name="body" data-richtext style="min-height:160px" required><?= e($editResponse['body'] ?? '') ?></textarea>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<a href="/staff/canned_responses.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="topbar" style="margin-bottom:10px"><h3 style="margin:0">Alle Textbausteine</h3><a href="/staff/canned_responses.php?new=1" class="btn secondary">+ Neuer Textbaustein</a></div>
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Abteilung</th><th>Text (Vorschau)</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($responses as $r): ?>
|
||||
<tr>
|
||||
<td><?= e($r['title']) ?></td>
|
||||
<td><?= e($r['department_name'] ?? 'Alle') ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?php
|
||||
$preview = mb_strimwidth(strip_tags($r['body']), 0, 70, '…');
|
||||
// Variablen farbig markieren
|
||||
echo preg_replace('/\{\{[^}]+\}\}/', '<code style="background:rgba(15,111,106,.1);color:var(--primary);border-radius:3px;padding:0 3px;font-size:.75rem">$0</code>', e($preview));
|
||||
?></td>
|
||||
<td>
|
||||
<a href="/staff/canned_responses.php?edit=<?= $r['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Textbaustein wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $r['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function insertVar(varName) {
|
||||
// Suche nach contenteditable (richtext) oder textarea
|
||||
var editable = document.querySelector('.richtext-editable');
|
||||
if (editable) {
|
||||
editable.focus();
|
||||
document.execCommand('insertText', false, varName);
|
||||
// Textarea-Wert syncen
|
||||
var ta = document.getElementById('body');
|
||||
if (ta) ta.value = editable.innerHTML;
|
||||
return;
|
||||
}
|
||||
var ta = document.getElementById('body');
|
||||
if (!ta) return;
|
||||
var start = ta.selectionStart;
|
||||
var end = ta.selectionEnd;
|
||||
ta.value = ta.value.substring(0, start) + varName + ta.value.substring(end);
|
||||
ta.selectionStart = ta.selectionEnd = start + varName.length;
|
||||
ta.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
/** Vorschaubild eines Katalogeintrags (nur Admins): /staff/catalog_preview.php?kind=plugin|theme&slug=... */
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_admin();
|
||||
session_write_close();
|
||||
|
||||
$img = catalog_preview_image((string) ($_GET['kind'] ?? ''), (string) ($_GET['slug'] ?? ''));
|
||||
if (!$img) { http_response_code(404); exit; }
|
||||
header('Content-Type: ' . $img['mime']);
|
||||
header('Content-Length: ' . strlen($img['data']));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: private, max-age=86400');
|
||||
echo $img['data'];
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
$withId = (int) ($_GET['with'] ?? 0);
|
||||
|
||||
// Alle Mitarbeiter für Kontaktliste
|
||||
$allStaff = $pdo->query('SELECT id, name, is_active FROM staff ORDER BY name')->fetchAll();
|
||||
|
||||
// Ungelesene Nachrichten je Absender
|
||||
$unreadStmt = $pdo->prepare(
|
||||
'SELECT from_staff_id, COUNT(*) AS cnt FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0 GROUP BY from_staff_id'
|
||||
);
|
||||
$unreadStmt->execute([$staff['id']]);
|
||||
$unreadByStaff = [];
|
||||
foreach ($unreadStmt->fetchAll() as $r) {
|
||||
$unreadByStaff[$r['from_staff_id']] = (int)$r['cnt'];
|
||||
}
|
||||
|
||||
$conversation = [];
|
||||
$withStaff = null;
|
||||
|
||||
if ($withId) {
|
||||
$s = $pdo->prepare('SELECT * FROM staff WHERE id=?');
|
||||
$s->execute([$withId]);
|
||||
$withStaff = $s->fetch();
|
||||
|
||||
// Als gelesen markieren
|
||||
$pdo->prepare('UPDATE staff_chat_messages SET is_read=1 WHERE from_staff_id=? AND to_staff_id=?')
|
||||
->execute([$withId, $staff['id']]);
|
||||
|
||||
// Konversation laden
|
||||
$cStmt = $pdo->prepare(
|
||||
"SELECT m.*, sf.name AS from_name
|
||||
FROM staff_chat_messages m
|
||||
JOIN staff sf ON sf.id = m.from_staff_id
|
||||
WHERE (m.from_staff_id=? AND m.to_staff_id=?)
|
||||
OR (m.from_staff_id=? AND m.to_staff_id=?)
|
||||
ORDER BY m.created_at ASC LIMIT 200"
|
||||
);
|
||||
$cStmt->execute([$staff['id'], $withId, $withId, $staff['id']]);
|
||||
$conversation = $cStmt->fetchAll();
|
||||
}
|
||||
|
||||
// Nachricht senden (AJAX oder normal)
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$toId = (int) ($_POST['to_id'] ?? 0);
|
||||
$msg = trim($_POST['message'] ?? '');
|
||||
if ($toId && $msg !== '') {
|
||||
$pdo->prepare('INSERT INTO staff_chat_messages (from_staff_id, to_staff_id, message, created_at) VALUES (?,?,?,?)')
|
||||
->execute([$staff['id'], $toId, $msg, db_now()]);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
redirect('/staff/chat.php?with=' . $toId);
|
||||
}
|
||||
|
||||
// AJAX: neue Nachrichten pollen
|
||||
if (($_GET['action'] ?? '') === 'poll' && $withId) {
|
||||
header('Content-Type: application/json');
|
||||
$since = $_GET['since'] ?? '1970-01-01 00:00:00';
|
||||
$pStmt = $pdo->prepare(
|
||||
"SELECT m.*, sf.name AS from_name FROM staff_chat_messages m
|
||||
JOIN staff sf ON sf.id = m.from_staff_id
|
||||
WHERE ((m.from_staff_id=? AND m.to_staff_id=?) OR (m.from_staff_id=? AND m.to_staff_id=?))
|
||||
AND m.created_at > ?
|
||||
ORDER BY m.created_at ASC"
|
||||
);
|
||||
$pStmt->execute([$staff['id'], $withId, $withId, $staff['id'], $since]);
|
||||
$pdo->prepare('UPDATE staff_chat_messages SET is_read=1 WHERE from_staff_id=? AND to_staff_id=?')
|
||||
->execute([$withId, $staff['id']]);
|
||||
echo json_encode($pStmt->fetchAll());
|
||||
exit;
|
||||
}
|
||||
|
||||
$pageTitle = 'Interner Chat';
|
||||
$activePage = 'chat';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
.ac-wrap{display:flex;height:calc(100vh - 56px);margin:-32px -28px -60px;overflow:hidden;background:#fff;border-top:1px solid #e5e7eb}
|
||||
.ac-main{flex:1;display:flex;flex-direction:column;min-width:0;background:#fff}
|
||||
.ac-topbar{padding:12px 18px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:12px;background:#fff;flex-shrink:0}
|
||||
.ac-topbar-av{width:38px;height:38px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.88rem;flex-shrink:0}
|
||||
.ac-msgs{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:0}
|
||||
.ac-msg{display:flex;align-items:flex-start;gap:10px;padding:4px 0}
|
||||
.ac-msg-av{width:34px;height:34px;border-radius:50%;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.78rem;flex-shrink:0;margin-top:14px}
|
||||
.ac-msg-body{display:flex;flex-direction:column;min-width:0}
|
||||
.ac-msg-name{font-size:.7rem;color:#9ca3af;margin-bottom:2px}
|
||||
.ac-msg-bubble{display:inline-block;padding:7px 12px;border-radius:18px;font-size:.88rem;line-height:1.45;word-break:break-word;max-width:480px}
|
||||
.ac-msg.me{flex-direction:row-reverse}
|
||||
.ac-msg.me .ac-msg-av{display:none}
|
||||
.ac-msg.me .ac-msg-body{align-items:flex-end}
|
||||
.ac-msg.me .ac-msg-bubble{background:var(--primary);color:#fff}
|
||||
.ac-msg.them .ac-msg-bubble{background:#f3f4f6;color:#111827}
|
||||
.ac-msg-time{font-size:.65rem;color:#d1d5db;margin-top:2px;padding:0 2px}
|
||||
.ac-inp-wrap{border-top:1px solid #e5e7eb;padding:10px 14px;display:flex;align-items:center;gap:10px;background:#fff;flex-shrink:0}
|
||||
.ac-inp{flex:1;border:none;background:transparent;font-size:.9rem;font-family:var(--font-body);color:#111827;outline:none;padding:4px 0}
|
||||
.ac-inp::placeholder{color:#9ca3af}
|
||||
.ac-inp-btn{width:34px;height:34px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1.1rem;transition:all .15s;background:none;color:#9ca3af}
|
||||
.ac-inp-btn:hover{color:var(--primary)}
|
||||
.ac-send{background:var(--primary);color:#fff;width:34px;height:34px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s}
|
||||
.ac-send:hover{background:var(--primary-dk)}
|
||||
.ac-side{width:220px;flex-shrink:0;border-left:1px solid #e5e7eb;display:flex;flex-direction:column;background:#fff}
|
||||
.ac-side-title{padding:14px 16px;font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9ca3af;border-bottom:1px solid #e5e7eb}
|
||||
.ac-side-list{flex:1;overflow-y:auto;padding:8px 0}
|
||||
.ac-user{display:flex;align-items:center;gap:10px;padding:8px 14px;text-decoration:none;border-radius:0;transition:background .12s;cursor:pointer}
|
||||
.ac-user:hover{background:#f9fafb}
|
||||
.ac-user.on{background:#f0fdf4}
|
||||
.ac-user-av{width:36px;height:36px;border-radius:50%;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.82rem;flex-shrink:0;position:relative}
|
||||
.ac-user-av::after{content:'';position:absolute;bottom:1px;right:1px;width:9px;height:9px;border-radius:50%;background:#22c55e;border:2px solid #fff}
|
||||
.ac-user-av.off::after{background:#d1d5db}
|
||||
.ac-user-name{font-size:.84rem;font-weight:500;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ac-user-sub{font-size:.71rem;color:#9ca3af}
|
||||
.ac-bdg{background:var(--danger);color:#fff;border-radius:10px;min-width:18px;height:18px;font-size:.65rem;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 4px;margin-left:auto}
|
||||
.ac-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#9ca3af;gap:10px}
|
||||
.ep{position:relative}
|
||||
.eg{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:8px;display:none;grid-template-columns:repeat(8,1fr);gap:2px;box-shadow:0 4px 16px rgba(0,0,0,.1);z-index:100;width:272px}
|
||||
.eg.open{display:grid}
|
||||
.eb{font-size:1.2rem;padding:5px;border:none;background:none;cursor:pointer;border-radius:6px;line-height:1}
|
||||
.eb:hover{background:#f3f4f6}
|
||||
|
||||
/* Avatar-Farben je Initiale */
|
||||
.av-a{background:#6366f1}.av-b{background:#8b5cf6}.av-c{background:#ec4899}.av-d{background:#14b8a6}
|
||||
.av-e{background:#f59e0b}.av-f{background:#10b981}.av-g{background:#3b82f6}.av-h{background:#ef4444}
|
||||
.av-i{background:#6366f1}.av-j{background:#8b5cf6}.av-k{background:#ec4899}.av-l{background:#14b8a6}
|
||||
.av-m{background:#f59e0b}.av-n{background:#10b981}.av-o{background:#3b82f6}.av-p{background:#ef4444}
|
||||
.av-q{background:#6366f1}.av-r{background:#8b5cf6}.av-s{background:#ec4899}.av-t{background:var(--primary)}
|
||||
.av-u{background:#f59e0b}.av-v{background:#10b981}.av-w{background:#3b82f6}.av-x{background:#ef4444}
|
||||
.av-y{background:#6366f1}.av-z{background:#8b5cf6}
|
||||
</style>
|
||||
|
||||
<?php
|
||||
function chatAvClass(string $name): string {
|
||||
$l = strtolower(mb_substr($name, 0, 1));
|
||||
return 'av-' . (ctype_alpha($l) ? $l : 'a');
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="ac-wrap">
|
||||
|
||||
<!-- Hauptbereich -->
|
||||
<div class="ac-main">
|
||||
|
||||
<?php if (!$withStaff): ?>
|
||||
<div class="ac-empty">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<div style="font-size:.9rem;font-weight:600;color:#374151">Mitarbeiter auswählen</div>
|
||||
<div style="font-size:.8rem;color:#9ca3af">Klicke rechts auf einen Mitarbeiter</div>
|
||||
</div>
|
||||
|
||||
<?php else:
|
||||
$ini2 = mb_strtoupper(mb_substr($withStaff['name'], 0, 1));
|
||||
$cls2 = chatAvClass($withStaff['name']);
|
||||
?>
|
||||
|
||||
<!-- Topbar -->
|
||||
<div class="ac-topbar">
|
||||
<div class="ac-topbar-av <?= $cls2 ?>"><?= e($ini2) ?></div>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:.93rem;color:#111827;line-height:1"><?= e($withStaff['name']) ?></div>
|
||||
<div style="font-size:.74rem;color:#9ca3af;margin-top:2px"><?= e($withStaff['email']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nachrichten -->
|
||||
<div id="chat-messages" class="ac-msgs">
|
||||
<?php if (!$conversation): ?>
|
||||
<div style="text-align:center;color:#9ca3af;font-size:.82rem;padding:30px 0;margin:auto">Noch keine Nachrichten – schreib etwas!</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$prevDate = '';
|
||||
foreach ($conversation as $msg):
|
||||
$isMe = $msg['from_staff_id'] == $staff['id'];
|
||||
$ini = mb_strtoupper(mb_substr($msg['from_name'], 0, 1));
|
||||
$cls = chatAvClass($msg['from_name']);
|
||||
$date = substr($msg['created_at'], 0, 10);
|
||||
if ($date !== $prevDate):
|
||||
$prevDate = $date;
|
||||
?>
|
||||
<div style="text-align:center;font-size:.72rem;color:#9ca3af;padding:12px 0"><?= date('d.m.Y', strtotime($date)) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="ac-msg <?= $isMe?'me':'them' ?>" data-ts="<?= e($msg['created_at']) ?>">
|
||||
<?php if (!$isMe): ?>
|
||||
<div class="ac-msg-av <?= $cls ?>"><?= e($ini) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="ac-msg-body">
|
||||
<?php if (!$isMe): ?><div class="ac-msg-name"><?= e($msg['from_name']) ?></div><?php endif; ?>
|
||||
<div class="ac-msg-bubble"><?= nl2br(e($msg['message'])) ?></div>
|
||||
<div class="ac-msg-time"><?= date('H:i', strtotime($msg['created_at'])) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Eingabe -->
|
||||
<div class="ac-inp-wrap">
|
||||
<div class="ep">
|
||||
<button type="button" class="ac-inp-btn" id="emoji-toggle" title="Emoji">🙂</button>
|
||||
<div class="eg" id="emoji-grid">
|
||||
<?php foreach(['😀','😂','😍','😎','🤔','😅','🙏','👍','👎','❤️','🔥','✅','❌','⚠️','🎉','💯','😊','😢','😡','🤣','😇','🥳','😴','🤝','💪','👏','🚀','💡','📌','✏️','📞','📧','🔒','🔑','⏰','📅'] as $em): ?>
|
||||
<button type="button" class="eb" onclick="insertEmoji('<?= $em ?>')"><?= $em ?></button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<form id="chat-form" style="display:contents">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="to_id" value="<?= $withId ?>">
|
||||
<input type="text" id="chat-input" name="message" class="ac-inp" placeholder="Aa"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();sendMsg();}">
|
||||
<button type="button" class="ac-send" onclick="sendMsg()" title="Senden">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Sidebar: Online-Mitarbeiter -->
|
||||
<div class="ac-side">
|
||||
<div class="ac-side-title">Online Mitarbeiter</div>
|
||||
<div class="ac-side-list">
|
||||
<?php foreach ($allStaff as $s): if ($s['id'] == $staff['id']) continue;
|
||||
$unread = $unreadByStaff[$s['id']] ?? 0;
|
||||
$ini = mb_strtoupper(mb_substr($s['name'], 0, 1));
|
||||
$cls = chatAvClass($s['name']);
|
||||
?>
|
||||
<a href="/staff/chat.php?with=<?= $s['id'] ?>" class="ac-user <?= $withId==$s['id']?'on':'' ?>">
|
||||
<div class="ac-user-av <?= $cls ?> <?= !$s['is_active']?'off':'' ?>"><?= e($ini) ?></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="ac-user-name"><?= e($s['name']) ?></div>
|
||||
<div class="ac-user-sub"><?= $s['is_active']?'Online':'Offline' ?></div>
|
||||
</div>
|
||||
<?php if ($unread): ?><div class="ac-bdg"><?= $unread>9?'9+':$unread ?></div><?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php if ($withStaff): ?>
|
||||
<script>
|
||||
var myId = <?= $staff['id'] ?>;
|
||||
var withId = <?= $withId ?>;
|
||||
var csrfToken = document.querySelector('[name=csrf_token]').value;
|
||||
// Token nach Rotation aktualisieren
|
||||
function getCsrf() { return document.querySelector('[name=csrf_token]').value; }
|
||||
var lastTs = <?= $conversation ? json_encode(end($conversation)['created_at']) : json_encode('1970-01-01 00:00:00') ?>;
|
||||
|
||||
function insertEmoji(em) {
|
||||
var input = document.getElementById('chat-input');
|
||||
var pos = input.selectionStart || input.value.length;
|
||||
input.value = input.value.slice(0, pos) + em + input.value.slice(pos);
|
||||
input.focus();
|
||||
document.getElementById('emoji-grid').classList.remove('open');
|
||||
}
|
||||
|
||||
document.getElementById('emoji-toggle')?.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('emoji-grid').classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', function() {
|
||||
document.getElementById('emoji-grid')?.classList.remove('open');
|
||||
});
|
||||
|
||||
function scrollBottom() {
|
||||
var el = document.getElementById('chat-messages');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
scrollBottom();
|
||||
|
||||
function appendBubble(msg) {
|
||||
var isMe = msg.from_staff_id == myId;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'chat-bubble ' + (isMe ? 'me' : 'them');
|
||||
div.dataset.ts = msg.created_at;
|
||||
div.innerHTML = '<div class="bubble-text">' + msg.message.replace(/&/g,'&').replace(/</g,'<').replace(/\n/g,'<br>') + '</div>'
|
||||
+ '<div class="bubble-time">' + msg.created_at + '</div>';
|
||||
document.getElementById('chat-messages').appendChild(div);
|
||||
lastTs = msg.created_at;
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
function sendMsg() {
|
||||
var input = document.getElementById('chat-input');
|
||||
var msg = input.value.trim();
|
||||
if (!msg) return;
|
||||
input.value = '';
|
||||
input.focus();
|
||||
fetch('/staff/chat.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
|
||||
body: 'csrf_token=' + encodeURIComponent(getCsrf()) + '&to_id=' + withId + '&message=' + encodeURIComponent(msg)
|
||||
}).then(function() {
|
||||
appendBubble({from_staff_id: myId, message: msg, created_at: new Date().toISOString().slice(0,19).replace('T',' ')});
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/staff/chat.php?with=' + withId + '&action=poll&since=' + encodeURIComponent(lastTs))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(msgs) {
|
||||
msgs.forEach(function(m) {
|
||||
if (m.from_staff_id != myId) appendBubble(m);
|
||||
lastTs = m.created_at;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(poll, 8000);
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$fieldTypes = [
|
||||
'text' => 'Einzeiliger Text',
|
||||
'textarea' => 'Mehrzeiliger Text',
|
||||
'select' => 'Dropdown-Auswahl',
|
||||
'checkbox' => 'Checkbox (Ja/Nein)',
|
||||
'number' => 'Zahl',
|
||||
'date' => 'Datum',
|
||||
];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$label = trim($_POST['label'] ?? '');
|
||||
$type = array_key_exists($_POST['field_type'] ?? '', $fieldTypes) ? $_POST['field_type'] : 'text';
|
||||
$optionsRaw = trim($_POST['options'] ?? '');
|
||||
$helpTopicId = ($_POST['help_topic_id'] ?? '') !== '' ? (int) $_POST['help_topic_id'] : null;
|
||||
$isRequired = !empty($_POST['is_required']) ? 1 : 0;
|
||||
$isActive = !empty($_POST['is_active']) ? 1 : 0;
|
||||
$sort = (int) ($_POST['sort_order'] ?? 0);
|
||||
|
||||
if ($label === '') $errors[] = 'Bitte eine Bezeichnung angeben.';
|
||||
if ($type === 'select' && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?)')
|
||||
->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, db_now()]);
|
||||
audit_log($admin['id'], "Ticket-Feld erstellt: {$label}");
|
||||
flash('success', 'Feld erstellt.');
|
||||
} else {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=? WHERE id=?')
|
||||
->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $id]);
|
||||
audit_log($admin['id'], "Ticket-Feld aktualisiert: {$label}");
|
||||
flash('success', 'Feld aktualisiert.');
|
||||
}
|
||||
redirect('/staff/custom_fields.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
$labelStmt = $pdo->prepare('SELECT label FROM custom_fields WHERE id = ?');
|
||||
$labelStmt->execute([$id]);
|
||||
$delLabel = $labelStmt->fetchColumn();
|
||||
$pdo->prepare('DELETE FROM custom_fields WHERE id = ?')->execute([$id]);
|
||||
audit_log($admin['id'], "Ticket-Feld gelöscht: {$delLabel}");
|
||||
flash('success', 'Feld gelöscht (bereits erfasste Werte bleiben in bestehenden Tickets sichtbar).');
|
||||
redirect('/staff/custom_fields.php');
|
||||
}
|
||||
}
|
||||
|
||||
$fields = $pdo->query(
|
||||
'SELECT cf.*, ht.name AS help_topic_name FROM custom_fields cf
|
||||
LEFT JOIN help_topics ht ON ht.id = cf.help_topic_id
|
||||
ORDER BY cf.sort_order, cf.label'
|
||||
)->fetchAll();
|
||||
$helpTopics = $pdo->query('SELECT * FROM help_topics ORDER BY name')->fetchAll();
|
||||
|
||||
$editId = $_GET['edit'] ?? null;
|
||||
$editField = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM custom_fields WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editField = $stmt->fetch();
|
||||
}
|
||||
|
||||
$pageTitle = 'Ticket-Felder';
|
||||
$activePage = 'custom_fields';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<h1>Individuelle Ticket-Felder</h1>
|
||||
<p class="muted">Diese Felder erscheinen zusätzlich zu Name, E-Mail, Betreff und Beschreibung auf dem Formular „Neues Ticket erstellen“. Die eingegebenen Werte werden im Ticket angezeigt.</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex; gap:24px; align-items:flex-start">
|
||||
<div style="flex:2">
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Bezeichnung</th><th>Typ</th><th>Hilfe-Thema</th><th>Pflichtfeld</th><th>Aktiv</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($fields as $f): ?>
|
||||
<tr>
|
||||
<td><?= e($f['label']) ?></td>
|
||||
<td><?= e($fieldTypes[$f['field_type']] ?? $f['field_type']) ?></td>
|
||||
<td class="muted"><?= $f['help_topic_name'] ? e($f['help_topic_name']) : 'Alle Themen' ?></td>
|
||||
<td><?= $f['is_required'] ? '✓' : '–' ?></td>
|
||||
<td><?= $f['is_active'] ? '✓' : '–' ?></td>
|
||||
<td>
|
||||
<a href="/staff/custom_fields.php?edit=<?= $f['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Feld wirklich löschen? Bereits erfasste Werte in bestehenden Tickets bleiben erhalten.');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $f['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$fields): ?>
|
||||
<tr><td colspan="6" class="muted">Noch keine individuellen Felder angelegt.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1; min-width:300px">
|
||||
<div class="card">
|
||||
<h3><?= $editField ? 'Feld bearbeiten' : 'Neues Feld' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editField ? 'update' : 'create' ?>">
|
||||
<?php if ($editField): ?><input type="hidden" name="id" value="<?= $editField['id'] ?>"><?php endif; ?>
|
||||
|
||||
<label for="label">Bezeichnung</label>
|
||||
<input type="text" id="label" name="label" value="<?= e($editField['label'] ?? '') ?>" placeholder="z. B. Vertragsnummer" required>
|
||||
|
||||
<label for="field_type">Feldtyp</label>
|
||||
<select name="field_type" id="field_type" onchange="document.getElementById('options-row').style.display = this.value==='select' ? 'block' : 'none'">
|
||||
<?php foreach ($fieldTypes as $val => $lbl): ?>
|
||||
<option value="<?= $val ?>" <?= ($editField['field_type'] ?? 'text') === $val ? 'selected' : '' ?>><?= e($lbl) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<div id="options-row" style="display:<?= ($editField['field_type'] ?? '') === 'select' ? 'block' : 'none' ?>">
|
||||
<label for="options">Optionen (eine pro Zeile)</label>
|
||||
<textarea id="options" name="options" placeholder="Option 1 Option 2 Option 3"><?= e($editField['options'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<label for="help_topic_id">Gilt für Hilfe-Thema</label>
|
||||
<select name="help_topic_id" id="help_topic_id">
|
||||
<option value="">Alle Themen (oder Formular ohne Themenauswahl)</option>
|
||||
<?php foreach ($helpTopics as $t): ?>
|
||||
<option value="<?= $t['id'] ?>" <?= (string) ($editField['help_topic_id'] ?? ($_GET['topic'] ?? '')) === (string) $t['id'] ? 'selected' : '' ?>><?= e($t['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="muted" style="font-size:.78rem">Auf „Alle Themen“ belassen, damit das Feld unabhängig vom gewählten Anliegen erscheint.</p>
|
||||
|
||||
<label for="sort_order">Sortierung</label>
|
||||
<input type="text" id="sort_order" name="sort_order" value="<?= e((string) ($editField['sort_order'] ?? '0')) ?>">
|
||||
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:14px">
|
||||
<input type="checkbox" name="is_required" style="width:auto" <?= !empty($editField['is_required']) ? 'checked' : '' ?>>
|
||||
Pflichtfeld
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:8px">
|
||||
<input type="checkbox" name="is_active" style="width:auto" <?= ($editField['is_active'] ?? 1) ? 'checked' : '' ?>>
|
||||
Aktiv (auf dem Ticket-Formular sichtbar)
|
||||
</label>
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn"><?= $editField ? 'Speichern' : 'Erstellen' ?></button>
|
||||
<?php if ($editField): ?><a href="/staff/custom_fields.php" class="btn secondary">Abbrechen</a><?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$sort = (int) ($_POST['sort_order'] ?? 0);
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
// Einträge: eine Zeile = ein Wert; Reihenfolge der Zeilen = Sortierung, doppelte Werte entfallen
|
||||
$values = [];
|
||||
foreach (preg_split('/\R/', (string) ($_POST['items'] ?? '')) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line !== '' && !in_array($line, $values, true)) $values[] = $line;
|
||||
}
|
||||
|
||||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM custom_lists WHERE LOWER(name) = LOWER(?) AND id <> ?');
|
||||
$dup->execute([$name, $id]);
|
||||
if ($dup->fetchColumn() > 0) $errors[] = 'Eine Liste mit diesem Namen existiert bereits.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO custom_lists (name, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?)')->execute([$name, $sort, $notes ?: null, db_now(), db_now()]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
audit_log($staff['id'], "Liste erstellt: {$name}");
|
||||
flash('success', 'Liste erstellt.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE custom_lists SET name=?, sort_order=?, notes=?, updated_at=? WHERE id=?')->execute([$name, $sort, $notes ?: null, db_now(), $id]);
|
||||
audit_log($staff['id'], "Liste aktualisiert: {$name}");
|
||||
flash('success', 'Änderungen gespeichert.');
|
||||
}
|
||||
// Einträge abgleichen: Bestehende behalten (und umsortieren), neue anlegen, entfernte löschen
|
||||
$existing = $pdo->prepare('SELECT id, value FROM custom_list_items WHERE list_id = ?');
|
||||
$existing->execute([$id]);
|
||||
$byValue = array_column($existing->fetchAll(), 'id', 'value');
|
||||
$upd = $pdo->prepare('UPDATE custom_list_items SET sort_order = ? WHERE id = ?');
|
||||
$ins = $pdo->prepare('INSERT INTO custom_list_items (list_id, value, sort_order) VALUES (?,?,?)');
|
||||
foreach ($values as $i => $val) {
|
||||
if (isset($byValue[$val])) { $upd->execute([($i + 1) * 10, $byValue[$val]]); unset($byValue[$val]); }
|
||||
else $ins->execute([$id, $val, ($i + 1) * 10]);
|
||||
}
|
||||
$del = $pdo->prepare('DELETE FROM custom_list_items WHERE id = ?');
|
||||
foreach ($byValue as $itemId) $del->execute([$itemId]);
|
||||
redirect('/staff/custom_lists.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('DELETE FROM custom_list_items WHERE list_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM custom_lists WHERE id = ?')->execute([$id]);
|
||||
audit_log($staff['id'], "Liste gelöscht: #{$id}");
|
||||
flash('success', 'Liste gelöscht.');
|
||||
redirect('/staff/custom_lists.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$lists = $pdo->query('SELECT l.*, (SELECT COUNT(*) FROM custom_list_items i WHERE i.list_id = l.id) AS item_count FROM custom_lists l ORDER BY l.sort_order, l.name')->fetchAll();
|
||||
$editList = null; $editItems = [];
|
||||
if ($editId) {
|
||||
$es = $pdo->prepare('SELECT * FROM custom_lists WHERE id = ?'); $es->execute([$editId]);
|
||||
$editList = $es->fetch() ?: null;
|
||||
$ei = $pdo->prepare('SELECT value FROM custom_list_items WHERE list_id = ? ORDER BY sort_order, value'); $ei->execute([$editId]);
|
||||
$editItems = $ei->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
$pageTitle = 'Benutzerdefinierte Listen'; $activePage = 'custom_lists';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editList[$col] ?? $default);
|
||||
?>
|
||||
<h1>
|
||||
<?= $editList ? 'Liste aktualisieren' : 'Neue Liste' ?>
|
||||
<?php if ($editList): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editList['name']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editList ? 'update' : 'create' ?>">
|
||||
<?php if ($editList): ?><input type="hidden" name="id" value="<?= (int) $editList['id'] ?>"><?php endif; ?>
|
||||
|
||||
<?php ui_tab_bar(['list' => 'Liste', 'items' => 'Einträge']); ?>
|
||||
|
||||
<div class="tab-pane" data-pane="list" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Listeninformationen</h3>
|
||||
<label for="name">Name *</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" placeholder="z. B. Betriebssysteme" required>
|
||||
<label for="sort_order">Sortierung</label>
|
||||
<input type="number" id="sort_order" name="sort_order" value="<?= e((string) $v('sort_order', 'sort_order', '0')) ?>" style="max-width:140px">
|
||||
<h3>Administratornotizen <span class="muted" style="font-weight:400;font-size:.85rem">– Interne Notizen, sichtbar für alle Administratoren</span></h3>
|
||||
<textarea name="notes" rows="5"><?= e($v('notes', 'notes')) ?></textarea>
|
||||
<p class="muted" style="font-size:.82rem">Wiederverwendbare Auswahllisten für Ticket-Felder: Wähle in einem <a href="/staff/custom_fields.php">Ticket-Feld</a> den Typ „Liste“ und verknüpfe diese Liste.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="items" style="padding:16px 20px;display:none">
|
||||
<label for="items" style="margin-top:0">Einträge – ein Wert pro Zeile, die Reihenfolge entspricht der Anzeige</label>
|
||||
<textarea id="items" name="items" rows="14" style="font-family:monospace"><?= e($_POST ? (string) ($_POST['items'] ?? '') : implode("\n", $editItems)) ?></textarea>
|
||||
<p class="muted" style="font-size:.78rem">Entfernte Zeilen werden gelöscht, bereits in Tickets gespeicherte Werte bleiben dort unverändert sichtbar.</p>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editList ? 'Änderungen speichern' : 'Erstellen' ?></button>
|
||||
<a href="/staff/custom_lists.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php ui_tab_assets('list'); ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">Benutzerdefinierte Listen</h1>
|
||||
<a href="/staff/custom_lists.php?new=1" class="btn">Neue Liste hinzufügen</a>
|
||||
</div>
|
||||
<p class="muted">Wiederverwendbare Auswahllisten für Ticket-Felder. Wähle in einem Ticket-Feld den Typ „Liste“ und verknüpfe eine dieser Listen.</p>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th style="text-align:center">Einträge</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($lists as $l): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/custom_lists.php?edit=<?= (int) $l['id'] ?>"><?= e($l['name']) ?></a></td>
|
||||
<td style="text-align:center"><?= (int) $l['item_count'] ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($l['created_at'] ?? null)) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($l['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/custom_lists.php?edit=<?= (int) $l['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Liste wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $l['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$lists): ?><tr><td colspan="5" class="muted">Noch keine Listen angelegt.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
$userId = (int) ($_GET['id'] ?? 0);
|
||||
$guestEmail = trim($_GET['email'] ?? '');
|
||||
$isGuest = false;
|
||||
|
||||
if ($userId > 0) {
|
||||
$stmt = $pdo->prepare('SELECT u.*, o.name AS org_name FROM users u LEFT JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
$customer = $stmt->fetch();
|
||||
} elseif ($guestEmail !== '') {
|
||||
// Gast-Anfrager (kein Konto, nur über Tickets bekannt) – keine Zeile in "users",
|
||||
// daher hier aus den Ticket-Daten zusammengesetzt statt per user_id nachgeschlagen.
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT requester_email AS email, MAX(requester_name) AS name, MIN(created_at) AS created_at,
|
||||
NULL AS phone, NULL AS org_name
|
||||
FROM tickets WHERE requester_email = ? AND user_id IS NULL GROUP BY requester_email"
|
||||
);
|
||||
$stmt->execute([$guestEmail]);
|
||||
$customer = $stmt->fetch();
|
||||
$isGuest = (bool) $customer;
|
||||
} else {
|
||||
$customer = false;
|
||||
}
|
||||
|
||||
if (!$customer) {
|
||||
flash('error', 'Kunde nicht gefunden.');
|
||||
redirect('/staff/customers.php');
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
// Notizen und Kontaktdaten hängen an einem echten Kundenkonto (users-Tabelle) –
|
||||
// für Gast-Anfrager ohne Konto gibt es das nicht, siehe Hinweis oben in der Seite.
|
||||
if ($isGuest && in_array($action, ['add_note', 'delete_note', 'save_customer'], true)) {
|
||||
flash('error', 'Für Gast-Anfrager ohne Konto nicht verfügbar.');
|
||||
redirect('/staff/customer_profile.php?email=' . urlencode($guestEmail));
|
||||
}
|
||||
|
||||
if ($action === 'add_note') {
|
||||
$note = trim($_POST['note'] ?? '');
|
||||
if ($note !== '') {
|
||||
$pdo->prepare('INSERT INTO customer_notes (user_id, staff_id, note, created_at) VALUES (?,?,?,?)')
|
||||
->execute([$userId, $staff['id'], $note, db_now()]);
|
||||
flash('success', 'Notiz gespeichert.');
|
||||
}
|
||||
redirect('/staff/customer_profile.php?id=' . $userId);
|
||||
}
|
||||
|
||||
if ($action === 'delete_note') {
|
||||
$noteId = (int) ($_POST['note_id'] ?? 0);
|
||||
$pdo->prepare('DELETE FROM customer_notes WHERE id=? AND user_id=?')->execute([$noteId, $userId]);
|
||||
redirect('/staff/customer_profile.php?id=' . $userId);
|
||||
}
|
||||
|
||||
if ($action === 'save_customer') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
if ($name !== '') {
|
||||
$pdo->prepare('UPDATE users SET name=?, phone=? WHERE id=?')->execute([$name, $phone ?: null, $userId]);
|
||||
flash('success', 'Kundendaten gespeichert.');
|
||||
}
|
||||
redirect('/staff/customer_profile.php?id=' . $userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Alle Tickets dieses Kunden (Mitarbeiter sehen nur Tickets ihrer Abteilungen)
|
||||
[$deptSql, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
|
||||
$ticketStmt = $pdo->prepare(
|
||||
"SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name
|
||||
FROM tickets t
|
||||
JOIN priorities p ON p.id = t.priority_id
|
||||
JOIN departments d ON d.id = t.department_id
|
||||
LEFT JOIN staff s ON s.id = t.assigned_staff_id
|
||||
WHERE (t.requester_email = ? OR t.user_id = ?) {$deptSql}
|
||||
ORDER BY t.created_at DESC LIMIT 100"
|
||||
);
|
||||
$ticketStmt->execute([$customer['email'], $userId, ...$deptParams]);
|
||||
$tickets = $ticketStmt->fetchAll();
|
||||
|
||||
// Notizen
|
||||
$noteStmt = $pdo->prepare(
|
||||
"SELECT cn.*, COALESCE(s.name, 'Gelöschter Mitarbeiter') AS staff_name
|
||||
FROM customer_notes cn
|
||||
LEFT JOIN staff s ON s.id = cn.staff_id
|
||||
WHERE cn.user_id = ? ORDER BY cn.created_at DESC"
|
||||
);
|
||||
$noteStmt->execute([$userId]);
|
||||
$notes = $noteStmt->fetchAll();
|
||||
|
||||
// Statistiken
|
||||
$totalTickets = count($tickets);
|
||||
$openTickets = count(array_filter($tickets, fn($t) => !in_array($t['status'], ['resolved','closed'])));
|
||||
$closedTickets = count(array_filter($tickets, fn($t) => in_array($t['status'], ['resolved','closed'])));
|
||||
|
||||
$pageTitle = 'Kundenprofil: ' . $customer['name'];
|
||||
$activePage = 'customers';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1><?= e($customer['name']) ?> <?php if ($isGuest): ?><span class="badge" style="background:#64748b; font-size:.7rem; vertical-align:middle">Gast</span><?php endif; ?></h1>
|
||||
<span class="muted" style="font-size:.88rem"><?= e($customer['email']) ?></span>
|
||||
</div>
|
||||
<a href="/staff/customers.php" class="btn secondary">← Zurück</a>
|
||||
</div>
|
||||
<?php if ($isGuest): ?>
|
||||
<div class="flash info" style="margin-bottom:20px">Gast-Anfrager ohne eigenes Kundenkonto – Notizen und Kontaktdaten-Bearbeitung sind daher nicht verfügbar.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display:flex; gap:24px; align-items:flex-start; flex-wrap:wrap">
|
||||
|
||||
<!-- Linke Spalte -->
|
||||
<div style="flex:2; min-width:0">
|
||||
|
||||
<!-- Tickets -->
|
||||
<div class="card">
|
||||
<h3>Tickets (<?= $totalTickets ?>)</h3>
|
||||
<?php if (!$tickets): ?>
|
||||
<div class="empty-state">Keine Tickets vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Ticket</th><th>Betreff</th><th>Status</th><th>Priorität</th><th>Mitarbeiter</th><th>Erstellt</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($tickets as $t):
|
||||
[$label, $color] = status_label($t['status']);
|
||||
?>
|
||||
<tr>
|
||||
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td>
|
||||
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>"><?= e($t['subject']) ?></a></td>
|
||||
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
|
||||
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
|
||||
<td class="muted"><?= e($t['staff_name'] ?? '–') ?></td>
|
||||
<td class="muted"><?= format_dt($t['created_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Notizen -->
|
||||
<?php if (!$isGuest): ?>
|
||||
<div class="card">
|
||||
<h3>Interne Notizen</h3>
|
||||
<form method="post" style="margin-bottom:16px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="add_note">
|
||||
<textarea name="note" placeholder="Notiz hinzufügen…" rows="3" required></textarea>
|
||||
<div class="btn-row"><button type="submit" class="btn">Notiz speichern</button></div>
|
||||
</form>
|
||||
<?php if (!$notes): ?>
|
||||
<p class="muted">Noch keine Notizen.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($notes as $n): ?>
|
||||
<div style="border-bottom:1px solid var(--border-soft); padding:10px 0; display:flex; justify-content:space-between; gap:12px">
|
||||
<div>
|
||||
<div style="font-size:.82rem; color:var(--muted); margin-bottom:4px"><?= e($n['staff_name']) ?> · <?= format_dt($n['created_at']) ?></div>
|
||||
<div style="white-space:pre-wrap"><?= e($n['note']) ?></div>
|
||||
</div>
|
||||
<form method="post" onsubmit="return confirm('Notiz löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_note">
|
||||
<input type="hidden" name="note_id" value="<?= $n['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:var(--muted-lt);cursor:pointer;font-size:.9rem" title="Löschen">✕</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte -->
|
||||
<div style="flex:1; min-width:240px; max-width:280px">
|
||||
|
||||
<!-- Statistiken -->
|
||||
<div class="stat-grid" style="grid-template-columns:1fr 1fr; margin-bottom:16px">
|
||||
<div class="stat-card"><div class="num"><?= $totalTickets ?></div><div class="lbl">Gesamt</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $openTickets ?></div><div class="lbl">Offen</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $closedTickets ?></div><div class="lbl">Gelöst</div></div>
|
||||
<div class="stat-card"><div class="num"><?= format_dt($customer['created_at'], 'd.m.Y') ?></div><div class="lbl">Seit</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Kundendaten bearbeiten -->
|
||||
<div class="card" style="padding:16px 18px">
|
||||
<div style="font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:12px">Kontaktdaten</div>
|
||||
<?php if ($isGuest): ?>
|
||||
<label style="font-size:.78rem; color:var(--muted)">Name</label>
|
||||
<div style="font-size:.84rem; padding:7px 0"><?= e($customer['name']) ?></div>
|
||||
<label style="font-size:.78rem; margin-top:4px; color:var(--muted)">E-Mail</label>
|
||||
<div style="font-size:.84rem; padding:7px 0; color:var(--muted)"><?= e($customer['email']) ?></div>
|
||||
<p class="muted" style="font-size:.78rem; margin-top:8px">Ohne Konto nicht bearbeitbar.</p>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_customer">
|
||||
<label style="font-size:.78rem">Name</label>
|
||||
<input type="text" name="name" value="<?= e($customer['name']) ?>" required>
|
||||
<label style="font-size:.78rem; margin-top:8px">Telefon</label>
|
||||
<input type="text" name="phone" value="<?= e($customer['phone'] ?? '') ?>" placeholder="Optional">
|
||||
<label style="font-size:.78rem; margin-top:8px; color:var(--muted)">E-Mail</label>
|
||||
<div style="font-size:.84rem; padding:7px 0; color:var(--muted)"><?= e($customer['email']) ?></div>
|
||||
<?php if ($customer['org_name']): ?>
|
||||
<label style="font-size:.78rem; margin-top:4px; color:var(--muted)">Organisation</label>
|
||||
<div style="font-size:.84rem; padding:7px 0"><?= e($customer['org_name']) ?></div>
|
||||
<?php endif; ?>
|
||||
<button type="submit" class="btn" style="width:100%; margin-top:12px; justify-content:center">Speichern</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$organizations = $pdo->query('SELECT * FROM organizations ORDER BY name')->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'assign_org' && $staff['role'] === 'admin') {
|
||||
$userId = (int) $_POST['user_id'];
|
||||
$orgId = $_POST['organization_id'] !== '' ? (int) $_POST['organization_id'] : null;
|
||||
$pdo->prepare('UPDATE users SET organization_id = ? WHERE id = ?')->execute([$orgId, $userId]);
|
||||
audit_log($staff['id'], "Organisation zugeordnet für Kunde #{$userId}");
|
||||
flash('success', 'Organisation zugeordnet.');
|
||||
redirect('/staff/customers.php' . ($_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : ''));
|
||||
}
|
||||
|
||||
if ($action === 'ban_email') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$matchType = ($_POST['match_type'] ?? 'email') === 'domain' ? 'domain' : 'email';
|
||||
$value = $matchType === 'domain' ? (substr(strrchr($email, '@'), 1) ?: $email) : $email;
|
||||
if ($value !== '') {
|
||||
// Nur einfügen wenn noch nicht vorhanden
|
||||
$exists = $pdo->prepare('SELECT COUNT(*) FROM banlist WHERE match_type=? AND LOWER(value)=?');
|
||||
$exists->execute([$matchType, strtolower($value)]);
|
||||
if (!(int)$exists->fetchColumn()) {
|
||||
$pdo->prepare('INSERT INTO banlist (match_type, value, reason, created_by, created_at) VALUES (?,?,?,?,?)')
|
||||
->execute([$matchType, strtolower($value), 'Gesperrt aus Kundenverwaltung', $staff['id'], db_now()]);
|
||||
audit_log($staff['id'], "Kunde gesperrt: {$value} ({$matchType})");
|
||||
flash('success', '"' . $value . '" wurde zur Sperrliste hinzugefuegt.');
|
||||
} else {
|
||||
flash('info', '"' . $value . '" ist bereits auf der Sperrliste.');
|
||||
}
|
||||
}
|
||||
redirect('/staff/customers.php' . ($_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : ''));
|
||||
}
|
||||
|
||||
if ($action === 'unban_email') {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$domain = substr(strrchr($email, '@'), 1) ?: '';
|
||||
$pdo->prepare('DELETE FROM banlist WHERE (match_type=\'email\' AND LOWER(value)=?) OR (match_type=\'domain\' AND LOWER(value)=?)')
|
||||
->execute([$email, $domain]);
|
||||
audit_log($staff['id'], "Kundensperrung aufgehoben: {$email}");
|
||||
flash('success', "Sperrung aufgehoben fuer: {$email}");
|
||||
}
|
||||
}
|
||||
|
||||
$search = trim($_GET['q'] ?? '');
|
||||
$like = '%' . $search . '%';
|
||||
|
||||
// Registrierte Kunden
|
||||
$regSql = "SELECT u.*, o.name AS organization_name,
|
||||
(SELECT COUNT(*) FROM tickets t WHERE t.user_id = u.id) AS ticket_count,
|
||||
(SELECT MAX(t2.requester_ip) FROM tickets t2 WHERE t2.user_id = u.id AND t2.requester_ip IS NOT NULL) AS last_ip
|
||||
FROM users u LEFT JOIN organizations o ON o.id = u.organization_id";
|
||||
$regParams = [];
|
||||
if ($search !== '') {
|
||||
$regSql .= ' WHERE u.name LIKE ? OR u.email LIKE ?';
|
||||
$regParams = [$like, $like];
|
||||
}
|
||||
$regSql .= ' ORDER BY u.name LIMIT 200';
|
||||
$regStmt = $pdo->prepare($regSql);
|
||||
$regStmt->execute($regParams);
|
||||
$registered = array_map(fn($c) => array_merge($c, ['is_guest' => false]), $regStmt->fetchAll());
|
||||
|
||||
// Gast-Kunden
|
||||
$guestSql = "SELECT requester_email AS email, MAX(requester_name) AS name,
|
||||
COUNT(*) AS ticket_count, MIN(created_at) AS created_at,
|
||||
MAX(requester_ip) AS last_ip
|
||||
FROM tickets WHERE user_id IS NULL";
|
||||
$guestParams = [];
|
||||
if ($search !== '') {
|
||||
$guestSql .= ' AND (requester_name LIKE ? OR requester_email LIKE ?)';
|
||||
$guestParams = [$like, $like];
|
||||
}
|
||||
$guestSql .= ' GROUP BY requester_email ORDER BY requester_email LIMIT 200';
|
||||
$guestStmt = $pdo->prepare($guestSql);
|
||||
$guestStmt->execute($guestParams);
|
||||
$guests = array_map(fn($g) => array_merge($g, [
|
||||
'id' => null, 'organization_id' => null, 'organization_name' => null, 'is_guest' => true,
|
||||
]), $guestStmt->fetchAll());
|
||||
|
||||
$customers = array_slice(
|
||||
usort_return(array_merge($registered, $guests), fn($a, $b) => strcasecmp($a['name'] ?? '', $b['name'] ?? '')),
|
||||
0, 200
|
||||
);
|
||||
|
||||
// Alle aktuell gesperrten E-Mails/Domains aus der Sperrliste vorausberechnen
|
||||
$bannedEmails = $pdo->query("SELECT LOWER(value) FROM banlist WHERE match_type='email'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$bannedDomains = $pdo->query("SELECT LOWER(value) FROM banlist WHERE match_type='domain'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$bannedEmailSet = array_flip($bannedEmails);
|
||||
$bannedDomainSet = array_flip($bannedDomains);
|
||||
|
||||
function is_customer_banned(string $email, array $bannedEmailSet, array $bannedDomainSet): bool
|
||||
{
|
||||
$email = strtolower($email);
|
||||
$domain = substr(strrchr($email, '@'), 1) ?: '';
|
||||
return isset($bannedEmailSet[$email]) || isset($bannedDomainSet[$domain]);
|
||||
}
|
||||
|
||||
function usort_return(array $arr, callable $fn): array { usort($arr, $fn); return $arr; }
|
||||
|
||||
$pageTitle = 'Kunden';
|
||||
$activePage = 'customers';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<h1>Kunden</h1>
|
||||
<p class="muted">Registrierte Kundenkonten und Gast-Anfrager (ohne Konto) aus allen Tickets.</p>
|
||||
|
||||
<form method="get" class="filters card">
|
||||
<div style="flex:2">
|
||||
<label>Suche</label>
|
||||
<input type="search" name="q" value="<?= e($search) ?>" placeholder="Name oder E-Mail">
|
||||
</div>
|
||||
<div><button type="submit" class="btn secondary">Suchen</button></div>
|
||||
<?php if ($search): ?>
|
||||
<div><a href="/staff/customers.php" class="btn secondary">Zurücksetzen</a></div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Typ</th>
|
||||
<th>Organisation</th>
|
||||
<th>Tickets</th>
|
||||
<th>Seit</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($customers as $c):
|
||||
$banned = is_customer_banned($c['email'], $bannedEmailSet, $bannedDomainSet);
|
||||
?>
|
||||
<tr style="<?= $banned ? 'opacity:.65' : '' ?>">
|
||||
<td style="font-weight:500"><a href="/staff/customer_profile.php?<?= $c['is_guest'] ? 'email=' . urlencode($c['email']) : 'id=' . (int) $c['id'] ?>"><?= e($c['name']) ?></a></td>
|
||||
<td><?= e($c['email']) ?></td>
|
||||
<td class="muted" style="font-family:var(--font-mono); font-size:.8rem">
|
||||
<?= $c['last_ip'] ? e($c['last_ip']) : '<span style="color:var(--muted-lt)">–</span>' ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= $c['is_guest']
|
||||
? '<span class="badge" style="background:#64748b">Gast</span>'
|
||||
: '<span class="badge" style="background:#0f6f6a">Konto</span>' ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($c['is_guest']): ?>
|
||||
<span class="muted" style="font-size:.82rem">– (kein Konto)</span>
|
||||
<?php else: ?>
|
||||
<form method="post" style="display:flex; gap:6px; align-items:center">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="assign_org">
|
||||
<input type="hidden" name="user_id" value="<?= $c['id'] ?>">
|
||||
<select name="organization_id" onchange="this.form.submit()" style="padding:4px 8px; font-size:.82rem">
|
||||
<option value="">– keine –</option>
|
||||
<?php foreach ($organizations as $o): ?>
|
||||
<option value="<?= $o['id'] ?>" <?= $c['organization_id'] == $o['id'] ? 'selected' : '' ?>><?= e($o['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ((int)$c['ticket_count'] > 0): ?>
|
||||
<a href="/staff/tickets.php?q=<?= urlencode($c['email']) ?>" style="font-weight:600"><?= (int)$c['ticket_count'] ?></a>
|
||||
<?php else: ?>
|
||||
<span class="muted">0</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="muted" style="white-space:nowrap"><?= format_dt($c['created_at']) ?></td>
|
||||
<td>
|
||||
<?php if ($banned): ?>
|
||||
<span class="badge" style="background:var(--danger)">Gesperrt</span>
|
||||
<?php else: ?>
|
||||
<span class="badge" style="background:var(--success)">Aktiv</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<?php if ($banned): ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('<?= e(addslashes($c['email'])) ?> wirklich aufheben?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="unban_email">
|
||||
<input type="hidden" name="email" value="<?= e($c['email']) ?>">
|
||||
<button type="submit" class="btn secondary" style="font-size:.78rem; padding:5px 10px; color:var(--success); border-color:var(--success)">✓ Entsperren</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('<?= e(addslashes($c['name'])) ?>" sperren? Zukünftige Tickets von <?= e(addslashes($c['email'])) ?> werden abgewiesen.');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="ban_email">
|
||||
<input type="hidden" name="email" value="<?= e($c['email']) ?>">
|
||||
<input type="hidden" name="match_type" value="email">
|
||||
<button type="submit" class="btn secondary" style="font-size:.78rem; padding:5px 10px; color:var(--danger); border-color:var(--danger)">🚫 Sperren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$customers): ?>
|
||||
<tr><td colspan="9" class="muted" style="text-align:center; padding:30px">Keine Kunden gefunden.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/**
|
||||
* staff/dashboard.php
|
||||
*
|
||||
* Ehemals eigene "Übersicht"-Seite, inzwischen zusammengelegt mit staff/reports.php (siehe
|
||||
* dort für den Grund) – bleibt hier nur noch als Weiterleitung bestehen, damit alte Links,
|
||||
* Lesezeichen und PWA-Verknüpfungen weiterhin funktionieren.
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_staff_login();
|
||||
redirect('/staff/reports.php');
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$myDeptIds = staff_department_ids($staff);
|
||||
[$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 'department_id');
|
||||
$now = db_now();
|
||||
|
||||
$open = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed')" . $deptFilter);
|
||||
$open->execute($deptParams); $open = (int)$open->fetchColumn();
|
||||
|
||||
$mine = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id=? AND status NOT IN ('resolved','closed')" . $deptFilter);
|
||||
$mine->execute(array_merge([$staff['id']], $deptParams)); $mine = (int)$mine->fetchColumn();
|
||||
|
||||
$unassigned = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')" . $deptFilter);
|
||||
$unassigned->execute($deptParams); $unassigned = (int)$unassigned->fetchColumn();
|
||||
|
||||
$overdue = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND resolution_due_at IS NOT NULL AND resolution_due_at < ?" . $deptFilter);
|
||||
$overdue->execute(array_merge([$now], $deptParams)); $overdue = (int)$overdue->fetchColumn();
|
||||
|
||||
$unanswered = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'" . $deptFilter);
|
||||
$unanswered->execute($deptParams); $unanswered = (int)$unanswered->fetchColumn();
|
||||
|
||||
$mentions = $pdo->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
|
||||
$mentions->execute([$staff['id']]); $mentions = (int)$mentions->fetchColumn();
|
||||
|
||||
echo json_encode(['open'=>$open,'mine'=>$mine,'unassigned'=>$unassigned,'overdue'=>$overdue,'unanswered'=>$unanswered,'mentions'=>$mentions]);
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin_or_permission('manage_departments');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$staffList = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
|
||||
try {
|
||||
department_save($pdo, $id, [
|
||||
'name' => $_POST['name'] ?? '', 'email' => $_POST['email'] ?? '', 'is_default' => !empty($_POST['is_default']),
|
||||
'is_active' => ($_POST['status'] ?? 'active') === 'active', 'manager_id' => $_POST['manager_id'] ?? null,
|
||||
'sla_plan_id' => $_POST['sla_plan_id'] ?? null, 'notes' => $_POST['notes'] ?? '', 'signature' => $_POST['signature'] ?? '',
|
||||
]);
|
||||
audit_log($staff['id'], ($action === 'create' ? 'Abteilung erstellt: ' : 'Abteilung aktualisiert: ') . trim($_POST['name'] ?? ''));
|
||||
flash('success', $action === 'create' ? 'Abteilung erstellt.' : 'Änderungen gespeichert.');
|
||||
redirect('/staff/departments.php');
|
||||
} catch (AdminModelException $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
try {
|
||||
$deleted = department_delete($pdo, (int) $_POST['id']);
|
||||
audit_log($staff['id'], "Abteilung gelöscht: {$deleted}");
|
||||
flash('success', 'Abteilung gelöscht.');
|
||||
} catch (AdminModelException $e) {
|
||||
flash('error', $e->getMessage());
|
||||
}
|
||||
redirect('/staff/departments.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$editDept = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM departments WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editDept = $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
// Mitglieder = Hauptabteilung ODER zusätzlich zugewiesene Abteilung
|
||||
$memberStmt = $pdo->prepare(
|
||||
'SELECT s.id, s.name, s.email, s.role, s.is_active, (s.department_id = ?) AS is_primary
|
||||
FROM staff s
|
||||
WHERE s.department_id = ? OR s.id IN (SELECT staff_id FROM staff_department WHERE department_id = ?)
|
||||
ORDER BY s.name'
|
||||
);
|
||||
|
||||
$departments = $pdo->query(
|
||||
'SELECT d.*, m.name AS manager_name,
|
||||
(SELECT COUNT(*) FROM staff s WHERE s.department_id = d.id OR s.id IN (SELECT staff_id FROM staff_department sd WHERE sd.department_id = d.id)) AS member_count
|
||||
FROM departments d LEFT JOIN staff m ON m.id = d.manager_id
|
||||
ORDER BY d.name'
|
||||
)->fetchAll();
|
||||
$emailOptions = $pdo->query(
|
||||
'SELECT ea.email, d.name AS department_name FROM email_addresses ea JOIN departments d ON d.id = ea.department_id ORDER BY ea.email'
|
||||
)->fetchAll();
|
||||
$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
|
||||
|
||||
$pageTitle = 'Abteilungen';
|
||||
$activePage = 'departments';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editDept[$col] ?? $default);
|
||||
$curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editDept['is_active'] ?? 1) ? 'active' : 'inactive');
|
||||
$curEmail = strtolower((string) $v('email', 'email'));
|
||||
$curManager = (string) $v('manager_id', 'manager_id');
|
||||
$isDefaultChecked = $_POST ? !empty($_POST['is_default']) : !empty($editDept['is_default']);
|
||||
$members = [];
|
||||
if ($editDept) {
|
||||
$did = (int) $editDept['id'];
|
||||
$memberStmt->execute([$did, $did, $did]);
|
||||
$members = $memberStmt->fetchAll();
|
||||
}
|
||||
$seen = false;
|
||||
?>
|
||||
<h1>
|
||||
<?= $editDept ? 'Abteilung aktualisieren' : 'Neue Abteilung' ?>
|
||||
<?php if ($editDept): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editDept['name']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editDept ? 'update' : 'create' ?>">
|
||||
<?php if ($editDept): ?><input type="hidden" name="id" value="<?= (int) $editDept['id'] ?>"><?php endif; ?>
|
||||
|
||||
<div style="display:flex;gap:4px;padding:10px 12px 0;border-bottom:1px solid var(--border)">
|
||||
<button type="button" class="tab-btn" data-tab="dept">Abteilung</button>
|
||||
<button type="button" class="tab-btn" data-tab="members">Mitglieder<?= $editDept ? ' (' . count($members) . ')' : '' ?></button>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="dept" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Abteilungsinformationen</h3>
|
||||
<label for="name">Name *</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" required>
|
||||
|
||||
<label>Status *</label>
|
||||
<div style="display:flex;gap:18px;margin:4px 0 10px">
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="active" style="width:auto" <?= $curStatus === 'active' ? 'checked' : '' ?>> <strong>Aktiv</strong></label>
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="inactive" style="width:auto" <?= $curStatus === 'inactive' ? 'checked' : '' ?>> Deaktiviert</label>
|
||||
</div>
|
||||
|
||||
<label for="manager_id">Abteilungsleiter</label>
|
||||
<select id="manager_id" name="manager_id">
|
||||
<option value="">— Keine(r) —</option>
|
||||
<?php foreach ($staffList as $s): ?>
|
||||
<option value="<?= $s['id'] ?>" <?= $curManager === (string) $s['id'] ? 'selected' : '' ?>><?= e($s['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="sla_plan_id">SLA-Plan</label>
|
||||
<select id="sla_plan_id" name="sla_plan_id">
|
||||
<option value="">— Zeiten der Priorität verwenden —</option>
|
||||
<?php foreach ($pdo->query('SELECT id, name, is_active FROM sla_plans ORDER BY name')->fetchAll() as $__sp): ?>
|
||||
<option value="<?= $__sp['id'] ?>" <?= (string) $v('sla_plan_id', 'sla_plan_id') === (string) $__sp['id'] ? 'selected' : '' ?>><?= e($__sp['name']) ?><?= $__sp['is_active'] ? '' : ' (deaktiviert)' ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="email">Abteilungs-E-Mail (optional)</label>
|
||||
<select id="email" name="email">
|
||||
<option value="">— Automatisch (Standardadresse der Abteilung) —</option>
|
||||
<?php foreach ($emailOptions as $o): $sel = strtolower($o['email']) === $curEmail; $seen = $seen || $sel; ?>
|
||||
<option value="<?= e($o['email']) ?>" <?= $sel ? 'selected' : '' ?>><?= e($o['email']) ?> (<?= e($o['department_name']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($curEmail !== '' && !$seen): ?><option value="<?= e($curEmail) ?>" selected><?= e($curEmail) ?></option><?php endif; ?>
|
||||
</select>
|
||||
<p class="muted" style="font-size:.78rem;margin-top:3px">Adressen, Priorität und Postfach-Abruf pflegst du unter <a href="/staff/email_addresses.php">E-Mail-Adressen</a>.</p>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:14px">
|
||||
<input type="checkbox" name="is_default" style="width:auto" <?= $isDefaultChecked ? 'checked' : '' ?>>
|
||||
Standardabteilung (für E-Mail-Tickets ohne Zuordnung)
|
||||
</label>
|
||||
|
||||
<label for="signature">Signatur (E-Mail-Fuß für Antworten ohne Mitarbeiter-Signatur)</label>
|
||||
<textarea id="signature" name="signature" data-richtext data-image-upload-url="/staff/upload_signature_image.php" style="min-height:90px" placeholder="z. B. Mit freundlichen Grüßen Ihr Support-Team"><?= e($v('signature', 'signature')) ?></textarea>
|
||||
|
||||
<h3>Administratornotizen <span class="muted" style="font-weight:400;font-size:.85rem">– Interne Notizen, sichtbar für alle Administratoren</span></h3>
|
||||
<textarea id="notes" name="notes" rows="6"><?= e($v('notes', 'notes')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="members" style="padding:16px 20px;display:none">
|
||||
<?php if (!$editDept): ?>
|
||||
<p class="muted">Mitglieder können nach dem Erstellen der Abteilung unter <a href="/staff/staff_manage.php">Mitarbeiter</a> zugewiesen werden.</p>
|
||||
<?php elseif (!$members): ?>
|
||||
<p class="muted">Dieser Abteilung sind noch keine Mitarbeiter zugewiesen. Die Zuweisung erfolgt beim jeweiligen Mitarbeiter unter <a href="/staff/staff_manage.php">Mitarbeiter</a>.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Zuordnung</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($members as $m): ?>
|
||||
<tr>
|
||||
<td><?= e($m['name']) ?><?= (string) $m['id'] === $curManager ? ' <span class="muted">(Leiter)</span>' : '' ?></td>
|
||||
<td><?= e($m['email']) ?></td>
|
||||
<td><?= $m['role'] === 'admin' ? 'Administrator' : 'Agent' ?></td>
|
||||
<td><?= $m['is_primary'] ? 'Hauptabteilung' : 'Zusätzlich' ?></td>
|
||||
<td><?= $m['is_active'] ? 'Aktiv' : 'Deaktiviert' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted" style="font-size:.78rem;margin-top:10px">Zuweisungen änderst du unter <a href="/staff/staff_manage.php">Mitarbeiter</a>.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editDept ? 'Änderungen speichern' : 'Erstellen' ?></button>
|
||||
<a href="/staff/departments.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.tab-btn{background:none;border:1px solid transparent;border-bottom:none;padding:8px 16px;cursor:pointer;border-radius:6px 6px 0 0;color:var(--muted)}
|
||||
.tab-btn.active{background:var(--card-bg,#fff);border-color:var(--border);color:inherit;font-weight:600;position:relative;top:1px}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var btns = document.querySelectorAll('.tab-btn'), panes = document.querySelectorAll('.tab-pane');
|
||||
function show(t) {
|
||||
btns.forEach(function (b) { b.classList.toggle('active', b.dataset.tab === t); });
|
||||
panes.forEach(function (p) { p.style.display = p.dataset.pane === t ? '' : 'none'; });
|
||||
}
|
||||
btns.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
|
||||
show('dept');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">Abteilungen</h1>
|
||||
<a href="/staff/departments.php?new=1" class="btn">Neue Abteilung hinzufügen</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:14px">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th style="text-align:center">Mitglieder</th><th>Abteilungsleiter</th><th>E-Mail</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/departments.php?edit=<?= (int) $d['id'] ?>"><?= e($d['name']) ?></a><?= $d['is_default'] ? ' <span class="muted">(Standard)</span>' : '' ?></td>
|
||||
<td><strong><?= ($d['is_active'] ?? 1) ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
|
||||
<td style="text-align:center"><a href="/staff/departments.php?edit=<?= (int) $d['id'] ?>"><?= (int) $d['member_count'] ?></a></td>
|
||||
<td><?= e($d['manager_name'] ?? '') ?></td>
|
||||
<td class="muted" style="font-size:.85rem"><?= e($d['email']) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($fmt($d['created_at'] ?? null)) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($fmt($d['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/departments.php?edit=<?= (int) $d['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $d['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$departments): ?>
|
||||
<tr><td colspan="8" class="muted">Noch keine Abteilungen angelegt.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin_or_permission('manage_email_addresses');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN)));
|
||||
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
|
||||
$topics = help_topic_options($pdo, false, false);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
|
||||
try {
|
||||
$newId = email_address_save($pdo, $id, [
|
||||
'email' => $_POST['email'] ?? '', 'label' => $_POST['label'] ?? '',
|
||||
'department_id' => $_POST['department_id'] ?? 0, 'priority_id' => $_POST['priority_id'] ?? null,
|
||||
'help_topic_id' => $_POST['help_topic_id'] ?? null, 'is_default' => !empty($_POST['is_default']),
|
||||
'is_active' => ($_POST['status'] ?? 'active') === 'active', 'notes' => $_POST['notes'] ?? '',
|
||||
'imap_enabled' => !empty($_POST['imap_enabled']), 'imap_host' => $_POST['host'] ?? '', 'imap_port' => $_POST['port'] ?? 993,
|
||||
'imap_encryption' => $_POST['encryption'] ?? 'ssl', 'imap_username' => $_POST['username'] ?? '', 'imap_password' => $_POST['password'] ?? '',
|
||||
'imap_folder' => $_POST['folder'] ?? '', 'imap_after_fetch' => $_POST['after_fetch'] ?? 'seen',
|
||||
'imap_archive_folder' => $_POST['archive_folder'] ?? '', 'imap_spam_folder' => $_POST['spam_folder'] ?? '',
|
||||
'smtp_enabled' => !empty($_POST['smtp_enabled']), 'smtp_host' => $_POST['smtp_host'] ?? '', 'smtp_port' => $_POST['smtp_port'] ?? 587,
|
||||
'smtp_encryption' => $_POST['smtp_encryption'] ?? 'tls', 'smtp_username' => $_POST['smtp_username'] ?? '', 'smtp_password' => $_POST['smtp_password'] ?? '',
|
||||
]);
|
||||
audit_log($admin['id'], ($action === 'create' ? 'E-Mail-Adresse erstellt: ' : 'E-Mail-Adresse aktualisiert: ') . strtolower(trim($_POST['email'] ?? '')));
|
||||
flash('success', $action === 'create' ? 'E-Mail-Adresse erstellt.' : 'Änderungen gespeichert.');
|
||||
redirect('/staff/email_addresses.php');
|
||||
} catch (EmailAddressException $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$delEmail = email_address_delete($pdo, (int) $_POST['id']);
|
||||
audit_log($admin['id'], "E-Mail-Adresse gelöscht: {$delEmail}");
|
||||
flash('success', 'E-Mail-Adresse gelöscht.');
|
||||
redirect('/staff/email_addresses.php');
|
||||
}
|
||||
|
||||
if (in_array($action, ['send_test', 'test', 'test_smtp'], true)) {
|
||||
$id = (int) $_POST['id'];
|
||||
try {
|
||||
$msg = match ($action) {
|
||||
'send_test' => email_address_send_test($pdo, $id, $admin['email']),
|
||||
'test' => email_address_test_imap($pdo, $id),
|
||||
default => email_address_test_smtp($pdo, $id),
|
||||
};
|
||||
flash('success', $msg);
|
||||
} catch (EmailAddressException $e) {
|
||||
flash('error', $e->getMessage());
|
||||
}
|
||||
redirect('/staff/email_addresses.php?edit=' . $id . ($action === 'test' ? '&tab=imap' : ($action === 'test_smtp' ? '&tab=smtp' : '')));
|
||||
}
|
||||
|
||||
if ($action === 'bulk_add') {
|
||||
$raw = trim($_POST['bulk_emails'] ?? '');
|
||||
$deptId = (int) ($_POST['bulk_department_id'] ?? 0);
|
||||
$prioId = ($_POST['bulk_priority_id'] ?? '') !== '' ? (int) $_POST['bulk_priority_id'] : null;
|
||||
|
||||
if (!in_array($deptId, array_map('intval', array_column($departments, 'id')), true)) {
|
||||
$errors[] = 'Bitte eine gültige Abteilung wählen.';
|
||||
} else {
|
||||
// Eine Adresse pro Zeile, optional mit Komma getrennter Bezeichnung: "email@x.de, Bezeichnung"
|
||||
$lines = array_filter(array_map('trim', preg_split('/[\r\n]+/', $raw)));
|
||||
$added = 0;
|
||||
$skipped = 0;
|
||||
foreach ($lines as $line) {
|
||||
$parts = array_map('trim', explode(',', $line, 2));
|
||||
try {
|
||||
email_address_save($pdo, 0, ['email' => $parts[0] ?? '', 'label' => $parts[1] ?? '', 'department_id' => $deptId, 'priority_id' => $prioId]);
|
||||
$added++;
|
||||
} catch (EmailAddressException $e) {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
audit_log($admin['id'], "E-Mail-Adressen Massen-Import: $added hinzugefügt, $skipped übersprungen");
|
||||
flash('success', "$added Adresse(n) hinzugefügt, $skipped übersprungen (ungültig oder bereits vorhanden).");
|
||||
redirect('/staff/email_addresses.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Anzeige: Formular (neu/bearbeiten) oder Liste ---------------------------------------
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$editAddress = null;
|
||||
$editImap = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM email_addresses WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editAddress = $stmt->fetch() ?: null;
|
||||
if ($editAddress) $editImap = email_address_imap($pdo, $editId);
|
||||
}
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$tab = in_array($_GET['tab'] ?? '', ['imap', 'smtp'], true) ? $_GET['tab'] : 'account';
|
||||
if ($errors && !empty($_POST['smtp_enabled'])) $tab = 'smtp';
|
||||
elseif ($errors && !empty($_POST['imap_enabled'])) $tab = 'imap';
|
||||
|
||||
$addresses = $pdo->query(
|
||||
'SELECT ea.*, d.name AS department_name, p.name AS priority_name,
|
||||
(SELECT COUNT(*) FROM imap_accounts ia WHERE ia.email_address_id = ea.id AND ia.is_active = 1) AS imap_active
|
||||
FROM email_addresses ea
|
||||
JOIN departments d ON d.id = ea.department_id
|
||||
LEFT JOIN priorities p ON p.id = ea.priority_id
|
||||
ORDER BY d.name, ea.email'
|
||||
)->fetchAll();
|
||||
$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
|
||||
|
||||
$pageTitle = 'E-Mail-Adressen';
|
||||
$activePage = 'email_addresses';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editAddress[$col] ?? $default);
|
||||
$imapV = fn(string $k, string $col, $default = '') => $f($k, $editImap[$col] ?? $default);
|
||||
$curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editAddress['is_active'] ?? 1) ? 'active' : 'inactive');
|
||||
$imapEnabled = $_POST ? !empty($_POST['imap_enabled']) : ($editImap && $editImap['is_active']);
|
||||
$smtpEnabled = $_POST ? !empty($_POST['smtp_enabled']) : !empty($editAddress['smtp_enabled']);
|
||||
$afterFetchVal = $imapV('after_fetch', 'after_fetch', 'seen');
|
||||
$hasSmtpPass = !empty($editAddress['smtp_password_enc']);
|
||||
?>
|
||||
<h1>
|
||||
<?= $editAddress ? 'E-Mail-Adresse' : 'Neue E-Mail-Adresse' ?>
|
||||
<?php if ($editAddress): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editAddress['label'] ?: '') ?> <<?= e($editAddress['email']) ?>></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editAddress ? 'update' : 'create' ?>">
|
||||
<?php if ($editAddress): ?><input type="hidden" name="id" value="<?= (int) $editAddress['id'] ?>"><?php endif; ?>
|
||||
|
||||
<div class="tabs" style="display:flex;gap:4px;padding:10px 12px 0;border-bottom:1px solid var(--border)">
|
||||
<button type="button" class="tab-btn" data-tab="account">Konto</button>
|
||||
<button type="button" class="tab-btn" data-tab="imap">Entferntes Postfach</button>
|
||||
<button type="button" class="tab-btn" data-tab="smtp">Abgehend (SMTP)</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="account" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">E-Mail-Informationen und Einstellungen</h3>
|
||||
<p class="muted" style="font-size:.82rem">Eingehende Mails an diese Adresse landen automatisch in der gewählten Abteilung; ausgehende Antworten dieser Abteilung werden mit dieser Adresse als Absender verschickt.</p>
|
||||
<label for="email">E-Mail-Adresse *</label>
|
||||
<input type="email" id="email" name="email" value="<?= e($v('email', 'email')) ?>" required>
|
||||
<label for="label">Anzeigename</label>
|
||||
<input type="text" id="label" name="label" value="<?= e($v('label', 'label')) ?>" placeholder="z. B. Windelgeschichten">
|
||||
<label>Status</label>
|
||||
<div style="display:flex;gap:18px;margin:4px 0 10px">
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="active" style="width:auto" <?= $curStatus === 'active' ? 'checked' : '' ?>> <strong>Aktiv</strong></label>
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="inactive" style="width:auto" <?= $curStatus !== 'active' ? 'checked' : '' ?>> Deaktiviert</label>
|
||||
</div>
|
||||
<p class="muted" style="font-size:.78rem;margin-top:-6px">Eine deaktivierte Adresse wird weder für das Routing noch als Absender verwendet; der Abruf pausiert.</p>
|
||||
|
||||
<h3>Einstellungen bei neuem Ticket</h3>
|
||||
<label for="department_id">Abteilung *</label>
|
||||
<select name="department_id" id="department_id" required>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= (string) $v('department_id', 'department_id') === (string) $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?><?= ($d['is_active'] ?? 1) ? '' : ' (deaktiviert)' ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="priority_id">Priorität</label>
|
||||
<select name="priority_id" id="priority_id">
|
||||
<option value="">— Systemstandard —</option>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= (string) $v('priority_id', 'priority_id') === (string) $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="help_topic_id">Hilfethema</label>
|
||||
<select name="help_topic_id" id="help_topic_id">
|
||||
<option value="">— Systemstandard —</option>
|
||||
<?php foreach ($topics as $t): ?>
|
||||
<option value="<?= $t['id'] ?>" <?= (string) $v('help_topic_id', 'help_topic_id') === (string) $t['id'] ? 'selected' : '' ?>><?= e($t['path']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:14px">
|
||||
<input type="checkbox" name="is_default" style="width:auto" <?= $_POST ? (!empty($_POST['is_default']) ? 'checked' : '') : (!empty($editAddress['is_default']) ? 'checked' : '') ?>>
|
||||
Standard-Absenderadresse dieser Abteilung
|
||||
</label>
|
||||
|
||||
<h3>Interne Notizen <span class="muted" style="font-weight:400;font-size:.85rem">– Sei offen, sie sind vertraulich</span></h3>
|
||||
<textarea name="notes" rows="5"><?= e($v('notes', 'notes')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="imap" style="padding:16px 20px;display:none">
|
||||
<h3 style="margin-top:0">Mails per IMAP abrufen</h3>
|
||||
<label style="display:flex; align-items:center; gap:8px">
|
||||
<input type="checkbox" name="imap_enabled" id="imap_enabled" style="width:auto" <?= $imapEnabled ? 'checked' : '' ?>>
|
||||
Abruf für diese Adresse aktivieren
|
||||
</label>
|
||||
<p class="muted" style="font-size:.8rem">Abteilung, Priorität und Hilfethema stammen aus dem Reiter „Konto“.
|
||||
<?php if ($editImap): ?>Letzter Abruf: <?= e(cron_last_run_label($editImap['last_run_at'] ?? null)) ?>.<?php endif; ?></p>
|
||||
<div id="imap-fields">
|
||||
<div style="display:grid;grid-template-columns:1fr 100px;gap:10px">
|
||||
<div><label for="host">Server</label>
|
||||
<input type="text" id="host" name="host" value="<?= e($imapV('host', 'host')) ?>" placeholder="imap.beispiel.de"></div>
|
||||
<div><label for="port">Port</label>
|
||||
<input type="number" id="port" name="port" value="<?= e($imapV('port', 'port', '993')) ?>"></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">
|
||||
<div><label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" value="<?= e($imapV('username', 'username')) ?>" placeholder="leer = E-Mail-Adresse" autocomplete="off"></div>
|
||||
<div><label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" placeholder="<?= $editImap ? '(unverändert)' : '' ?>" autocomplete="new-password"></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">
|
||||
<div><label for="encryption">Verschlüsselung</label>
|
||||
<select id="encryption" name="encryption">
|
||||
<?php $encVal = $imapV('encryption', 'encryption', 'ssl'); ?>
|
||||
<option value="ssl" <?= $encVal === 'ssl' ? 'selected' : '' ?>>SSL/TLS (993)</option>
|
||||
<option value="tls" <?= $encVal === 'tls' ? 'selected' : '' ?>>STARTTLS (143)</option>
|
||||
<option value="none" <?= $encVal === 'none' ? 'selected' : '' ?>>Keine</option>
|
||||
</select></div>
|
||||
<div><label for="folder">Ordner</label>
|
||||
<input type="text" id="folder" name="folder" value="<?= e($imapV('folder', 'folder', 'INBOX')) ?>"></div>
|
||||
</div>
|
||||
<label for="after_fetch" style="margin-top:10px">Aktion nach Abruf</label>
|
||||
<select id="after_fetch" name="after_fetch">
|
||||
<option value="seen" <?= $afterFetchVal === 'seen' ? 'selected' : '' ?>>Als gelesen markieren</option>
|
||||
<option value="archive" <?= $afterFetchVal === 'archive' ? 'selected' : '' ?>>In Ordner verschieben</option>
|
||||
<option value="delete" <?= $afterFetchVal === 'delete' ? 'selected' : '' ?>>Löschen</option>
|
||||
</select>
|
||||
<div id="archive-row" style="margin-top:10px">
|
||||
<label for="archive_folder">Zielordner</label>
|
||||
<input type="text" id="archive_folder" name="archive_folder" value="<?= e($imapV('archive_folder', 'archive_folder', 'Processed')) ?>">
|
||||
</div>
|
||||
<label for="spam_folder" style="margin-top:10px">Spam-/Junk-Ordner</label>
|
||||
<input type="text" id="spam_folder" name="spam_folder" value="<?= e($imapV('spam_folder', 'spam_folder', 'Junk')) ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="smtp" style="padding:16px 20px;display:none">
|
||||
<h3 style="margin-top:0">Ausgehende Mails (SMTP)</h3>
|
||||
<label style="display:flex; align-items:center; gap:8px">
|
||||
<input type="checkbox" name="smtp_enabled" id="smtp_enabled" style="width:auto" <?= $smtpEnabled ? 'checked' : '' ?>>
|
||||
Eigenen SMTP-Server für diese Adresse verwenden
|
||||
</label>
|
||||
<p class="muted" style="font-size:.8rem">Ohne eigenen Server werden Mails mit dieser Absenderadresse über den globalen SMTP aus den <a href="/staff/settings.php">Einstellungen</a> verschickt.</p>
|
||||
<div id="smtp-fields">
|
||||
<div style="display:grid;grid-template-columns:1fr 100px;gap:10px">
|
||||
<div><label for="smtp_host">Server</label>
|
||||
<input type="text" id="smtp_host" name="smtp_host" value="<?= e($v('smtp_host', 'smtp_host')) ?>" placeholder="smtp.beispiel.de"></div>
|
||||
<div><label for="smtp_port">Port</label>
|
||||
<input type="number" id="smtp_port" name="smtp_port" value="<?= e($v('smtp_port', 'smtp_port', '587')) ?>"></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">
|
||||
<div><label for="smtp_username">Benutzername</label>
|
||||
<input type="text" id="smtp_username" name="smtp_username" value="<?= e($v('smtp_username', 'smtp_username')) ?>" autocomplete="off"></div>
|
||||
<div><label for="smtp_password">Passwort</label>
|
||||
<input type="password" id="smtp_password" name="smtp_password" placeholder="<?= $hasSmtpPass ? '(unverändert)' : '' ?>" autocomplete="new-password"></div>
|
||||
</div>
|
||||
<label for="smtp_encryption" style="margin-top:10px">Verschlüsselung</label>
|
||||
<select id="smtp_encryption" name="smtp_encryption" style="max-width:260px">
|
||||
<?php $smtpEnc = $v('smtp_encryption', 'smtp_encryption', 'tls'); ?>
|
||||
<option value="tls" <?= $smtpEnc === 'tls' ? 'selected' : '' ?>>STARTTLS (587)</option>
|
||||
<option value="ssl" <?= $smtpEnc === 'ssl' ? 'selected' : '' ?>>SSL/TLS (465)</option>
|
||||
<option value="none" <?= $smtpEnc === 'none' ? 'selected' : '' ?>>Keine</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editAddress ? 'Änderungen speichern' : 'Erstellen' ?></button>
|
||||
<a href="/staff/email_addresses.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if ($editAddress): ?>
|
||||
<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap">
|
||||
<?php
|
||||
$tests = [['send_test', 'Test-Mail senden (an ' . $admin['email'] . ')', true]];
|
||||
if ($editImap) $tests[] = ['test', 'Postfach-Verbindung testen', true];
|
||||
if (!empty($editAddress['smtp_enabled'])) $tests[] = ['test_smtp', 'SMTP-Server testen', true];
|
||||
foreach ($tests as [$act, $lbl]): ?>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $act ?>">
|
||||
<input type="hidden" name="id" value="<?= (int) $editAddress['id'] ?>">
|
||||
<button type="submit" class="btn secondary"><?= e($lbl) ?></button>
|
||||
</form>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<p class="muted" style="font-size:.78rem">Tests verwenden die gespeicherten Werte.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<style>
|
||||
.tab-btn{background:none;border:1px solid transparent;border-bottom:none;padding:8px 16px;cursor:pointer;border-radius:6px 6px 0 0;color:var(--muted)}
|
||||
.tab-btn.active{background:var(--card-bg,#fff);border-color:var(--border);color:inherit;font-weight:600;position:relative;top:1px}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var btns = document.querySelectorAll('.tab-btn'), panes = document.querySelectorAll('.tab-pane');
|
||||
function show(t) {
|
||||
btns.forEach(function (b) { b.classList.toggle('active', b.dataset.tab === t); });
|
||||
panes.forEach(function (p) { p.style.display = p.dataset.pane === t ? '' : 'none'; });
|
||||
}
|
||||
btns.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
|
||||
show(<?= json_encode($tab) ?>);
|
||||
|
||||
function toggler(checkId, boxId) {
|
||||
var on = document.getElementById(checkId), box = document.getElementById(boxId);
|
||||
function sync() { box.style.opacity = on.checked ? '1' : '.5'; box.querySelectorAll('input,select').forEach(function (i) { i.disabled = !on.checked; }); }
|
||||
on.addEventListener('change', sync); sync();
|
||||
}
|
||||
toggler('imap_enabled', 'imap-fields'); toggler('smtp_enabled', 'smtp-fields');
|
||||
|
||||
var af = document.getElementById('after_fetch'), ar = document.getElementById('archive-row');
|
||||
function syncAf() { ar.style.display = af.value === 'archive' ? '' : 'none'; }
|
||||
af.addEventListener('change', syncAf); syncAf();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">E-Mail-Adressen</h1>
|
||||
<a href="/staff/email_addresses.php?new=1" class="btn">Neue E-Mail-Adresse</a>
|
||||
</div>
|
||||
<p class="muted">Eine Adresse = Abteilung + Priorität + Hilfethema + (optional) Postfach zum Abruf und eigener SMTP-Server. DeskHub wertet beim Abruf den „An:“- und „CC:“-Header aus und legt das Ticket in der passenden Abteilung ab. Ist keine Adresse passend, greift die Standardabteilung aus den <a href="/staff/settings.php">Einstellungen</a>.</p>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>E-Mail-Adresse</th><th>Anzeigename</th><th>Status</th><th>Abteilung</th><th>Priorität</th><th>Abruf</th><th>Eigener SMTP</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($addresses as $a): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/email_addresses.php?edit=<?= (int) $a['id'] ?>"><?= e($a['email']) ?></a><?= $a['is_default'] ? ' <span class="muted">(Standard)</span>' : '' ?></td>
|
||||
<td><?= e($a['label'] ?: '–') ?></td>
|
||||
<td><strong><?= ($a['is_active'] ?? 1) ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
|
||||
<td><?= e($a['department_name']) ?></td>
|
||||
<td><?= e($a['priority_name'] ?? 'Systemstandard') ?></td>
|
||||
<td><?= $a['imap_active'] ? '✓' : '–' ?></td>
|
||||
<td><?= !empty($a['smtp_enabled']) ? '✓' : '–' ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($fmt($a['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/email_addresses.php?edit=<?= (int) $a['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('E-Mail-Adresse wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$addresses): ?>
|
||||
<tr><td colspan="9" class="muted">Noch keine E-Mail-Adressen angelegt.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="card" style="margin-top:20px">
|
||||
<summary style="cursor:pointer;font-weight:600">Mehrere Adressen auf einmal anlegen</summary>
|
||||
<p class="muted" style="font-size:.82rem;margin:10px 0">Eine Adresse pro Zeile, optional mit Anzeigename nach einem Komma (z. B. <code>technik@firma.de, Technik-Support</code>).</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="bulk_add">
|
||||
<textarea name="bulk_emails" rows="5" required style="font-family:monospace;font-size:.82rem"></textarea>
|
||||
<label style="margin-top:10px">Abteilung</label>
|
||||
<select name="bulk_department_id" required>
|
||||
<?php foreach ($departments as $d): ?><option value="<?= $d['id'] ?>"><?= e($d['name']) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
<label style="margin-top:10px">Priorität</label>
|
||||
<select name="bulk_priority_id">
|
||||
<option value="">— Systemstandard —</option>
|
||||
<?php foreach ($priorities as $p): ?><option value="<?= $p['id'] ?>"><?= e($p['name']) ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
<div class="btn-row"><button type="submit" class="btn">Alle anlegen</button></div>
|
||||
</form>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin_or_permission('manage_email_templates');
|
||||
$pdo = get_db();
|
||||
|
||||
$templates = [
|
||||
'ticket_created' => [
|
||||
'label' => 'Ticket erstellt (an Kunde)',
|
||||
'placeholders' => ['ticket_number', 'subject', 'customer_name', 'department_name', 'ticket_url', 'lookup_url', 'site_name'],
|
||||
'default_subject' => 'Ihr Ticket #{ticket_number} wurde erstellt',
|
||||
'default_body' => email_ticket_created_default_body(),
|
||||
],
|
||||
'new_reply' => [
|
||||
'label' => 'Neue Antwort (an Kunde)',
|
||||
'placeholders' => ['ticket_number', 'subject', 'customer_name', 'message', 'ticket_url'],
|
||||
'default_subject' => '[#{ticket_number}] {subject}',
|
||||
'default_body' => email_new_reply_default_body(),
|
||||
],
|
||||
'new_ticket_department' => [
|
||||
'label' => 'Neues Ticket (an Abteilung)',
|
||||
'placeholders' => ['ticket_number', 'subject', 'customer_name', 'customer_email', 'department_name', 'ticket_url'],
|
||||
'default_subject' => 'Neues Ticket [#{ticket_number}] {subject}',
|
||||
'default_body' => email_new_ticket_department_default_body(),
|
||||
],
|
||||
'ticket_overdue' => [
|
||||
'label' => 'Ticket überfällig (an Admins)',
|
||||
'placeholders' => ['ticket_number', 'subject', 'department_name', 'ticket_url'],
|
||||
'default_subject' => 'SLA überschritten: Ticket #{ticket_number}',
|
||||
'default_body' => email_ticket_overdue_default_body(),
|
||||
],
|
||||
'live_chat_transcript' => [
|
||||
'label' => 'Chatverlauf (an Kunde, nach Chatende)',
|
||||
'placeholders' => ['ticket_number', 'customer_name', 'transcript', 'ticket_url'],
|
||||
'default_subject' => 'Ihr Chatverlauf – Ticket #{ticket_number}',
|
||||
'default_body' => email_live_chat_transcript_default_body(),
|
||||
],
|
||||
];
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$key = $_POST['template_key'] ?? '';
|
||||
if (!isset($templates[$key])) {
|
||||
$errors[] = 'Unbekannte Vorlage.';
|
||||
} else {
|
||||
$subject = trim($_POST['subject'] ?? '');
|
||||
$body = sanitize_staff_html(trim($_POST['body'] ?? ''));
|
||||
if ($subject === '') $errors[] = 'Bitte einen Betreff angeben.';
|
||||
if (trim(strip_tags($body)) === '') $errors[] = 'Bitte einen Text angeben.';
|
||||
|
||||
if (!$errors) {
|
||||
set_setting("email_template_{$key}_subject", $subject);
|
||||
set_setting("email_template_{$key}_body", $body);
|
||||
audit_log($admin['id'], "E-Mail-Vorlage geändert: {$templates[$key]['label']}");
|
||||
flash('success', 'Vorlage gespeichert.');
|
||||
redirect('/staff/email_templates.php');
|
||||
}
|
||||
}
|
||||
|
||||
if (($_POST['action'] ?? '') === 'reset') {
|
||||
$key = $_POST['template_key'] ?? '';
|
||||
if (isset($templates[$key])) {
|
||||
set_setting("email_template_{$key}_subject", null);
|
||||
set_setting("email_template_{$key}_body", null);
|
||||
audit_log($admin['id'], "E-Mail-Vorlage zurückgesetzt: {$templates[$key]['label']}");
|
||||
flash('success', 'Vorlage auf Standard zurückgesetzt.');
|
||||
redirect('/staff/email_templates.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'E-Mail-Vorlagen';
|
||||
$activePage = 'email_templates';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<h1>E-Mail-Vorlagen</h1>
|
||||
<p class="muted">Texte der automatischen E-Mails anpassen. Platzhalter in geschweiften Klammern werden beim Versand durch die echten Werte ersetzt. Der Branding-Rahmen (Kopfzeile mit Firmenname, Fußzeile) wird beim Versand automatisch um diesen Text herum ergänzt – hier nur den Inhalt der Karte anpassen.</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php foreach ($templates as $key => $tpl):
|
||||
$subject = get_setting("email_template_{$key}_subject") ?: $tpl['default_subject'];
|
||||
$body = get_setting("email_template_{$key}_body") ?: $tpl['default_body'];
|
||||
$isCustomized = get_setting("email_template_{$key}_subject") !== null;
|
||||
?>
|
||||
<div class="card">
|
||||
<h3><?= e($tpl['label']) ?> <?php if ($isCustomized): ?><span class="badge" style="background:#0f6f6a">angepasst</span><?php endif; ?></h3>
|
||||
<p class="muted" style="font-size:.82rem">Verfügbare Platzhalter: <?php foreach ($tpl['placeholders'] as $ph): ?><code>{<?= $ph ?>}</code> <?php endforeach; ?></p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="template_key" value="<?= e($key) ?>">
|
||||
<label for="subject_<?= $key ?>">Betreff</label>
|
||||
<input type="text" id="subject_<?= $key ?>" name="subject" value="<?= e($subject) ?>" required>
|
||||
<label for="body_<?= $key ?>">Text</label>
|
||||
<textarea id="body_<?= $key ?>" name="body" data-richtext style="min-height:140px" required><?= e($body) ?></textarea>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<?php if ($isCustomized): ?>
|
||||
<button type="submit" name="action" value="reset" class="btn secondary" formnovalidate onclick="return confirm('Diese Vorlage wirklich auf den Standardtext zurücksetzen?');">Auf Standard zurücksetzen</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin_or_permission('manage_escalations');
|
||||
$pdo = get_db();
|
||||
|
||||
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
|
||||
$staffList = $pdo->query("SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name")->fetchAll();
|
||||
$depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
|
||||
|
||||
$triggerTypes = [
|
||||
'no_reply' => 'Keine Mitarbeiter-Antwort seit X Stunden',
|
||||
'no_update' => 'Kein Update seit X Stunden',
|
||||
'overdue' => 'Ticket ist überfällig (SLA überschritten)',
|
||||
'open_too_long' => 'Ticket offen seit X Stunden',
|
||||
];
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'save') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$isActive = !empty($_POST['is_active']) ? 1 : 0;
|
||||
$triggerType = $_POST['trigger_type'] ?? 'no_reply';
|
||||
$triggerHours = max(1, (int)($_POST['trigger_hours'] ?? 24));
|
||||
$condStatus = trim($_POST['condition_status'] ?? '');
|
||||
$condPrioId = ($_POST['condition_priority_id'] ?? '') !== '' ? (int)$_POST['condition_priority_id'] : null;
|
||||
$condDeptId = ($_POST['condition_dept_id'] ?? '') !== '' ? (int)$_POST['condition_dept_id'] : null;
|
||||
$actPrioId = ($_POST['action_set_priority_id'] ?? '') !== '' ? (int)$_POST['action_set_priority_id'] : null;
|
||||
$actStaffId = ($_POST['action_assign_staff_id'] ?? '') !== '' ? (int)$_POST['action_assign_staff_id'] : null;
|
||||
$actStatus = $_POST['action_set_status'] ?? '';
|
||||
$actNotAdmin = !empty($_POST['action_notify_admin']) ? 1 : 0;
|
||||
$actNotStaff = !empty($_POST['action_notify_staff']) ? 1 : 0;
|
||||
$sortOrder = (int)($_POST['sort_order'] ?? 0);
|
||||
|
||||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||||
if (!array_key_exists($triggerType, $triggerTypes)) $errors[] = 'Ungültiger Trigger.';
|
||||
|
||||
if (!$errors) {
|
||||
$data = [$name, $isActive, $triggerType, $triggerHours, $condStatus ?: null,
|
||||
$condPrioId, $condDeptId, $actPrioId, $actStaffId,
|
||||
$actStatus ?: null, $actNotAdmin, $actNotStaff, $sortOrder];
|
||||
if ($id) {
|
||||
$pdo->prepare(
|
||||
'UPDATE escalation_rules SET name=?,is_active=?,trigger_type=?,trigger_hours=?,
|
||||
condition_status=?,condition_priority_id=?,condition_dept_id=?,
|
||||
action_set_priority_id=?,action_assign_staff_id=?,action_set_status=?,
|
||||
action_notify_admin=?,action_notify_staff=?,sort_order=? WHERE id=?'
|
||||
)->execute(array_merge($data, [$id]));
|
||||
audit_log($staff['id'], "Eskalationsregel geändert: $name");
|
||||
} else {
|
||||
$pdo->prepare(
|
||||
'INSERT INTO escalation_rules (name,is_active,trigger_type,trigger_hours,
|
||||
condition_status,condition_priority_id,condition_dept_id,
|
||||
action_set_priority_id,action_assign_staff_id,action_set_status,
|
||||
action_notify_admin,action_notify_staff,sort_order,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||
)->execute(array_merge($data, [db_now()]));
|
||||
audit_log($staff['id'], "Eskalationsregel erstellt: $name");
|
||||
}
|
||||
flash('success', 'Eskalationsregel gespeichert.');
|
||||
redirect('/staff/escalation_rules.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$pdo->prepare('DELETE FROM escalation_rules WHERE id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM escalation_log WHERE rule_id = ?')->execute([$id]);
|
||||
audit_log($staff['id'], "Eskalationsregel gelöscht: #$id");
|
||||
flash('success', 'Regel gelöscht.');
|
||||
redirect('/staff/escalation_rules.php');
|
||||
}
|
||||
|
||||
if ($action === 'toggle') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$pdo->prepare('UPDATE escalation_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]);
|
||||
redirect('/staff/escalation_rules.php');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$rules = $pdo->query(
|
||||
'SELECT er.*,
|
||||
p.name AS prio_name,
|
||||
s.name AS staff_name,
|
||||
d.name AS dept_name,
|
||||
cp.name AS cond_prio_name,
|
||||
(SELECT COUNT(*) FROM escalation_log el WHERE el.rule_id = er.id) AS applied_count
|
||||
FROM escalation_rules er
|
||||
LEFT JOIN priorities p ON p.id = er.action_set_priority_id
|
||||
LEFT JOIN staff s ON s.id = er.action_assign_staff_id
|
||||
LEFT JOIN departments d ON d.id = er.condition_dept_id
|
||||
LEFT JOIN priorities cp ON cp.id = er.condition_priority_id
|
||||
ORDER BY er.sort_order, er.id'
|
||||
)->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
$rules = [];
|
||||
}
|
||||
|
||||
$editId = (int)($_GET['edit'] ?? 0);
|
||||
$editRule = null;
|
||||
if ($editId) {
|
||||
foreach ($rules as $r) { if ($r['id'] === $editId) { $editRule = $r; break; } }
|
||||
}
|
||||
|
||||
$pageTitle = 'Eskalationsregeln';
|
||||
$activePage = 'ticket_rules';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>Eskalationsregeln</h1>
|
||||
<a href="?new=1" class="btn">+ Neue Regel</a>
|
||||
</div>
|
||||
|
||||
<p class="muted" style="margin-bottom:20px;font-size:.85rem">
|
||||
Eskalationsregeln werden stündlich per Cron geprüft (<code>cron/check_escalations.php</code>).
|
||||
Jede Regel wird pro Ticket nur einmal angewendet.
|
||||
</p>
|
||||
|
||||
<?php foreach ($errors as $e): ?><div class="flash error"><?= e($e) ?></div><?php endforeach; ?>
|
||||
|
||||
<!-- ── Formular ──────────────────────────────────────────────────────────────── -->
|
||||
<?php if (isset($_GET['new']) || $editRule): ?>
|
||||
<?php $r = $editRule; ?>
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<h3><?= $r ? 'Regel bearbeiten' : 'Neue Eskalationsregel' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<?php if ($r): ?><input type="hidden" name="id" value="<?= $r['id'] ?>"><?php endif; ?>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div>
|
||||
<label>Name <span style="color:var(--danger)">*</span></label>
|
||||
<input type="text" name="name" value="<?= e($r['name'] ?? '') ?>" required placeholder="z.B. Hohe Priorität nach 8h unbeantwortet">
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:16px">
|
||||
<div>
|
||||
<label>Sortierung</label>
|
||||
<input type="number" name="sort_order" value="<?= (int)($r['sort_order'] ?? 0) ?>" style="width:80px">
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;cursor:pointer;margin-bottom:1px">
|
||||
<input type="checkbox" name="is_active" value="1" style="width:auto" <?= ($r['is_active'] ?? 1) ? 'checked' : '' ?>>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border-soft)">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Auslöser</div>
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:12px">
|
||||
<div>
|
||||
<label>Trigger</label>
|
||||
<select name="trigger_type" id="trigger-type" onchange="updateTriggerHoursVisibility()">
|
||||
<?php foreach ($triggerTypes as $tv => $tl): ?>
|
||||
<option value="<?= $tv ?>" <?= ($r['trigger_type'] ?? '') === $tv ? 'selected' : '' ?>><?= e($tl) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div id="trigger-hours-wrap">
|
||||
<label>Stunden</label>
|
||||
<input type="number" name="trigger_hours" value="<?= (int)($r['trigger_hours'] ?? 24) ?>" min="1" max="8760">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border-soft)">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Bedingungen <span style="font-weight:400;text-transform:none;letter-spacing:0">(optional – leer = alle Tickets)</span></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<label>Nur Status</label>
|
||||
<input type="text" name="condition_status" value="<?= e($r['condition_status'] ?? '') ?>" placeholder="z.B. open,pending">
|
||||
<small class="muted">Kommagetrennt</small>
|
||||
</div>
|
||||
<div>
|
||||
<label>Nur Priorität</label>
|
||||
<select name="condition_priority_id">
|
||||
<option value="">Alle</option>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= ($r['condition_priority_id'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Nur Abteilung</label>
|
||||
<select name="condition_dept_id">
|
||||
<option value="">Alle</option>
|
||||
<?php foreach ($depts as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= ($r['condition_dept_id'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border-soft)">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Aktionen</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<div>
|
||||
<label>Priorität setzen</label>
|
||||
<select name="action_set_priority_id">
|
||||
<option value="">– keine Änderung –</option>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= ($r['action_set_priority_id'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Mitarbeiter zuweisen</label>
|
||||
<select name="action_assign_staff_id">
|
||||
<option value="">– keine Änderung –</option>
|
||||
<?php foreach ($staffList as $s): ?>
|
||||
<option value="<?= $s['id'] ?>" <?= ($r['action_assign_staff_id'] ?? '') == $s['id'] ? 'selected' : '' ?>><?= e($s['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Status setzen</label>
|
||||
<select name="action_set_status">
|
||||
<option value="">– keine Änderung –</option>
|
||||
<?php foreach (['open'=>'Offen','pending'=>'Wartet auf Kunde','on_hold'=>'Pausiert'] as $sv => $sl): ?>
|
||||
<option value="<?= $sv ?>" <?= ($r['action_set_status'] ?? '') === $sv ? 'selected' : '' ?>><?= $sl ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:20px;flex-wrap:wrap">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;cursor:pointer">
|
||||
<input type="checkbox" name="action_notify_admin" value="1" style="width:auto" <?= ($r['action_notify_admin'] ?? 1) ? 'checked' : '' ?>>
|
||||
Admin benachrichtigen
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;cursor:pointer">
|
||||
<input type="checkbox" name="action_notify_staff" value="1" style="width:auto" <?= !empty($r['action_notify_staff']) ? 'checked' : '' ?>>
|
||||
Zugewiesenen Mitarbeiter benachrichtigen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="margin-top:16px">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<a href="/staff/escalation_rules.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ── Regeln-Tabelle ─────────────────────────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<?php if (!$rules): ?>
|
||||
<div class="empty-state">Noch keine Eskalationsregeln. Erstellen Sie eine neue Regel mit dem Button oben.</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Auslöser</th>
|
||||
<th>Aktionen</th>
|
||||
<th style="text-align:right">Angewendet</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rules as $r): ?>
|
||||
<tr>
|
||||
<td style="font-weight:500"><?= e($r['name']) ?></td>
|
||||
<td style="font-size:.82rem;color:var(--ink-soft)">
|
||||
<?= e($triggerTypes[$r['trigger_type']] ?? $r['trigger_type']) ?>
|
||||
<?php if ($r['trigger_type'] !== 'overdue'): ?>
|
||||
<span class="muted">(<?= (int)$r['trigger_hours'] ?> Std)</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($r['cond_prio_name'] || $r['dept_name']): ?>
|
||||
<div style="font-size:.76rem;color:var(--muted)">
|
||||
<?= $r['cond_prio_name'] ? '· Priorität: '.e($r['cond_prio_name']) : '' ?>
|
||||
<?= $r['dept_name'] ? '· Abteilung: '.e($r['dept_name']) : '' ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="font-size:.82rem">
|
||||
<?php $acts = []; ?>
|
||||
<?php if ($r['prio_name']): $acts[] = '↑ Priorität: '.e($r['prio_name']); endif; ?>
|
||||
<?php if ($r['staff_name']): $acts[] = '→ '.e($r['staff_name']); endif; ?>
|
||||
<?php if ($r['action_set_status']): $acts[] = '⚑ '.e($r['action_set_status']); endif; ?>
|
||||
<?php if ($r['action_notify_admin']): $acts[] = '📧 Admin'; endif; ?>
|
||||
<?php if ($r['action_notify_staff']): $acts[] = '📧 Mitarbeiter'; endif; ?>
|
||||
<?= implode(', ', $acts) ?: '<span class="muted">Nur benachrichtigen</span>' ?>
|
||||
</td>
|
||||
<td style="text-align:right;color:var(--muted);font-size:.82rem"><?= (int)$r['applied_count'] ?>×</td>
|
||||
<td>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:.78rem;font-weight:600;color:<?= $r['is_active'] ? '#22c55e' : '#94a3b8' ?>">
|
||||
<span style="width:7px;height:7px;border-radius:50%;background:currentColor"></span>
|
||||
<?= $r['is_active'] ? 'Aktiv' : 'Inaktiv' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="?edit=<?= $r['id'] ?>" style="font-size:.82rem">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<input type="hidden" name="id" value="<?= $r['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;color:var(--primary);font-size:.82rem;padding:0">
|
||||
<?= $r['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?>
|
||||
</button>
|
||||
</form> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Regel wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $r['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;color:var(--danger);font-size:.82rem;padding:0">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3>Crontab-Eintrag</h3>
|
||||
<pre style="background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;padding:12px;font-size:.78rem;margin:0">0 * * * * php /pfad/zu/deskhub/cron/check_escalations.php</pre>
|
||||
<p class="muted" style="font-size:.79rem;margin:8px 0 0">Stündliche Prüfung. Letzte Ausführung: <?= e(get_setting('cron_check_escalations_last_run', 'noch nie')) ?></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateTriggerHoursVisibility() {
|
||||
var t = document.getElementById('trigger-type').value;
|
||||
document.getElementById('trigger-hours-wrap').style.display = t === 'overdue' ? 'none' : 'block';
|
||||
}
|
||||
updateTriggerHoursVisibility();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
|
||||
|
||||
$type = $_GET['type'] ?? 'tickets';
|
||||
|
||||
function csv_response(string $filename, array $header, array $rows): void
|
||||
{
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
$out = fopen('php://output', 'w');
|
||||
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOM, damit Excel Umlaute korrekt anzeigt
|
||||
fputcsv($out, $header, ';');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, $row, ';');
|
||||
}
|
||||
fclose($out);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($type === 'tickets') {
|
||||
// Dieselben Filter wie staff/tickets.php, inkl. Abteilungs-Zugriffsbeschränkung
|
||||
$filterDept = $_GET['department_id'] ?? '';
|
||||
$filterStatus = $_GET['status'] ?? '';
|
||||
$filterPrio = $_GET['priority_id'] ?? '';
|
||||
$filterAssign = $_GET['assigned'] ?? '';
|
||||
$filterTag = $_GET['tag_id'] ?? '';
|
||||
$search = trim($_GET['q'] ?? '');
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
[$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 't.department_id');
|
||||
if ($deptFilter !== '') { $where[] = substr($deptFilter, 5); array_push($params, ...$deptParams); }
|
||||
|
||||
if ($filterDept !== '') { $where[] = 't.department_id = ?'; $params[] = $filterDept; }
|
||||
if ($filterStatus !== '') { $where[] = 't.status = ?'; $params[] = $filterStatus; }
|
||||
if ($filterPrio !== '') { $where[] = 't.priority_id = ?'; $params[] = $filterPrio; }
|
||||
if ($filterAssign === 'me') { $where[] = 't.assigned_staff_id = ?'; $params[] = $staff['id']; }
|
||||
elseif ($filterAssign === 'unassigned') { $where[] = 't.assigned_staff_id IS NULL'; }
|
||||
elseif ($filterAssign !== '') { $where[] = 't.assigned_staff_id = ?'; $params[] = $filterAssign; }
|
||||
if ($filterTag !== '') { $where[] = 't.id IN (SELECT ticket_id FROM ticket_tags WHERE tag_id = ?)'; $params[] = $filterTag; }
|
||||
if ($search !== '') {
|
||||
$where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ?)';
|
||||
$like = '%' . $search . '%';
|
||||
array_push($params, $like, $like, $like);
|
||||
}
|
||||
if ($filterStatus === '') { $where[] = "t.status != 'closed'"; }
|
||||
|
||||
$sql = "SELECT t.ticket_number, t.subject, t.requester_name, t.requester_email, d.name AS department_name,
|
||||
p.name AS priority_name, t.status, s.name AS staff_name, t.created_at, t.resolution_due_at
|
||||
FROM tickets t
|
||||
JOIN priorities p ON p.id = t.priority_id
|
||||
JOIN departments d ON d.id = t.department_id
|
||||
LEFT JOIN staff s ON s.id = t.assigned_staff_id";
|
||||
if ($where) { $sql .= ' WHERE ' . implode(' AND ', $where); }
|
||||
$sql .= ' ORDER BY t.resolution_due_at ASC LIMIT 2000';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll() as $t) {
|
||||
[$statusLabel] = status_label($t['status']);
|
||||
$rows[] = [
|
||||
$t['ticket_number'], $t['subject'], $t['requester_name'], $t['requester_email'],
|
||||
$t['department_name'], $t['priority_name'], $statusLabel,
|
||||
$t['staff_name'] ?: '', $t['created_at'], $t['resolution_due_at'],
|
||||
];
|
||||
}
|
||||
|
||||
csv_response('tickets_' . date('Y-m-d') . '.csv',
|
||||
['Ticketnummer', 'Betreff', 'Anfrager', 'E-Mail', 'Abteilung', 'Priorität', 'Status', 'Zugewiesen', 'Erstellt', 'Fällig'],
|
||||
$rows);
|
||||
}
|
||||
|
||||
if ($type === 'report') {
|
||||
// Berichte sind seit Kurzem für alle Mitarbeiter sichtbar (abteilungsbeschränkt für Nicht-Admins),
|
||||
// der Export folgt derselben Regel wie staff/reports.php.
|
||||
$dim = $_GET['dim'] ?? 'dept';
|
||||
$dimColumn = ['dept' => 'department_id', 'topic' => 'help_topic_id', 'agent' => 'assigned_staff_id'][$dim] ?? 'department_id';
|
||||
|
||||
$preset = $_GET['range'] ?? '30d';
|
||||
$today = new DateTime('today');
|
||||
switch ($preset) {
|
||||
case '7d': $from = (clone $today)->modify('-6 days'); $to = clone $today; break;
|
||||
case 'thismonth': $from = new DateTime(date('Y-m-01')); $to = clone $today; break;
|
||||
case 'thisyear': $from = new DateTime(date('Y-01-01')); $to = clone $today; break;
|
||||
case 'custom':
|
||||
$from = !empty($_GET['from']) ? new DateTime($_GET['from']) : (clone $today)->modify('-29 days');
|
||||
$to = !empty($_GET['to']) ? new DateTime($_GET['to']) : clone $today;
|
||||
break;
|
||||
default: $from = (clone $today)->modify('-29 days'); $to = clone $today;
|
||||
}
|
||||
if ($from > $to) { [$from, $to] = [$to, $from]; }
|
||||
$rangeFrom = $from->format('Y-m-d 00:00:00');
|
||||
$rangeTo = $to->format('Y-m-d 23:59:59');
|
||||
|
||||
$rows = build_report_table($pdo, $dimColumn, $rangeFrom, $rangeTo, $myDeptIds);
|
||||
|
||||
$labelMap = match ($dim) {
|
||||
'topic' => $pdo->query('SELECT id, name FROM help_topics')->fetchAll(PDO::FETCH_KEY_PAIR),
|
||||
'agent' => $pdo->query('SELECT id, name FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR),
|
||||
default => $pdo->query('SELECT id, name FROM departments')->fetchAll(PDO::FETCH_KEY_PAIR),
|
||||
};
|
||||
$noneLabel = match ($dim) {
|
||||
'topic' => 'Kein Hilfethema',
|
||||
'agent' => 'Nicht zugewiesen',
|
||||
default => 'Ohne Abteilung',
|
||||
};
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $key => $r) {
|
||||
$label = $key === 'none' ? $noneLabel : ($labelMap[(int) $key] ?? "#{$key}");
|
||||
$out[] = [
|
||||
$label, $r['geoeffnet'], $r['zugewiesen'], $r['ueberfaellig'], $r['geschlossen'],
|
||||
$r['wieder_geoeffnet'], $r['geloescht'],
|
||||
$r['servicezeit'] ?? '', $r['reaktionszeit'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
csv_response('bericht_' . $dim . '_' . date('Y-m-d') . '.csv',
|
||||
['Bezeichnung', 'Geöffnet', 'Zugewiesen', 'Überfällig', 'Geschlossen', 'Wieder geöffnet', 'Gelöscht', 'Servicezeit (Std)', 'Reaktionszeit (Std)'],
|
||||
$out);
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
echo 'Unbekannter Export-Typ.';
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
if (current_staff()) {
|
||||
redirect('/staff/reports.php');
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$sent = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
|
||||
} elseif (!rate_limit_check('password_reset_request_staff', client_ip(), 3, 15)) {
|
||||
$errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} elseif (!rate_limit_check('password_reset_request_staff_email', $email, 2, 60)) {
|
||||
// Gleiche Fehlermeldung – verrät nicht ob die E-Mail bekannt ist
|
||||
$errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} else {
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
|
||||
$stmt->execute([$email]);
|
||||
$staff = $stmt->fetch();
|
||||
|
||||
if ($staff) {
|
||||
$token = create_password_reset('staff', $email);
|
||||
$link = base_url() . '/staff/reset_password.php?token=' . urlencode($token);
|
||||
email_send_and_log($email, 'Passwort zurücksetzen (Mitarbeiterbereich)', email_password_reset_body($link), null, 'Passwort-zurücksetzen-Mail (Mitarbeiter)');
|
||||
}
|
||||
$sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Passwort vergessen';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Passwort vergessen – Mitarbeiterbereich</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<?php require __DIR__ . '/../includes/brand_block.php'; ?>
|
||||
<h1>Passwort vergessen</h1>
|
||||
<div class="sub">Mitarbeiterbereich – wir senden Ihnen einen Link zum Zurücksetzen.</div>
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($sent): ?>
|
||||
<div class="flash success">Falls ein aktives Mitarbeiterkonto mit dieser E-Mail-Adresse existiert, wurde soeben ein Link zum Zurücksetzen versendet.</div>
|
||||
<p style="margin-top:18px;font-size:.85rem"><a href="/staff/login.php">← Zur Anmeldung</a></p>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" required autofocus>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" style="width:100%">Link anfordern</button>
|
||||
</div>
|
||||
</form>
|
||||
<p style="margin-top:18px;font-size:.85rem"><a href="/staff/login.php">← Zur Anmeldung</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin_or_permission('manage_help_topics');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM help_topics')->fetchAll(PDO::FETCH_COLUMN)));
|
||||
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
|
||||
try {
|
||||
help_topic_save($pdo, $id, [
|
||||
'name' => $_POST['name'] ?? '', 'department_id' => $_POST['department_id'] ?? 0, 'priority_id' => $_POST['priority_id'] ?? 0,
|
||||
'is_active' => ($_POST['status'] ?? 'active') === 'active', 'is_public' => ($_POST['type'] ?? 'public') === 'public',
|
||||
'parent_id' => $_POST['parent_id'] ?? null, 'sla_plan_id' => $_POST['sla_plan_id'] ?? null,
|
||||
'notes' => $_POST['notes'] ?? '', 'sort_order' => $_POST['sort_order'] ?? 0,
|
||||
]);
|
||||
audit_log($admin['id'], ($action === 'create' ? 'Hilfethema erstellt: ' : 'Hilfethema aktualisiert: ') . trim($_POST['name'] ?? ''));
|
||||
flash('success', $action === 'create' ? 'Hilfethema erstellt.' : 'Änderungen gespeichert.');
|
||||
redirect('/staff/help_topics.php');
|
||||
} catch (AdminModelException $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$deleted = help_topic_delete($pdo, (int) $_POST['id']);
|
||||
audit_log($admin['id'], "Hilfethema gelöscht: {$deleted}");
|
||||
flash('success', 'Hilfethema gelöscht.');
|
||||
redirect('/staff/help_topics.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$editTopic = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM help_topics WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editTopic = $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
$topicFields = $globalFields = [];
|
||||
$fieldTypeLabels = ['text' => 'Einzeiliger Text', 'textarea' => 'Mehrzeiliger Text', 'select' => 'Dropdown-Auswahl', 'checkbox' => 'Checkbox (Ja/Nein)', 'number' => 'Zahl', 'date' => 'Datum'];
|
||||
if ($editTopic) {
|
||||
$cfStmt = $pdo->prepare('SELECT * FROM custom_fields WHERE help_topic_id = ? ORDER BY sort_order, label');
|
||||
$cfStmt->execute([$editTopic['id']]);
|
||||
$topicFields = $cfStmt->fetchAll();
|
||||
$globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll();
|
||||
}
|
||||
$sortMode = ($_GET['sort'] ?? 'alpha') === 'manual' ? 'manual' : 'alpha';
|
||||
$topics = $pdo->query(
|
||||
'SELECT h.*, d.name AS department_name, p.name AS priority_name
|
||||
FROM help_topics h
|
||||
JOIN departments d ON d.id = h.department_id
|
||||
JOIN priorities p ON p.id = h.priority_id
|
||||
ORDER BY ' . ($sortMode === 'manual' ? 'h.sort_order, h.name' : 'h.name')
|
||||
)->fetchAll();
|
||||
// Anzeigename mit Elternpfad
|
||||
$names = array_column($topics, 'name', 'id');
|
||||
$parents = array_column($topics, 'parent_id', 'id');
|
||||
$pathOf = function (int $id) use ($names, $parents): string {
|
||||
$parts = []; $seen = [];
|
||||
while ($id && isset($names[$id]) && !isset($seen[$id])) { $seen[$id] = true; array_unshift($parts, $names[$id]); $id = (int) ($parents[$id] ?? 0); }
|
||||
return implode(' / ', $parts);
|
||||
};
|
||||
$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
|
||||
|
||||
$pageTitle = 'Hilfethemen';
|
||||
$activePage = 'help_topics';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editTopic[$col] ?? $default);
|
||||
$curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editTopic['is_active'] ?? 1) ? 'active' : 'inactive');
|
||||
$curType = $_POST ? ($_POST['type'] ?? 'public') : (($editTopic['is_public'] ?? 1) ? 'public' : 'internal');
|
||||
$curParent = (string) $v('parent_id', 'parent_id');
|
||||
$excluded = $editTopic ? array_merge([(int) $editTopic['id']], help_topic_descendants($pdo, (int) $editTopic['id'])) : [];
|
||||
?>
|
||||
<h1>
|
||||
<?= $editTopic ? 'Hilfethema aktualisieren' : 'Neues Hilfethema' ?>
|
||||
<?php if ($editTopic): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editTopic['name']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editTopic ? 'update' : 'create' ?>">
|
||||
<?php if ($editTopic): ?><input type="hidden" name="id" value="<?= (int) $editTopic['id'] ?>"><?php endif; ?>
|
||||
|
||||
<div style="display:flex;gap:4px;padding:10px 12px 0;border-bottom:1px solid var(--border)">
|
||||
<button type="button" class="tab-btn" data-tab="info">Hilfethemeninformationen</button>
|
||||
<button type="button" class="tab-btn" data-tab="options">Optionen bei neuem Ticket</button>
|
||||
<button type="button" class="tab-btn" data-tab="forms">Formulare</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="info" style="padding:16px 20px">
|
||||
<label for="name">Thema *</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" placeholder="z. B. Technisches Problem" required>
|
||||
<label for="status">Status *</label>
|
||||
<select id="status" name="status">
|
||||
<option value="active" <?= $curStatus === 'active' ? 'selected' : '' ?>>Aktiv</option>
|
||||
<option value="inactive" <?= $curStatus === 'inactive' ? 'selected' : '' ?>>Deaktiviert</option>
|
||||
</select>
|
||||
<label>Typ *</label>
|
||||
<div style="display:flex;gap:18px;margin:4px 0 10px">
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="type" value="public" style="width:auto" <?= $curType === 'public' ? 'checked' : '' ?>> Öffentlich</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="type" value="internal" style="width:auto" <?= $curType === 'internal' ? 'checked' : '' ?>> Vertraulich/Intern</label>
|
||||
</div>
|
||||
<p class="muted" style="font-size:.78rem;margin-top:-6px">Öffentliche Themen sehen auch Kunden im Portal; interne nur Mitarbeiter.</p>
|
||||
<label for="parent_id">Übergeordnetes Thema</label>
|
||||
<select id="parent_id" name="parent_id">
|
||||
<option value="">— Oberste Hilfethemenebene —</option>
|
||||
<?php foreach ($topics as $t): if (in_array((int) $t['id'], $excluded, true)) continue; ?>
|
||||
<option value="<?= $t['id'] ?>" <?= $curParent === (string) $t['id'] ? 'selected' : '' ?>><?= e($pathOf((int) $t['id'])) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="notes">Interne Notizen <span class="muted" style="font-weight:400">– Sei offen, sie sind vertraulich</span></label>
|
||||
<textarea id="notes" name="notes" rows="8"><?= e($v('notes', 'notes')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="options" style="padding:16px 20px;display:none">
|
||||
<p class="muted" style="font-size:.82rem;margin-top:0">Diese Werte werden beim Anlegen eines Tickets mit diesem Thema automatisch gesetzt.</p>
|
||||
<label for="department_id">Abteilung *</label>
|
||||
<select name="department_id" id="department_id" required>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= (string) $v('department_id', 'department_id') === (string) $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="priority_id">Priorität *</label>
|
||||
<select name="priority_id" id="priority_id" required>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= (string) $v('priority_id', 'priority_id') === (string) $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="sla_plan_id">SLA-Plan</label>
|
||||
<select name="sla_plan_id" id="sla_plan_id">
|
||||
<option value="">— Abteilung/Priorität bestimmt die Zeiten —</option>
|
||||
<?php foreach ($pdo->query('SELECT id, name, is_active FROM sla_plans ORDER BY name')->fetchAll() as $__sp): ?>
|
||||
<option value="<?= $__sp['id'] ?>" <?= (string) $v('sla_plan_id', 'sla_plan_id') === (string) $__sp['id'] ? 'selected' : '' ?>><?= e($__sp['name']) ?><?= $__sp['is_active'] ? '' : ' (deaktiviert)' ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="sort_order">Sortierung</label>
|
||||
<input type="number" id="sort_order" name="sort_order" value="<?= e((string) $v('sort_order', 'sort_order', '0')) ?>">
|
||||
<p class="muted" style="font-size:.78rem;margin-top:10px">Zusatzfelder pro Thema legst du unter <a href="/staff/custom_fields.php">Zusatzfelder</a> an.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="forms" style="padding:16px 20px;display:none">
|
||||
<h3 style="margin-top:0">Formulare</h3>
|
||||
<?php if (!$editTopic): ?>
|
||||
<p class="muted">Zusatzfelder lassen sich nach dem Erstellen des Hilfethemas zuordnen.</p>
|
||||
<?php else: ?>
|
||||
<p class="muted" style="margin-top:0">Diese Zusatzfelder erscheinen auf dem Ticketformular, sobald Kunden dieses Hilfethema wählen.</p>
|
||||
<?php if (!$topicFields): ?>
|
||||
<p class="muted">Für dieses Thema gibt es noch keine eigenen Felder.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Bezeichnung</th><th>Typ</th><th>Pflicht</th><th>Aktiv</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($topicFields as $cf): ?>
|
||||
<tr>
|
||||
<td><?= e($cf['label']) ?></td>
|
||||
<td><?= e($fieldTypeLabels[$cf['field_type']] ?? $cf['field_type']) ?></td>
|
||||
<td><?= $cf['is_required'] ? '✓' : '–' ?></td>
|
||||
<td><?= $cf['is_active'] ? '✓' : '–' ?></td>
|
||||
<td><a href="/staff/custom_fields.php?edit=<?= (int) $cf['id'] ?>">Bearbeiten</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
<p style="margin-top:12px"><a class="btn secondary" href="/staff/custom_fields.php?topic=<?= (int) $editTopic['id'] ?>">+ Neues Feld für dieses Thema</a></p>
|
||||
<?php if ($globalFields): ?>
|
||||
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px">Felder für alle Themen</h4>
|
||||
<p class="muted" style="font-size:.82rem;margin-top:0">Gelten zusätzlich bei jedem Thema und werden unter <a href="/staff/custom_fields.php">Ticket-Felder</a> verwaltet.</p>
|
||||
<ul style="margin:0 0 0 18px"><?php foreach ($globalFields as $cf): ?><li><?= e($cf['label']) ?><?= $cf['is_required'] ? ' *' : '' ?></li><?php endforeach; ?></ul>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn">Änderungen speichern</button>
|
||||
<a href="/staff/help_topics.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.tab-btn{background:none;border:1px solid transparent;border-bottom:none;padding:8px 16px;cursor:pointer;border-radius:6px 6px 0 0;color:var(--muted)}
|
||||
.tab-btn.active{background:var(--card-bg,#fff);border-color:var(--border);color:inherit;font-weight:600;position:relative;top:1px}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var btns = document.querySelectorAll('.tab-btn'), panes = document.querySelectorAll('.tab-pane');
|
||||
function show(t) {
|
||||
btns.forEach(function (b) { b.classList.toggle('active', b.dataset.tab === t); });
|
||||
panes.forEach(function (p) { p.style.display = p.dataset.pane === t ? '' : 'none'; });
|
||||
}
|
||||
btns.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
|
||||
show('info');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<h1 style="margin:0">Hilfethemen</h1>
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<form method="get" style="display:flex;align-items:center;gap:6px;margin:0">
|
||||
<label for="sort" style="margin:0">Sortiermodus:</label>
|
||||
<select id="sort" name="sort" onchange="this.form.submit()" style="width:auto">
|
||||
<option value="alpha" <?= $sortMode === 'alpha' ? 'selected' : '' ?>>Alphabetisch</option>
|
||||
<option value="manual" <?= $sortMode === 'manual' ? 'selected' : '' ?>>Manuell</option>
|
||||
</select>
|
||||
</form>
|
||||
<a href="/staff/help_topics.php?new=1" class="btn">Neues Hilfethema hinzufügen</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Hilfethemen erscheinen im Kundenportal als Auswahl bei der Ticket-Erstellung und setzen automatisch Abteilung und Priorität.</p>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Hilfethema</th><th>Status</th><th>Typ</th><th>Priorität</th><th>Abteilung</th><th>Zuletzt aktualisiert</th><th>Erstellt</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($topics as $t): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/help_topics.php?edit=<?= (int) $t['id'] ?>"><?= e($pathOf((int) $t['id'])) ?></a></td>
|
||||
<td><strong><?= $t['is_active'] ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
|
||||
<td><?= ($t['is_public'] ?? 1) ? 'Öffentlich' : 'Intern' ?></td>
|
||||
<td><?= e($t['priority_name']) ?></td>
|
||||
<td><?= e($t['department_name']) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($fmt($t['updated_at'] ?? null)) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($fmt($t['created_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/help_topics.php?edit=<?= (int) $t['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Hilfethema wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $t['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$topics): ?>
|
||||
<tr><td colspan="8" class="muted">Noch keine Hilfethemen angelegt.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin_or_permission('manage_email_addresses');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM imap_accounts WHERE department_id IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN)));
|
||||
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$label = trim($_POST['label'] ?? '') ?: 'Postfach';
|
||||
$host = trim($_POST['host'] ?? '');
|
||||
$port = (int) ($_POST['port'] ?? 993) ?: 993;
|
||||
$enc = in_array($_POST['encryption'] ?? 'ssl', ['ssl','tls','none'], true) ? $_POST['encryption'] : 'ssl';
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$folder = trim($_POST['folder'] ?? '') ?: 'INBOX';
|
||||
$afterFetch = in_array($_POST['after_fetch'] ?? 'seen', ['seen','archive','delete'], true) ? $_POST['after_fetch'] : 'seen';
|
||||
$archiveFolder = trim($_POST['archive_folder'] ?? '') ?: 'Processed';
|
||||
$spamFolder = trim($_POST['spam_folder'] ?? '') ?: 'Junk';
|
||||
$deptId = $_POST['department_id'] !== '' ? (int) $_POST['department_id'] : null;
|
||||
$prioId = $_POST['priority_id'] !== '' ? (int) $_POST['priority_id'] : null;
|
||||
$isActive = !empty($_POST['is_active']) ? 1 : 0;
|
||||
$plainPass = trim($_POST['password'] ?? '');
|
||||
|
||||
if ($host === '') $errors[] = 'Bitte einen Server (Host) angeben.';
|
||||
if ($username === '') $errors[] = 'Bitte einen Benutzernamen angeben.';
|
||||
if ($action === 'create' && $plainPass === '') $errors[] = 'Bitte ein Passwort angeben.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare(
|
||||
'INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, spam_folder, department_id, priority_id, is_active, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||
)->execute([
|
||||
$label, $host, $port, $enc, $username, encrypt_secret($plainPass), $folder,
|
||||
$afterFetch, $archiveFolder, $spamFolder, $deptId, $prioId, $isActive, db_now(),
|
||||
]);
|
||||
audit_log($staff['id'], "IMAP-Postfach angelegt: {$label} ({$host})");
|
||||
flash('success', 'Postfach angelegt.');
|
||||
} else {
|
||||
$id = (int) $_POST['id'];
|
||||
if ($plainPass !== '') {
|
||||
$pdo->prepare(
|
||||
'UPDATE imap_accounts SET label=?, host=?, port=?, encryption=?, username=?, password_enc=?, folder=?, after_fetch=?, archive_folder=?, spam_folder=?, department_id=?, priority_id=?, is_active=? WHERE id=?'
|
||||
)->execute([$label, $host, $port, $enc, $username, encrypt_secret($plainPass), $folder, $afterFetch, $archiveFolder, $spamFolder, $deptId, $prioId, $isActive, $id]);
|
||||
} else {
|
||||
// Passwort leer = unverändert lassen
|
||||
$pdo->prepare(
|
||||
'UPDATE imap_accounts SET label=?, host=?, port=?, encryption=?, username=?, folder=?, after_fetch=?, archive_folder=?, spam_folder=?, department_id=?, priority_id=?, is_active=? WHERE id=?'
|
||||
)->execute([$label, $host, $port, $enc, $username, $folder, $afterFetch, $archiveFolder, $spamFolder, $deptId, $prioId, $isActive, $id]);
|
||||
}
|
||||
audit_log($staff['id'], "IMAP-Postfach aktualisiert: {$label} ({$host})");
|
||||
flash('success', 'Postfach gespeichert.');
|
||||
}
|
||||
redirect('/staff/imap_accounts.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
$lblStmt = $pdo->prepare('SELECT label FROM imap_accounts WHERE id = ?');
|
||||
$lblStmt->execute([$id]);
|
||||
$lbl = $lblStmt->fetchColumn();
|
||||
$pdo->prepare('DELETE FROM imap_accounts WHERE id = ?')->execute([$id]);
|
||||
audit_log($staff['id'], "IMAP-Postfach gelöscht: {$lbl}");
|
||||
flash('success', 'Postfach gelöscht.');
|
||||
redirect('/staff/imap_accounts.php');
|
||||
}
|
||||
|
||||
if ($action === 'test') {
|
||||
$id = (int) $_POST['id'];
|
||||
$stmt = $pdo->prepare('SELECT * FROM imap_accounts WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$acc = $stmt->fetch();
|
||||
if (!$acc) {
|
||||
flash('error', 'Postfach nicht gefunden.');
|
||||
} elseif (!function_exists('imap_open')) {
|
||||
flash('error', 'PHP-IMAP-Erweiterung (php-imap) ist auf diesem Server nicht installiert.');
|
||||
} else {
|
||||
$sslFlag = match ($acc['encryption']) {
|
||||
'ssl' => '/imap/ssl', 'tls' => '/imap/tls', default => '/imap/notls',
|
||||
};
|
||||
$mailbox = '{' . $acc['host'] . ':' . $acc['port'] . $sslFlag . '}' . $acc['folder'];
|
||||
$mbox = @imap_open($mailbox, $acc['username'], decrypt_secret($acc['password_enc']), 0, 1);
|
||||
if ($mbox) {
|
||||
$unseen = imap_search($mbox, 'UNSEEN') ?: [];
|
||||
imap_close($mbox);
|
||||
flash('success', "Verbindung zu „{$acc['label']}" . "\" erfolgreich. " . count($unseen) . ' ungelesene Mail(s) im Postfach.');
|
||||
} else {
|
||||
flash('error', "Verbindung zu „{$acc['label']}\" fehlgeschlagen: " . imap_last_error());
|
||||
}
|
||||
}
|
||||
redirect('/staff/imap_accounts.php');
|
||||
}
|
||||
}
|
||||
|
||||
$accounts = $pdo->query(
|
||||
'SELECT ia.*, d.name AS department_name, p.name AS priority_name
|
||||
FROM imap_accounts ia
|
||||
LEFT JOIN departments d ON d.id = ia.department_id
|
||||
LEFT JOIN priorities p ON p.id = ia.priority_id
|
||||
WHERE ia.email_address_id IS NULL
|
||||
ORDER BY ia.id'
|
||||
)->fetchAll();
|
||||
|
||||
$editId = $_GET['edit'] ?? null;
|
||||
$editAcc = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM imap_accounts WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editAcc = $stmt->fetch();
|
||||
}
|
||||
|
||||
$pageTitle = 'Postfächer (E-Mail-Abruf)';
|
||||
$activePage = 'imap_accounts';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="flash info" style="margin-bottom:16px">
|
||||
Postfächer werden jetzt direkt bei der jeweiligen <a href="/staff/email_addresses.php"><strong>E-Mail-Adresse</strong></a> im Reiter „Entferntes Postfach“ eingerichtet.
|
||||
Diese Seite zeigt nur noch ältere, nicht verknüpfte Postfächer.
|
||||
</div>
|
||||
<h1>Postfächer für den E-Mail-Abruf</h1>
|
||||
<p class="muted">
|
||||
Jedes Postfach wird beim Abruf (Cronjob <code>fetch_emails.php</code>) einzeln per IMAP abgefragt.
|
||||
Nutzt ihr getrennte Mail-Konten pro Abteilung (statt eines Sammelpostfachs mit Weiterleitungen),
|
||||
legt hier einfach für jede Abteilung ein eigenes Postfach an und wählt die passende Standardabteilung –
|
||||
die <a href="/staff/email_addresses.php">Abteilungs-Routing-Tabelle</a> per Empfängeradresse ist dann optional
|
||||
und greift nur noch zusätzlich, falls ein einzelnes Postfach doch Mails für mehrere Abteilungen empfängt.
|
||||
</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex; gap:24px; align-items:flex-start">
|
||||
<div style="flex:2">
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Postfach</th><th>Server</th><th>Standardabteilung</th><th>Aktiv</th><th>Letzter Abruf</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<tr>
|
||||
<td><strong><?= e($a['label']) ?></strong><br><span class="muted" style="font-size:.78rem"><?= e($a['username']) ?></span></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($a['host']) ?>:<?= e($a['port']) ?></td>
|
||||
<td><?= e($a['department_name'] ?? 'Automatisch (Routing/Standard)') ?></td>
|
||||
<td><?= $a['is_active'] ? '✓' : '–' ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(cron_last_run_label($a['last_run_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="test">
|
||||
<input type="hidden" name="id" value="<?= $a['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:var(--primary);cursor:pointer;padding:0;font-size:.85rem">Testen</button>
|
||||
</form> ·
|
||||
<a href="/staff/imap_accounts.php?edit=<?= $a['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Postfach wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $a['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.85rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$accounts): ?>
|
||||
<tr><td colspan="6" class="muted">Noch kein Postfach angelegt.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1; min-width:300px">
|
||||
<div class="card">
|
||||
<h3><?= $editAcc ? 'Postfach bearbeiten' : 'Neues Postfach' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editAcc ? 'update' : 'create' ?>">
|
||||
<?php if ($editAcc): ?><input type="hidden" name="id" value="<?= $editAcc['id'] ?>"><?php endif; ?>
|
||||
<label for="label">Bezeichnung</label>
|
||||
<input type="text" id="label" name="label" value="<?= e($editAcc['label'] ?? '') ?>" placeholder="z.B. Technik-Postfach" required>
|
||||
<div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:flex-end;margin-top:10px">
|
||||
<div>
|
||||
<label for="host">Server</label>
|
||||
<input type="text" id="host" name="host" value="<?= e($editAcc['host'] ?? '') ?>" placeholder="imap.beispiel.de" required>
|
||||
</div>
|
||||
<div style="width:80px">
|
||||
<label for="port">Port</label>
|
||||
<input type="number" id="port" name="port" value="<?= e($editAcc['port'] ?? '993') ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">
|
||||
<div>
|
||||
<label for="username">Benutzername / E-Mail</label>
|
||||
<input type="text" id="username" name="username" value="<?= e($editAcc['username'] ?? '') ?>" autocomplete="off" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" placeholder="<?= $editAcc ? '(unverändert)' : '' ?>" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">
|
||||
<div>
|
||||
<label for="folder">Postfach / Ordner</label>
|
||||
<input type="text" id="folder" name="folder" value="<?= e($editAcc['folder'] ?? 'INBOX') ?>">
|
||||
</div>
|
||||
<div>
|
||||
<label for="encryption">Verschlüsselung</label>
|
||||
<select id="encryption" name="encryption">
|
||||
<option value="ssl" <?= ($editAcc['encryption'] ?? 'ssl')==='ssl' ?'selected':'' ?>>SSL/TLS (993)</option>
|
||||
<option value="tls" <?= ($editAcc['encryption'] ?? '')==='tls' ?'selected':'' ?>>STARTTLS (143)</option>
|
||||
<option value="none" <?= ($editAcc['encryption'] ?? '')==='none'?'selected':'' ?>>Keine</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label for="department_id" style="margin-top:10px">Standardabteilung</label>
|
||||
<select id="department_id" name="department_id">
|
||||
<option value="">Automatisch (Routing-Tabelle / globale Standardabteilung)</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= ($editAcc['department_id'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="priority_id">Standardpriorität (optional)</label>
|
||||
<select id="priority_id" name="priority_id">
|
||||
<option value="">Automatisch</option>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= ($editAcc['priority_id'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="after_fetch" style="margin-top:10px">Aktion nach Abruf</label>
|
||||
<select id="after_fetch" name="after_fetch" onchange="document.getElementById('archive-row').style.display=this.value==='archive'?'block':'none'">
|
||||
<option value="seen" <?= ($editAcc['after_fetch'] ?? 'seen')==='seen' ?'selected':'' ?>>Als gelesen markieren</option>
|
||||
<option value="archive" <?= ($editAcc['after_fetch'] ?? '')==='archive'?'selected':'' ?>>In Ordner verschieben</option>
|
||||
<option value="delete" <?= ($editAcc['after_fetch'] ?? '')==='delete' ?'selected':'' ?>>Löschen</option>
|
||||
</select>
|
||||
<div id="archive-row" style="display:<?= ($editAcc['after_fetch'] ?? '')==='archive'?'block':'none' ?>;margin-top:10px">
|
||||
<label for="archive_folder">Zielordner</label>
|
||||
<input type="text" id="archive_folder" name="archive_folder" value="<?= e($editAcc['archive_folder'] ?? 'Processed') ?>">
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<label for="spam_folder">Spam-/Junk-Ordner</label>
|
||||
<input type="text" id="spam_folder" name="spam_folder" value="<?= e($editAcc['spam_folder'] ?? 'Junk') ?>" placeholder="z.B. Junk, Spam oder INBOX.Junk">
|
||||
<div class="muted" style="font-size:.75rem;margin-top:3px">Wird verwendet, wenn ein eingegangenes E-Mail-Ticket als Spam markiert wird.</div>
|
||||
</div>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:14px">
|
||||
<input type="checkbox" name="is_active" style="width:auto" <?= ($editAcc['is_active'] ?? 1) ? 'checked' : '' ?>>
|
||||
Aktiv (wird beim Abruf berücksichtigt)
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn"><?= $editAcc ? 'Speichern' : 'Erstellen' ?></button>
|
||||
<?php if ($editAcc): ?><a href="/staff/imap_accounts.php" class="btn secondary">Abbrechen</a><?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/osticket_import_passes.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
if (!empty($_POST['action'])) {
|
||||
ob_start();
|
||||
error_reporting(0);
|
||||
@ini_set('display_errors', '0');
|
||||
register_shutdown_function(function () {
|
||||
$err = error_get_last();
|
||||
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['ok' => false, 'error' => $err['message'] . ' in ' . basename($err['file']) . ':' . $err['line']]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ini_set('memory_limit', '1024M');
|
||||
@set_time_limit(300);
|
||||
|
||||
function import_json(array $data): never
|
||||
{
|
||||
while (ob_get_level() > 0) ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
function import_ajax_guard(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) import_json(['ok' => false, 'error' => 'CSRF']);
|
||||
}
|
||||
|
||||
$stateDir = osticket_state_dir();
|
||||
|
||||
// ── Chunked Upload ─────────────────────────────────────────────────────────
|
||||
if (($_POST['action'] ?? '') === 'upload_chunk') {
|
||||
import_ajax_guard();
|
||||
$chunk = $_FILES['chunk'] ?? null;
|
||||
$idx = (int) ($_POST['chunk_index'] ?? 0);
|
||||
$total = (int) ($_POST['total_chunks'] ?? 1);
|
||||
$tmpFile = $stateDir . '/upload.sql';
|
||||
if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => 'Chunk-Fehler']);
|
||||
$fh = fopen($tmpFile, $idx === 0 ? 'wb' : 'ab');
|
||||
fwrite($fh, file_get_contents($chunk['tmp_name']));
|
||||
fclose($fh);
|
||||
$done = ($idx + 1) >= $total;
|
||||
if ($done) {
|
||||
$_SESSION['import_sql_path'] = $tmpFile;
|
||||
$_SESSION['import_filename'] = basename((string) ($_POST['filename'] ?? 'import.sql'));
|
||||
@unlink($stateDir . '/state.json');
|
||||
@unlink($stateDir . '/entries.jsonl');
|
||||
@unlink($stateDir . '/entry_map.jsonl');
|
||||
}
|
||||
import_json(['ok' => true, 'done' => $done, 'chunk' => $idx]);
|
||||
}
|
||||
|
||||
// ── Dateipfad ──────────────────────────────────────────────────────────────
|
||||
if (($_POST['action'] ?? '') === 'use_path') {
|
||||
verify_csrf();
|
||||
$filename = basename(trim($_POST['sql_filename'] ?? ''));
|
||||
$fullPath = realpath(__DIR__ . '/../' . $filename);
|
||||
$rootDir = realpath(__DIR__ . '/..');
|
||||
if (!$filename || !$fullPath || strpos($fullPath, $rootDir) !== 0 || !file_exists($fullPath)) {
|
||||
flash('error', 'Datei nicht gefunden: ' . $filename); redirect('/staff/import_osticket.php');
|
||||
}
|
||||
$_SESSION['import_sql_path'] = $fullPath;
|
||||
$_SESSION['import_filename'] = $filename;
|
||||
foreach (['state.json', 'entries.jsonl', 'entry_map.jsonl'] as $f) @unlink($stateDir . '/' . $f);
|
||||
redirect('/staff/import_osticket.php?step=analyze');
|
||||
}
|
||||
|
||||
// ── Analyse ────────────────────────────────────────────────────────────────
|
||||
if (($_POST['action'] ?? '') === 'analyze_chunk') {
|
||||
import_ajax_guard();
|
||||
$tmp = $_SESSION['import_sql_path'] ?? '';
|
||||
if (!$tmp || !file_exists($tmp)) import_json(['ok' => false, 'error' => 'Datei fehlt']);
|
||||
try {
|
||||
$res = osticket_analyze_step($tmp, $stateDir, (int) ($_POST['byte_offset'] ?? 0));
|
||||
} catch (Throwable $e) {
|
||||
import_json(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
import_json(['ok' => true, 'done' => $res['done'], 'byte_offset' => $res['offset'], 'file_size' => filesize($tmp), 'counts' => $res['counts']]);
|
||||
}
|
||||
|
||||
// ── Import starten ─────────────────────────────────────────────────────────
|
||||
if (($_POST['action'] ?? '') === 'start') {
|
||||
verify_csrf();
|
||||
$allowed = ['users', 'structure', 'tickets', 'threads', 'attachments'];
|
||||
$_SESSION['import_what'] = array_values(array_intersect($allowed, (array) ($_POST['import'] ?? [])));
|
||||
redirect('/staff/import_osticket.php?step=run');
|
||||
}
|
||||
|
||||
// ── Reset ──────────────────────────────────────────────────────────────────
|
||||
if (($_POST['action'] ?? '') === 'reset_import') {
|
||||
import_ajax_guard();
|
||||
$type = $_POST['reset_type'] ?? 'all';
|
||||
if ($type === 'threads_only') {
|
||||
$pdo->exec('DELETE FROM ticket_attachments');
|
||||
$pdo->exec('DELETE FROM ticket_threads');
|
||||
} else {
|
||||
$pdo->exec('DELETE FROM ticket_attachments');
|
||||
$pdo->exec('DELETE FROM ticket_threads');
|
||||
$pdo->exec('DELETE FROM ticket_ratings');
|
||||
$pdo->exec('DELETE FROM tickets');
|
||||
$pdo->exec('DELETE FROM users WHERE id NOT IN (SELECT DISTINCT user_id FROM tickets WHERE user_id IS NOT NULL)');
|
||||
}
|
||||
@unlink($stateDir . '/entry_map.jsonl');
|
||||
audit_log($staff['id'], 'Import-Reset: ' . $type);
|
||||
import_json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── Import-Schritt ─────────────────────────────────────────────────────────
|
||||
if (($_POST['action'] ?? '') === 'import_chunk') {
|
||||
import_ajax_guard();
|
||||
$pass = (string) ($_POST['pass'] ?? '');
|
||||
$offset = (int) ($_POST['offset'] ?? 0);
|
||||
if (!in_array($pass, ['users', 'structure', 'tickets', 'threads', 'attachments'], true)) import_json(['ok' => false, 'error' => 'Unbekannter Durchgang']);
|
||||
if ($pass === 'threads' && $offset === 0) @unlink($stateDir . '/entry_map.jsonl');
|
||||
try {
|
||||
$res = osticket_import_step($pdo, $stateDir, $pass, $offset, (string) ($_SESSION['import_sql_path'] ?? ''));
|
||||
} catch (Throwable $e) {
|
||||
import_json(['ok' => false, 'error' => $e->getMessage()]);
|
||||
}
|
||||
$importWhat = $_SESSION['import_what'] ?? [];
|
||||
if ($res['done'] && $pass === end($importWhat)) {
|
||||
$tmp = $_SESSION['import_sql_path'] ?? '';
|
||||
if ($tmp && file_exists($tmp) && str_starts_with($tmp, $stateDir)) @unlink($tmp);
|
||||
osticket_state_clear($stateDir);
|
||||
unset($_SESSION['import_sql_path'], $_SESSION['import_what'], $_SESSION['import_filename']);
|
||||
audit_log($staff['id'], 'osTicket Import abgeschlossen');
|
||||
}
|
||||
import_json(['ok' => true] + $res);
|
||||
}
|
||||
|
||||
// ── Seite ──────────────────────────────────────────────────────────────────
|
||||
$step = $_GET['step'] ?? '1';
|
||||
$sqlPath = $_SESSION['import_sql_path'] ?? '';
|
||||
$filename = $_SESSION['import_filename'] ?? '';
|
||||
$fileSize = $sqlPath && file_exists($sqlPath) ? filesize($sqlPath) : 0;
|
||||
|
||||
$pageTitle = 'osTicket Import';
|
||||
$activePage = 'settings';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>osTicket Import</h1>
|
||||
<a href="/staff/settings.php" class="btn secondary">← Einstellungen</a>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0;margin-bottom:24px;border-bottom:2px solid var(--border)">
|
||||
<?php foreach(['1'=>'Hochladen','analyze'=>'Analysieren','2'=>'Optionen','run'=>'Import'] as $s=>$l): ?>
|
||||
<div style="padding:10px 18px;font-size:.84rem;font-weight:<?=$step===$s?'700':'400'?>;color:<?=$step===$s?'var(--primary)':'var(--muted)'?>;border-bottom:2px solid <?=$step===$s?'var(--primary)':'transparent'?>;margin-bottom:-2px"><?=$l?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($step === '1'): ?>
|
||||
<div class="card" style="max-width:620px">
|
||||
<h3>SQL-Datei hochladen oder Pfad angeben</h3>
|
||||
<p class="muted" style="font-size:.85rem">Unterstützt werden SQL-Dumps von osTicket 1.10 bis 1.18 (phpMyAdmin-Export oder <code>mysqldump</code>, auch mit anderem Tabellenpräfix als <code>ost_</code>). Importiert werden Kunden, Abteilungen, Hilfethemen, Tickets, Nachrichten und Anhänge.</p>
|
||||
<p style="font-weight:600;font-size:.88rem;margin-bottom:8px">Option 1: Datei in den DeskHub-Ordner legen (für große Dateien empfohlen)</p>
|
||||
<form method="post" style="display:flex;gap:8px;margin-bottom:20px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="use_path">
|
||||
<input type="text" name="sql_filename" placeholder="helpdesk.sql" style="flex:1">
|
||||
<button type="submit" class="btn">Verwenden →</button>
|
||||
</form>
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin-bottom:20px">
|
||||
<p style="font-weight:600;font-size:.88rem;margin-bottom:8px">Option 2: Direkt hochladen (in 5-MB-Teilen)</p>
|
||||
<input type="file" id="sql-file-input" accept=".sql" style="margin-bottom:10px">
|
||||
<div id="upload-progress" style="display:none;margin-bottom:10px">
|
||||
<div style="background:var(--border);border-radius:20px;height:10px;overflow:hidden;margin-bottom:5px">
|
||||
<div id="upload-bar" style="background:var(--primary);height:10px;width:0%;border-radius:20px;transition:width .2s"></div>
|
||||
</div>
|
||||
<div id="upload-status" class="muted" style="font-size:.82rem"></div>
|
||||
</div>
|
||||
<button onclick="startChunkedUpload()" class="btn secondary" id="upload-btn">Hochladen →</button>
|
||||
<script>
|
||||
var csrf = <?= json_encode(csrf_token()) ?>;
|
||||
function startChunkedUpload() {
|
||||
var file = document.getElementById('sql-file-input').files[0];
|
||||
if (!file) { alert('Bitte eine Datei auswaehlen.'); return; }
|
||||
var btn = document.getElementById('upload-btn');
|
||||
btn.disabled = true;
|
||||
document.getElementById('upload-progress').style.display = 'block';
|
||||
var chunkSize = 5*1024*1024, totalChunks = Math.ceil(file.size/chunkSize), idx = 0;
|
||||
function next() {
|
||||
var blob = file.slice(idx*chunkSize, (idx+1)*chunkSize);
|
||||
var fd = new FormData();
|
||||
fd.append('action','upload_chunk'); fd.append('csrf_token',csrf);
|
||||
fd.append('chunk',blob,file.name); fd.append('chunk_index',idx);
|
||||
fd.append('total_chunks',totalChunks); fd.append('filename',file.name);
|
||||
fetch('/staff/import_osticket.php',{method:'POST',body:fd})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!d.ok){alert('Fehler: '+d.error);btn.disabled=false;return;}
|
||||
idx++;
|
||||
var pct=Math.round(idx/totalChunks*100);
|
||||
document.getElementById('upload-bar').style.width=pct+'%';
|
||||
document.getElementById('upload-status').textContent=pct+'% - '+Math.round(idx*5)+' MB';
|
||||
if(d.done){
|
||||
document.getElementById('upload-bar').style.background='#22c55e';
|
||||
setTimeout(()=>{window.location.href='/staff/import_osticket.php?step=analyze';},400);
|
||||
} else { next(); }
|
||||
}).catch(e=>{alert('Fehler: '+e);btn.disabled=false;});
|
||||
}
|
||||
next();
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<?php elseif ($step === 'analyze'): ?>
|
||||
<div class="card" style="max-width:620px">
|
||||
<h3>Analysiere: <?= e($filename) ?> (<?= round($fileSize/1024/1024,1) ?> MB)</h3>
|
||||
<p class="muted" style="font-size:.85rem;margin-bottom:14px">Liest die Datei in 4-MB-Blöcken. Bei sehr großen Dateien (Anhänge, Sitzungsdaten) kann das einige Minuten dauern.</p>
|
||||
<div style="background:var(--border);border-radius:20px;height:12px;overflow:hidden;margin-bottom:10px">
|
||||
<div id="analyze-bar" style="background:var(--primary);height:12px;width:0%;border-radius:20px;transition:width .2s"></div>
|
||||
</div>
|
||||
<div id="analyze-status" class="muted" style="font-size:.82rem;margin-bottom:14px">Starte...</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
|
||||
<?php foreach(['users'=>'Kunden','tickets'=>'Tickets','threads'=>'Nachrichten','departments'=>'Abteilungen','topics'=>'Hilfethemen','attachments'=>'Anhänge'] as $k=>$l): ?>
|
||||
<div style="text-align:center;background:var(--bg-soft);border-radius:8px;padding:10px">
|
||||
<div id="cnt-<?=$k?>" style="font-size:1.5rem;font-weight:800;color:var(--primary)">0</div>
|
||||
<div class="muted" style="font-size:.75rem"><?=$l?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<a id="next-btn" href="/staff/import_osticket.php?step=2" class="btn" style="display:none">Weiter →</a>
|
||||
</div>
|
||||
<script>
|
||||
var fileSize=<?=(int)$fileSize?>,byteOffset=0,csrf=<?=json_encode(csrf_token())?>;
|
||||
function analyzeChunk(){
|
||||
var fd=new FormData();
|
||||
fd.append('action','analyze_chunk');fd.append('csrf_token',csrf);fd.append('byte_offset',byteOffset);
|
||||
fetch('/staff/import_osticket.php',{method:'POST',body:fd})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!d.ok){alert('Fehler: '+d.error);return;}
|
||||
byteOffset=d.byte_offset;
|
||||
var pct=fileSize>0?Math.min(100,Math.round(d.byte_offset/fileSize*100)):100;
|
||||
document.getElementById('analyze-bar').style.width=pct+'%';
|
||||
document.getElementById('analyze-status').textContent=pct+'% - '+Math.round(d.byte_offset/1024/1024)+' MB';
|
||||
for(var k in d.counts){var el=document.getElementById('cnt-'+k);if(el)el.textContent=Number(d.counts[k]).toLocaleString();}
|
||||
if(d.done){
|
||||
document.getElementById('analyze-bar').style.background='#22c55e';
|
||||
document.getElementById('analyze-status').textContent='Analyse abgeschlossen!';
|
||||
document.getElementById('next-btn').style.display='inline-flex';
|
||||
} else { setTimeout(analyzeChunk,10); }
|
||||
}).catch(e=>{console.error(e);alert('Netzwerkfehler: '+e);});
|
||||
}
|
||||
analyzeChunk();
|
||||
</script>
|
||||
|
||||
<?php elseif ($step === '2'): ?>
|
||||
<?php $ad = osticket_state_load($stateDir); $counts = osticket_counts($ad); ?>
|
||||
<div class="card" style="max-width:620px">
|
||||
<h3>Was soll importiert werden?</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px">
|
||||
<?php foreach(['users'=>'Kunden','tickets'=>'Tickets','threads'=>'Nachrichten','departments'=>'Abteilungen','topics'=>'Hilfethemen','attachments'=>'Anhänge'] as $k=>$l): ?>
|
||||
<div style="text-align:center;background:var(--bg-soft);border-radius:8px;padding:12px">
|
||||
<div style="font-size:1.5rem;font-weight:800;color:var(--primary)"><?= number_format((int) $counts[$k], 0, ',', '.') ?></div>
|
||||
<div class="muted" style="font-size:.75rem"><?=$l?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="start">
|
||||
<?php foreach([
|
||||
'users' => ['Kunden', 'Name und E-Mail-Adresse'],
|
||||
'structure' => ['Abteilungen und Hilfethemen', 'Fehlende werden angelegt, gleichnamige vorhandene wiederverwendet. Tickets landen damit in der richtigen Abteilung.'],
|
||||
'tickets' => ['Tickets', 'Betreff, Nummer, Status, Priorität, Abteilung, Hilfethema, zugewiesener Mitarbeiter (über gleiche E-Mail-Adresse), Zeitstempel. Gelöschte Tickets werden ausgelassen.'],
|
||||
'threads' => ['Nachrichten', 'Kundennachrichten, Antworten, interne Notizen und Mitlesende'],
|
||||
'attachments' => ['Anhänge (' . round($counts['attachment_bytes'] / 1048576, 1) . ' MB)', 'Dateien zu den Nachrichten, bis ' . round(OSTICKET_ATTACHMENT_MAX_BYTES / 1048576) . ' MB je Datei. Setzt die Nachrichten voraus.'],
|
||||
] as $v => [$lb, $desc]): ?>
|
||||
<label style="display:flex;align-items:flex-start;gap:10px;margin-bottom:10px;cursor:pointer;font-weight:400">
|
||||
<input type="checkbox" name="import[]" value="<?=$v?>" <?= $v === 'attachments' && !$counts['attachments'] ? '' : 'checked' ?> style="width:auto;margin-top:2px">
|
||||
<div><strong><?=$lb?></strong><div class="muted" style="font-size:.78rem"><?=$desc?></div></div>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<p class="muted" style="font-size:.78rem">Der Import kann gefahrlos wiederholt werden: Vorhandene Kunden und Ticketnummern werden übersprungen, bereits importierte Nachrichten nicht doppelt angelegt. Vorher ein Backup erstellen ist trotzdem empfehlenswert.</p>
|
||||
<div class="btn-row" style="margin-top:16px;flex-wrap:wrap;gap:8px">
|
||||
<button type="submit" class="btn">Import starten →</button>
|
||||
<a href="/staff/import_osticket.php" class="btn secondary">Neu hochladen</a>
|
||||
<button type="button" onclick="resetImport('threads_only')" class="btn secondary" style="border-color:#f59e0b;color:#92400e">Nur Nachrichten löschen</button>
|
||||
<button type="button" onclick="resetImport('all')" class="btn secondary" style="border-color:var(--danger);color:var(--danger)">Alle Tickets löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
function resetImport(type){
|
||||
if(!confirm(type==='threads_only'?'Alle Nachrichten und Anhänge löschen?':'ALLE Tickets, Nachrichten und Kunden ohne Ticket in DeskHub löschen?')) return;
|
||||
var fd=new FormData();
|
||||
fd.append('action','reset_import');fd.append('reset_type',type);fd.append('csrf_token',<?=json_encode(csrf_token())?>);
|
||||
fetch('/staff/import_osticket.php',{method:'POST',body:fd}).then(r=>r.json()).then(d=>{if(d.ok){alert('Gelöscht.');location.reload();}});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<?php elseif ($step === 'run'): ?>
|
||||
<?php $importWhat = $_SESSION['import_what'] ?? []; ?>
|
||||
<div class="card" style="max-width:620px">
|
||||
<h3>Import läuft...</h3>
|
||||
<p class="muted" style="font-size:.85rem;margin-bottom:18px">Seite nicht schließen.</p>
|
||||
<?php foreach(['users'=>'Kunden','structure'=>'Abteilungen und Hilfethemen','tickets'=>'Tickets','threads'=>'Nachrichten','attachments'=>'Anhänge'] as $p=>$l): if(!in_array($p,$importWhat)) continue; ?>
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:.84rem"><span><?=$l?></span><span id="lbl-<?=$p?>" class="muted">Wartend...</span></div>
|
||||
<div style="background:var(--border);border-radius:20px;height:10px;overflow:hidden">
|
||||
<div id="bar-<?=$p?>" style="background:var(--primary);height:10px;width:0%;border-radius:20px;transition:width .3s"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div id="result" style="display:none;margin-top:20px"></div>
|
||||
</div>
|
||||
<script>
|
||||
var passes=<?=json_encode(array_values($importWhat))?>,csrf=<?=json_encode(csrf_token())?>;
|
||||
var totals={},skipped=0,passIdx=0,offset=0,notes={};
|
||||
function runChunk(){
|
||||
if(passIdx>=passes.length){showResult();return;}
|
||||
var pass=passes[passIdx];
|
||||
var fd=new FormData();
|
||||
fd.append('action','import_chunk');fd.append('csrf_token',csrf);fd.append('pass',pass);fd.append('offset',offset);
|
||||
fetch('/staff/import_osticket.php',{method:'POST',body:fd})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!d.ok){alert('Fehler: '+d.error);return;}
|
||||
totals[pass]=(totals[pass]||0)+d.inserted;
|
||||
skipped+=d.skipped||0;
|
||||
offset=d.next_offset;
|
||||
for(var k in (d.notes||{})) notes[pass+k]=d.notes[k];
|
||||
var bar=document.getElementById('bar-'+pass),lbl=document.getElementById('lbl-'+pass);
|
||||
if(bar)bar.style.width=d.done?'100%':Math.min(98,Math.max(5,(bar.dataset.p=(Number(bar.dataset.p||0)+1))))+'%';
|
||||
if(lbl)lbl.textContent=(totals[pass]||0).toLocaleString()+' importiert';
|
||||
if(d.done){
|
||||
if(bar)bar.style.background='#22c55e';
|
||||
if(lbl)lbl.textContent='✅ '+(totals[pass]||0).toLocaleString();
|
||||
passIdx++;offset=0;setTimeout(runChunk,100);
|
||||
} else { setTimeout(runChunk,30); }
|
||||
}).catch(e=>alert('Netzwerkfehler: '+e));
|
||||
}
|
||||
function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||||
function showResult(){
|
||||
var r=document.getElementById('result');r.style.display='block';
|
||||
var n=Object.keys(notes).map(function(k){return '<li>'+esc(notes[k])+'</li>';}).join('');
|
||||
r.innerHTML='<div class="flash success">Fertig! Kunden: <strong>'+(totals.users||0).toLocaleString()+'</strong>, Tickets: <strong>'+(totals.tickets||0).toLocaleString()+'</strong>, Nachrichten: <strong>'+(totals.threads||0).toLocaleString()+'</strong>, Anhänge: <strong>'+(totals.attachments||0).toLocaleString()+'</strong>, Übersprungen: <strong>'+skipped.toLocaleString()+'</strong></div>'
|
||||
+(n?'<ul style="font-size:.84rem;color:var(--muted)">'+n+'</ul>':'')
|
||||
+'<div class="btn-row"><a href="/staff/tickets.php" class="btn">Tickets ansehen</a><a href="/staff/import_osticket.php" class="btn secondary">Erneut importieren</a></div>';
|
||||
}
|
||||
runChunk();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin_or_permission('manage_kb');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create_category') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if ($name === '') {
|
||||
$errors[] = 'Bitte einen Kategorienamen angeben.';
|
||||
} else {
|
||||
$slug = slugify($name);
|
||||
$pdo->prepare('INSERT INTO kb_categories (name, slug, sort_order) VALUES (?,?,0)')->execute([$name, $slug]);
|
||||
audit_log($admin['id'], "KB-Kategorie erstellt: {$name}");
|
||||
flash('success', 'Kategorie erstellt.');
|
||||
redirect('/staff/kb_manage.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete_category') {
|
||||
$id = (int) $_POST['id'];
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE category_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
if ((int) $stmt->fetchColumn() > 0) {
|
||||
flash('error', 'Diese Kategorie enthält noch Artikel und kann nicht gelöscht werden.');
|
||||
} else {
|
||||
$nameStmt = $pdo->prepare('SELECT name FROM kb_categories WHERE id = ?');
|
||||
$nameStmt->execute([$id]);
|
||||
$catName = $nameStmt->fetchColumn();
|
||||
$pdo->prepare('DELETE FROM kb_categories WHERE id = ?')->execute([$id]);
|
||||
audit_log($admin['id'], "KB-Kategorie gelöscht: {$catName}");
|
||||
flash('success', 'Kategorie gelöscht.');
|
||||
}
|
||||
redirect('/staff/kb_manage.php');
|
||||
}
|
||||
|
||||
if ($action === 'save_article') {
|
||||
$id = $_POST['id'] ?? '';
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$categoryId = (int) ($_POST['category_id'] ?? 0);
|
||||
$body = sanitize_staff_html($_POST['body'] ?? '');
|
||||
$isPublished = !empty($_POST['is_published']) ? 1 : 0;
|
||||
|
||||
if ($title === '') $errors[] = 'Bitte einen Titel angeben.';
|
||||
if (trim(strip_tags($body)) === '') $errors[] = 'Bitte einen Inhalt angeben.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($id === '') {
|
||||
$slug = slugify($title);
|
||||
$base = $slug; $i = 2;
|
||||
$check = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE slug = ?');
|
||||
while (true) {
|
||||
$check->execute([$slug]);
|
||||
if ((int) $check->fetchColumn() === 0) break;
|
||||
$slug = $base . '-' . $i++;
|
||||
}
|
||||
$pdo->prepare('INSERT INTO kb_articles (category_id, title, slug, body, is_published, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([$categoryId, $title, $slug, $body, $isPublished, db_now(), db_now()]);
|
||||
audit_log($admin['id'], "KB-Artikel erstellt: {$title}");
|
||||
flash('success', 'Artikel erstellt.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE kb_articles SET category_id=?, title=?, body=?, is_published=?, updated_at=? WHERE id=?')
|
||||
->execute([$categoryId, $title, $body, $isPublished, db_now(), (int) $id]);
|
||||
audit_log($admin['id'], "KB-Artikel aktualisiert: {$title}");
|
||||
flash('success', 'Artikel aktualisiert.');
|
||||
}
|
||||
redirect('/staff/kb_manage.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete_article') {
|
||||
$id = (int) $_POST['id'];
|
||||
$titleStmt = $pdo->prepare('SELECT title FROM kb_articles WHERE id = ?');
|
||||
$titleStmt->execute([$id]);
|
||||
$artTitle = $titleStmt->fetchColumn();
|
||||
$pdo->prepare('DELETE FROM kb_articles WHERE id = ?')->execute([$id]);
|
||||
audit_log($admin['id'], "KB-Artikel gelöscht: {$artTitle}");
|
||||
flash('success', 'Artikel gelöscht.');
|
||||
redirect('/staff/kb_manage.php');
|
||||
}
|
||||
}
|
||||
|
||||
$categories = $pdo->query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll();
|
||||
$articles = $pdo->query(
|
||||
'SELECT a.*, c.name AS category_name FROM kb_articles a JOIN kb_categories c ON c.id=a.category_id ORDER BY a.title'
|
||||
)->fetchAll();
|
||||
|
||||
$editId = $_GET['edit'] ?? null;
|
||||
$editArticle = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM kb_articles WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editArticle = $stmt->fetch();
|
||||
}
|
||||
$showForm = isset($_GET['new']) || $editArticle;
|
||||
|
||||
$pageTitle = 'Wissensdatenbank';
|
||||
$activePage = 'kb';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<h1>Wissensdatenbank verwalten</h1>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex; gap:24px; align-items:flex-start">
|
||||
<div style="flex:2; min-width:0">
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<div class="card">
|
||||
<h3><?= $editArticle ? 'Artikel bearbeiten' : 'Neuer Artikel' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_article">
|
||||
<?php if ($editArticle): ?><input type="hidden" name="id" value="<?= $editArticle['id'] ?>"><?php endif; ?>
|
||||
<label for="title">Titel</label>
|
||||
<input type="text" id="title" name="title" value="<?= e($editArticle['title'] ?? '') ?>" required>
|
||||
<label for="category_id">Kategorie</label>
|
||||
<select name="category_id" id="category_id" required>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option value="<?= $c['id'] ?>" <?= ($editArticle['category_id'] ?? '') == $c['id'] ? 'selected' : '' ?>><?= e($c['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="body">Inhalt (HTML erlaubt)</label>
|
||||
<textarea id="body" name="body" data-richtext style="min-height:220px" required><?= e($editArticle['body'] ?? '') ?></textarea>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:14px">
|
||||
<input type="checkbox" name="is_published" style="width:auto" <?= ($editArticle['is_published'] ?? 1) ? 'checked' : '' ?>>
|
||||
Veröffentlicht
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<a href="/staff/kb_manage.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="topbar" style="margin-bottom:10px"><h3 style="margin:0">Artikel</h3><a href="/staff/kb_manage.php?new=1" class="btn secondary">+ Neuer Artikel</a></div>
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Kategorie</th><th>Status</th><th>Aufrufe</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($articles as $a): ?>
|
||||
<tr>
|
||||
<td><?= e($a['title']) ?></td>
|
||||
<td><?= e($a['category_name']) ?></td>
|
||||
<td><?= $a['is_published'] ? '<span class="badge" style="background:#16a34a">Live</span>' : '<span class="badge" style="background:#6b7280">Entwurf</span>' ?></td>
|
||||
<td><?= (int) $a['views'] ?></td>
|
||||
<td>
|
||||
<a href="/staff/kb_manage.php?edit=<?= $a['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Artikel wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_article">
|
||||
<input type="hidden" name="id" value="<?= $a['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; min-width:260px">
|
||||
<div class="card">
|
||||
<h3>Kategorien</h3>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid var(--border)">
|
||||
<span><?= e($c['name']) ?></span>
|
||||
<form method="post" onsubmit="return confirm('Kategorie wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_category">
|
||||
<input type="hidden" name="id" value="<?= $c['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.85rem">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<form method="post" style="margin-top:14px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="create_category">
|
||||
<label for="name">Neue Kategorie</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
<div class="btn-row"><button type="submit" class="btn secondary">Hinzufügen</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,614 @@
|
||||
<?php
|
||||
/**
|
||||
* staff/live_chat.php
|
||||
*
|
||||
* Eigenständiges Postfach für Live-Chats von Kunden (Tickets mit source='chat') –
|
||||
* bewusst getrennt von staff/chat.php (interner Mitarbeiter-Chat, staff_chat_messages).
|
||||
* Beide nutzen unterschiedliche Datenmodelle und werden nie vermischt: hier geht es
|
||||
* ausschließlich um Konversationen mit Kunden über das Live-Chat-Widget im Kundenportal.
|
||||
*
|
||||
* Ein Live-Chat ist technisch ein ganz normales Ticket (siehe live_chat.php im Root-
|
||||
* Verzeichnis) – hier gibt es nur eine auf schnelles Chatten zugeschnittene Oberfläche
|
||||
* obendrauf. Für alles Erweiterte (Anhänge, Textbausteine, Zusammenführen, …) bleibt
|
||||
* die normale Ticketansicht über "Im Ticket öffnen" weiter erreichbar.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
$myDeptIds = staff_department_ids($staff);
|
||||
|
||||
function lc_json($data): never
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
// Aktuellen CSRF-Token immer mitschicken: verify_csrf() rotiert ihn nach jeder
|
||||
// erfolgreichen Anfrage, das JS aktualisiert seine lokale Kopie daraus. Ohne das
|
||||
// schlägt jede zweite Nachricht/Chat-Ende mit einer (hier nicht JSON-lesbaren)
|
||||
// "Ungültige Anfrage"-Antwort fehl, sobald der beim Laden eingebettete Token
|
||||
// durch die vorherige Aktion bereits verbraucht/rotiert wurde.
|
||||
if (is_array($data) && !isset($data['csrfToken'])) {
|
||||
$data['csrfToken'] = csrf_token();
|
||||
}
|
||||
echo json_encode($data);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Wie lc_json(), schickt die Antwort aber sofort raus und beendet das Skript NICHT –
|
||||
* der Aufrufer kann danach noch etwas Langsames erledigen (hier: die Chatverlauf-Mail
|
||||
* per SMTP verschicken), ohne dass der Mitarbeiter auf den Klick auf "Chat beenden"
|
||||
* warten muss. Gleiche Technik wie chat_flush_response() im Root-live_chat.php. */
|
||||
function lc_flush_response(array $data): void
|
||||
{
|
||||
if (!isset($data['csrfToken'])) {
|
||||
$data['csrfToken'] = csrf_token();
|
||||
}
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if (ob_get_level() === 0) ob_start();
|
||||
echo json_encode($data);
|
||||
if (!headers_sent()) {
|
||||
header('Content-Length: ' . ob_get_length());
|
||||
header('Connection: close');
|
||||
}
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
@fastcgi_finish_request();
|
||||
} else {
|
||||
while (ob_get_level() > 0) { @ob_end_flush(); }
|
||||
@flush();
|
||||
}
|
||||
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
|
||||
}
|
||||
|
||||
/** JSON-taugliche CSRF-Prüfung für die AJAX-Endpunkte dieser Seite – verify_csrf()
|
||||
* bricht bei Fehlschlag mit einer reinen Textmeldung ab, die fetch().json() nicht
|
||||
* parsen kann. Rotiert den Token nach Verbrauch genauso wie verify_csrf(). */
|
||||
function lc_verify_csrf(): void
|
||||
{
|
||||
$token = $_POST['csrf_token'] ?? '';
|
||||
if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
|
||||
lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]);
|
||||
}
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
[$deptFilterSql, $deptFilterParams] = dept_filter_sql($myDeptIds, 't.department_id');
|
||||
|
||||
// Sperre gegen versehentliches gleichzeitiges Antworten zweier Mitarbeiter im selben
|
||||
// Chat (auch mit der Mobile-App geteilt, siehe live_chat_locked_by_other() in
|
||||
// includes/functions.php): wer den Chat gerade offen hat, "besitzt" ihn für ein paar
|
||||
// Minuten. Andere Mitarbeiter können den Chat trotzdem öffnen und live mitlesen
|
||||
// (bewusst kein Redirect wie bei der normalen Ticketsperre in ticket_view.php – beim
|
||||
// Live-Chat will man durchaus mitschauen können), das Schreiben wird ihnen aber sowohl
|
||||
// in der Oberfläche als auch serverseitig (s.u.) verwehrt.
|
||||
function lc_locked_by_other(array $ticket, array $staff, PDO $pdo): ?string
|
||||
{
|
||||
return live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
|
||||
}
|
||||
|
||||
// ── AJAX: Nachrichten pollen ────────────────────────────────────────────────
|
||||
if (($_GET['action'] ?? '') === 'poll') {
|
||||
$number = $_GET['n'] ?? '';
|
||||
$since = (int) ($_GET['since'] ?? 0);
|
||||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||||
$ticket = $stmt->fetch();
|
||||
if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden.']);
|
||||
|
||||
$msgStmt = $pdo->prepare(
|
||||
'SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name
|
||||
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
|
||||
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 AND tt.id > ?
|
||||
ORDER BY tt.created_at ASC, tt.id ASC'
|
||||
);
|
||||
$msgStmt->execute([$ticket['id'], $since]);
|
||||
$lcAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
|
||||
$polledMessages = array_map(function (array $m) use ($lcAttStmt) {
|
||||
$lcAttStmt->execute([$m['id']]);
|
||||
$m['attachments'] = $lcAttStmt->fetchAll();
|
||||
return $m;
|
||||
}, $msgStmt->fetchAll());
|
||||
$customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time();
|
||||
lc_json(['status' => $ticket['status'], 'customerTyping' => $customerTyping, 'messages' => $polledMessages]);
|
||||
}
|
||||
|
||||
// ── AJAX: "Ich tippe gerade" (für den Tippt-Indikator beim Kunden) ─────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'typing') {
|
||||
// Token wird geprüft, aber bewusst NICHT rotiert (wie im Kunden-Widget, siehe
|
||||
// live_chat.php): feuert bei jedem Tastenanschlag (gedrosselt), eine Rotation würde
|
||||
// sonst mit einem parallel abgeschickten "send" um denselben Token wettlaufen.
|
||||
$submittedCsrf = $_POST['csrf_token'] ?? '';
|
||||
if (!$submittedCsrf || !hash_equals($_SESSION['csrf_token'] ?? '', $submittedCsrf)) {
|
||||
lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]);
|
||||
}
|
||||
$number = $_POST['n'] ?? '';
|
||||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||||
$ticket = $stmt->fetch();
|
||||
if ($ticket && $ticket['status'] !== 'closed' && !lc_locked_by_other($ticket, $staff, $pdo)) {
|
||||
$pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?')
|
||||
->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]);
|
||||
}
|
||||
lc_json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── AJAX: Chat-Liste pollen (für Live-Aktualisierung + Ton-Benachrichtigung) ────
|
||||
if (($_GET['action'] ?? '') === 'list') {
|
||||
$listStmt = $pdo->prepare(
|
||||
"SELECT t.ticket_number, t.requester_name, t.updated_at,
|
||||
(SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_author,
|
||||
(SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_message
|
||||
FROM tickets t
|
||||
WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . "
|
||||
ORDER BY t.updated_at DESC LIMIT 100"
|
||||
);
|
||||
$listStmt->execute($deptFilterParams);
|
||||
lc_json(['items' => array_map(fn(array $c) => [
|
||||
'number' => $c['ticket_number'],
|
||||
'name' => $c['requester_name'],
|
||||
'preview' => mb_substr(strip_tags((string) $c['last_message']), 0, 60),
|
||||
'needsReply' => $c['last_author'] === 'customer',
|
||||
'updatedAt' => $c['updated_at'],
|
||||
], $listStmt->fetchAll())]);
|
||||
}
|
||||
|
||||
// ── AJAX: Chat beenden ───────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'end') {
|
||||
lc_verify_csrf();
|
||||
$number = $_POST['n'] ?? '';
|
||||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||||
$ticket = $stmt->fetch();
|
||||
if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']);
|
||||
if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) {
|
||||
lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]);
|
||||
}
|
||||
|
||||
$now = db_now();
|
||||
$pdo->prepare(
|
||||
"UPDATE tickets SET status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||||
)->execute([$now, $now, $ticket['id']]);
|
||||
log_activity($pdo, $ticket['id'], $staff['id'], 'Live-Chat beendet');
|
||||
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
|
||||
|
||||
// Mitarbeiter nicht auf den SMTP-Versand warten lassen – Antwort sofort raus,
|
||||
// Chatverlauf-Mail danach im Hintergrund (abschaltbar unter Einstellungen →
|
||||
// Allgemein, siehe notify_customer_chat_transcript() in includes/mailer.php).
|
||||
lc_flush_response(['ok' => true]);
|
||||
|
||||
$transcriptStmt = $pdo->prepare(
|
||||
'SELECT tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staff_name
|
||||
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
|
||||
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC'
|
||||
);
|
||||
$transcriptStmt->execute([$ticket['id']]);
|
||||
$transcriptMessages = array_map(fn(array $m) => [
|
||||
'authorType' => $m['authorType'],
|
||||
'authorName' => $m['staff_name'],
|
||||
'message' => $m['message'],
|
||||
'createdAt' => $m['createdAt'],
|
||||
], $transcriptStmt->fetchAll());
|
||||
if ($ticket['requester_email']) {
|
||||
try {
|
||||
notify_customer_chat_transcript($ticket, $transcriptMessages);
|
||||
} catch (Throwable $e) {
|
||||
error_log('live_chat transcript mail: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── AJAX: Antwort senden ────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
lc_verify_csrf();
|
||||
$number = $_POST['n'] ?? '';
|
||||
$message = sanitize_staff_html(trim($_POST['message'] ?? ''));
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||||
$ticket = $stmt->fetch();
|
||||
if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']);
|
||||
if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) {
|
||||
lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]);
|
||||
}
|
||||
if (trim(strip_tags($message)) === '') lc_json(['error' => 'Bitte eine Nachricht eingeben.']);
|
||||
|
||||
$now = db_now();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'
|
||||
)->execute([$ticket['id'], 'staff', $staff['id'], $message, $now]);
|
||||
$threadId = (int) $pdo->lastInsertId();
|
||||
|
||||
$autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === '');
|
||||
$newStatus = 'answered';
|
||||
$updateFirstResponse = $ticket['first_responded_at'] === null;
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE tickets SET status = ?, updated_at = ?' .
|
||||
($updateFirstResponse ? ', first_responded_at = ?' : '') .
|
||||
($autoAssign ? ', assigned_staff_id = ?' : '') .
|
||||
' WHERE id = ?'
|
||||
);
|
||||
$args = [$newStatus, $now];
|
||||
if ($updateFirstResponse) $args[] = $now;
|
||||
if ($autoAssign) $args[] = $staff['id'];
|
||||
$args[] = $ticket['id'];
|
||||
$stmt->execute($args);
|
||||
if ($autoAssign) log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (Live-Chat)');
|
||||
|
||||
save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket);
|
||||
log_activity($pdo, $ticket['id'], $staff['id'], 'Live-Chat-Antwort gesendet');
|
||||
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
|
||||
|
||||
lc_json(['ok' => true, 'id' => $threadId, 'createdAt' => $now, 'status' => $newStatus]);
|
||||
}
|
||||
|
||||
// ── Normale Seite ────────────────────────────────────────────────────────────
|
||||
$listSql = "SELECT t.*, d.name AS department_name,
|
||||
(SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_author,
|
||||
(SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_message
|
||||
FROM tickets t JOIN departments d ON d.id = t.department_id
|
||||
WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . "
|
||||
ORDER BY t.updated_at DESC LIMIT 100";
|
||||
$listStmt = $pdo->prepare($listSql);
|
||||
$listStmt->execute($deptFilterParams);
|
||||
$chats = $listStmt->fetchAll();
|
||||
|
||||
$activeNumber = $_GET['n'] ?? ($chats[0]['ticket_number'] ?? null);
|
||||
$activeTicket = null;
|
||||
$activeMessages = [];
|
||||
if ($activeNumber) {
|
||||
foreach ($chats as $c) {
|
||||
if ($c['ticket_number'] === $activeNumber) { $activeTicket = $c; break; }
|
||||
}
|
||||
if (!$activeTicket) {
|
||||
// Ticket ggf. außerhalb der aktiven Liste (z.B. gerade geschlossen) – direkt laden
|
||||
$stmt = $pdo->prepare('SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id=t.department_id WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||||
$stmt->execute(array_merge([$activeNumber], $deptFilterParams));
|
||||
$activeTicket = $stmt->fetch() ?: null;
|
||||
}
|
||||
if ($activeTicket) {
|
||||
$msgStmt = $pdo->prepare(
|
||||
'SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name
|
||||
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
|
||||
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC'
|
||||
);
|
||||
$msgStmt->execute([$activeTicket['id']]);
|
||||
$activeMessages = $msgStmt->fetchAll();
|
||||
$lcInitAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
|
||||
foreach ($activeMessages as &$__m) {
|
||||
$lcInitAttStmt->execute([$__m['id']]);
|
||||
$__m['attachments'] = $lcInitAttStmt->fetchAll();
|
||||
}
|
||||
unset($__m);
|
||||
}
|
||||
}
|
||||
|
||||
// Sperre übernehmen/auffrischen bzw. feststellen, dass ein anderer Mitarbeiter gerade
|
||||
// aktiv dran ist (siehe lc_locked_by_other() oben).
|
||||
$lockedByOtherName = null;
|
||||
if ($activeTicket && $activeTicket['status'] !== 'closed') {
|
||||
$lockedByOtherName = lc_locked_by_other($activeTicket, $staff, $pdo);
|
||||
if (!$lockedByOtherName) {
|
||||
live_chat_acquire_lock((int) $activeTicket['id'], (int) $staff['id'], $pdo);
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Live-Chat';
|
||||
$activePage = 'live_chat';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php if (get_setting('live_chat_enabled', '0') !== '1'): ?>
|
||||
<h1>💬 Live-Chat</h1>
|
||||
<div class="flash info">Live-Chat ist derzeit deaktiviert. Aktivieren unter <a href="/staff/settings.php?tab=allgemein">Einstellungen → Allgemein</a>.</div>
|
||||
<?php else: ?>
|
||||
|
||||
<style>
|
||||
.lc-wrap{display:flex;height:calc(100vh - 56px);margin:-32px -28px -60px;overflow:hidden;background:#fff;border-top:1px solid #e5e7eb}
|
||||
.lc-list{width:280px;flex-shrink:0;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;background:#fff;overflow-y:auto}
|
||||
.lc-list-title{padding:14px 16px;font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9ca3af;border-bottom:1px solid #e5e7eb;flex-shrink:0}
|
||||
.lc-item{display:block;padding:11px 16px;text-decoration:none;border-bottom:1px solid #f3f4f6;cursor:pointer}
|
||||
.lc-item:hover{background:#f9fafb}
|
||||
.lc-item.on{background:#f0fdf4}
|
||||
.lc-item-top{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.lc-item-name{font-size:.85rem;font-weight:600;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.lc-item-time{font-size:.68rem;color:#9ca3af;flex-shrink:0}
|
||||
.lc-item-preview{font-size:.76rem;color:#9ca3af;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.lc-item.needs-reply .lc-item-preview{color:#111827;font-weight:600}
|
||||
.lc-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--danger);margin-right:5px}
|
||||
.lc-main{flex:1;display:flex;flex-direction:column;min-width:0;background:#fff}
|
||||
.lc-topbar{padding:12px 18px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;gap:12px;background:#fff;flex-shrink:0}
|
||||
.lc-msgs{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:0;background:#f7f8fa}
|
||||
.lc-msg{display:flex;padding:4px 0}
|
||||
.lc-msg.me{justify-content:flex-end}
|
||||
.lc-msg.them{justify-content:flex-start}
|
||||
.lc-msg-col{display:flex;flex-direction:column;min-width:0;max-width:100%}
|
||||
.lc-bubble{display:inline-block;padding:8px 12px;border-radius:14px;font-size:.87rem;line-height:1.45;word-break:break-word;max-width:520px;white-space:pre-wrap}
|
||||
.lc-msg.me .lc-bubble{background:var(--primary);color:#fff}
|
||||
.lc-msg.them .lc-bubble{background:#fff;border:1px solid #e5e7eb;color:#1f2937}
|
||||
.lc-time{font-size:.65rem;color:#9ca3af;margin-top:2px;text-align:right}
|
||||
.lc-inp-wrap{border-top:1px solid #e5e7eb;padding:10px 14px;display:flex;align-items:center;gap:10px;background:#fff;flex-shrink:0}
|
||||
.lc-inp{flex:1;border:1px solid #d1d5db;border-radius:8px;background:#fff;font-size:.88rem;font-family:var(--font-body);color:#111827;outline:none;padding:8px 12px}
|
||||
.lc-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#9ca3af;gap:10px}
|
||||
</style>
|
||||
|
||||
<div class="lc-wrap">
|
||||
<div class="lc-list">
|
||||
<div class="lc-list-title" id="lc-list-title">Aktive Live-Chats (<?= count($chats) ?>)</div>
|
||||
<div id="lc-chat-list">
|
||||
<?php if (!$chats): ?>
|
||||
<div style="padding:20px 16px;color:#9ca3af;font-size:.82rem" id="lc-empty-hint">Aktuell keine offenen Live-Chats.</div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($chats as $c): $needsReply = $c['last_author'] === 'customer'; ?>
|
||||
<a href="?n=<?= urlencode($c['ticket_number']) ?>" class="lc-item <?= $c['ticket_number']===$activeNumber?'on':'' ?> <?= $needsReply?'needs-reply':'' ?>" data-number="<?= e($c['ticket_number']) ?>">
|
||||
<div class="lc-item-top">
|
||||
<span class="lc-item-name"><?= $needsReply ? '<span class="lc-dot"></span>' : '' ?><?= e($c['requester_name']) ?></span>
|
||||
<span class="lc-item-time"><?= date('H:i', strtotime($c['updated_at'])) ?></span>
|
||||
</div>
|
||||
<div class="lc-item-preview"><?= e(mb_substr(strip_tags((string)$c['last_message']), 0, 60)) ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lc-main">
|
||||
<?php if (!$activeTicket): ?>
|
||||
<div class="lc-empty">
|
||||
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<div style="font-size:.9rem;font-weight:600;color:#374151">Kein Live-Chat ausgewählt</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="lc-topbar">
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:.93rem;color:#111827;line-height:1"><?= e($activeTicket['requester_name']) ?></div>
|
||||
<div style="font-size:.74rem;color:#9ca3af;margin-top:2px"><?= e($activeTicket['requester_email']) ?> · <?= e($activeTicket['department_name']) ?></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<?php if ($activeTicket['status'] !== 'closed' && !$lockedByOtherName): ?>
|
||||
<button type="button" id="lc-end-btn" class="btn secondary" style="font-size:.78rem;padding:5px 12px;color:var(--danger, #dc2626);border-color:var(--danger, #dc2626)">Chat beenden</button>
|
||||
<?php endif; ?>
|
||||
<a href="/staff/ticket_view.php?n=<?= urlencode($activeTicket['ticket_number']) ?>" class="btn secondary" style="font-size:.78rem;padding:5px 12px">Im Ticket öffnen ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ($lockedByOtherName): ?>
|
||||
<div style="padding:8px 16px;font-size:.78rem;background:#fffbeb;border-bottom:1px solid #fde68a;color:#92400e;display:flex;align-items:center;gap:6px">
|
||||
🔒 Wird gerade von <strong> <?= e($lockedByOtherName) ?> </strong> bearbeitet – Sie sehen live mit, können hier aber nicht schreiben.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div id="lc-messages" class="lc-msgs">
|
||||
<?php foreach ($activeMessages as $m): $isMe = $m['author_type'] === 'staff'; ?>
|
||||
<div class="lc-msg <?= $isMe?'me':'them' ?>" data-id="<?= $m['id'] ?>">
|
||||
<div class="lc-msg-col">
|
||||
<?php if (trim($m['message']) !== ''): ?>
|
||||
<div class="lc-bubble"><?= nl2br(e($m['message'])) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($m['attachments'] as $a): ?>
|
||||
<a href="/download.php?id=<?= $a['id'] ?>" target="_blank" style="display:inline-flex;align-items:center;gap:5px;font-size:.78rem;padding:5px 10px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;text-decoration:none;color:#1f2937;margin-bottom:4px">📎 <?= e($a['original_name']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<div class="lc-time"><?= date('H:i', strtotime($m['created_at'])) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div id="lc-typing" style="display:none;padding:0 16px 6px;font-size:.76rem;color:#9ca3af;font-style:italic;background:#fff">Kunde tippt …</div>
|
||||
<?php if ($activeTicket['status'] === 'closed'): ?>
|
||||
<div style="padding:12px 16px;font-size:.8rem;color:#9ca3af;text-align:center;border-top:1px solid #e5e7eb">Dieser Chat wurde geschlossen.</div>
|
||||
<?php elseif ($lockedByOtherName): ?>
|
||||
<div style="padding:12px 16px;font-size:.8rem;color:#9ca3af;text-align:center;border-top:1px solid #e5e7eb">Nur Lesezugriff, solange <?= e($lockedByOtherName) ?> diesen Chat bearbeitet.</div>
|
||||
<?php else: ?>
|
||||
<div class="lc-inp-wrap">
|
||||
<input type="text" id="lc-input" class="lc-inp" placeholder="Nachricht schreiben…">
|
||||
<button type="button" class="btn" id="lc-send-btn">Senden</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Kurzer Hinweiston per Web Audio API (kein Audio-Asset nötig, funktioniert überall).
|
||||
window.lcPlayNotifySound = function () {
|
||||
try {
|
||||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
osc.connect(gain); gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
|
||||
osc.start(); osc.stop(ctx.currentTime + 0.35);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
var known = {}; // ticket_number -> needsReply, aus dem letzten Abruf
|
||||
var firstRun = true;
|
||||
var activeNumber = <?= json_encode($activeTicket['ticket_number'] ?? null) ?>;
|
||||
|
||||
function renderList(items) {
|
||||
var list = document.getElementById('lc-chat-list');
|
||||
document.getElementById('lc-list-title').textContent = 'Aktive Live-Chats (' + items.length + ')';
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div style="padding:20px 16px;color:#9ca3af;font-size:.82rem">Aktuell keine offenen Live-Chats.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
items.forEach(function (it) {
|
||||
var a = document.createElement('a');
|
||||
a.href = '?n=' + encodeURIComponent(it.number);
|
||||
a.className = 'lc-item' + (it.number === activeNumber ? ' on' : '') + (it.needsReply ? ' needs-reply' : '');
|
||||
var top = document.createElement('div');
|
||||
top.className = 'lc-item-top';
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'lc-item-name';
|
||||
if (it.needsReply) {
|
||||
var dot = document.createElement('span');
|
||||
dot.className = 'lc-dot';
|
||||
nameSpan.appendChild(dot);
|
||||
}
|
||||
nameSpan.appendChild(document.createTextNode(it.name));
|
||||
var timeSpan = document.createElement('span');
|
||||
timeSpan.className = 'lc-item-time';
|
||||
timeSpan.textContent = (it.updatedAt || '').substring(11, 16);
|
||||
top.appendChild(nameSpan); top.appendChild(timeSpan);
|
||||
var preview = document.createElement('div');
|
||||
preview.className = 'lc-item-preview';
|
||||
preview.textContent = it.preview;
|
||||
a.appendChild(top); a.appendChild(preview);
|
||||
list.appendChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
function pollList() {
|
||||
fetch('/staff/live_chat.php?action=list')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.items) return;
|
||||
var newlyNeedsReply = false;
|
||||
data.items.forEach(function (it) {
|
||||
var wasKnown = Object.prototype.hasOwnProperty.call(known, it.number);
|
||||
if (it.needsReply && (!wasKnown || !known[it.number])) {
|
||||
// Neue oder neu unbeantwortete Konversation – aber beim allerersten Laden
|
||||
// (firstRun) nicht gleich losklingeln, sonst piept es bei jedem Seitenaufruf.
|
||||
if (!firstRun) newlyNeedsReply = true;
|
||||
}
|
||||
known[it.number] = it.needsReply;
|
||||
});
|
||||
renderList(data.items);
|
||||
if (newlyNeedsReply) window.lcPlayNotifySound();
|
||||
firstRun = false;
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
pollList();
|
||||
setInterval(pollList, 8000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php if ($activeTicket): ?>
|
||||
<script>
|
||||
(function () {
|
||||
var number = <?= json_encode($activeTicket['ticket_number']) ?>;
|
||||
var csrf = <?= json_encode(csrf_token()) ?>;
|
||||
var lastId = <?= $activeMessages ? (int) end($activeMessages)['id'] : 0 ?>;
|
||||
var messagesEl = document.getElementById('lc-messages');
|
||||
var typingEl = document.getElementById('lc-typing');
|
||||
var input = document.getElementById('lc-input');
|
||||
var sendBtn = document.getElementById('lc-send-btn');
|
||||
var endBtn = document.getElementById('lc-end-btn');
|
||||
var lastTypingPingAt = 0;
|
||||
|
||||
function scrollBottom() { messagesEl.scrollTop = messagesEl.scrollHeight; }
|
||||
scrollBottom();
|
||||
|
||||
function pingTyping() {
|
||||
var now = Date.now();
|
||||
if (now - lastTypingPingAt < 3000) return;
|
||||
lastTypingPingAt = now;
|
||||
var body = new URLSearchParams();
|
||||
body.set('action', 'typing');
|
||||
body.set('n', number);
|
||||
body.set('csrf_token', csrf);
|
||||
fetch('/staff/live_chat.php', { method: 'POST', body: body }).catch(function () {});
|
||||
}
|
||||
if (input) input.addEventListener('input', pingTyping);
|
||||
|
||||
function appendBubble(m, isMe) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'lc-msg ' + (isMe ? 'me' : 'them');
|
||||
var col = document.createElement('div');
|
||||
col.className = 'lc-msg-col';
|
||||
if (m.message) {
|
||||
var bubble = document.createElement('div');
|
||||
bubble.className = 'lc-bubble';
|
||||
bubble.textContent = m.message;
|
||||
col.appendChild(bubble);
|
||||
}
|
||||
(m.attachments || []).forEach(function (a) {
|
||||
var link = document.createElement('a');
|
||||
link.href = '/download.php?id=' + a.id;
|
||||
link.target = '_blank';
|
||||
link.style.cssText = 'display:inline-flex;align-items:center;gap:5px;font-size:.78rem;padding:5px 10px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;text-decoration:none;color:#1f2937;margin-bottom:4px';
|
||||
link.textContent = '📎 ' + a.name;
|
||||
col.appendChild(link);
|
||||
});
|
||||
var time = document.createElement('div');
|
||||
time.className = 'lc-time';
|
||||
time.textContent = (m.created_at || '').substring(11, 16);
|
||||
col.appendChild(time);
|
||||
row.appendChild(col);
|
||||
messagesEl.appendChild(row);
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
function updateCsrf(data) {
|
||||
if (data && data.csrfToken) csrf = data.csrfToken;
|
||||
}
|
||||
|
||||
function send() {
|
||||
var text = input.value.trim();
|
||||
if (!text) return;
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
var body = new URLSearchParams();
|
||||
body.set('n', number);
|
||||
body.set('message', text);
|
||||
body.set('csrf_token', csrf);
|
||||
fetch('/staff/live_chat.php', { method: 'POST', body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
updateCsrf(data);
|
||||
// Eingabe erst NACH bestätigtem Erfolg leeren – schlägt das Senden fehl
|
||||
// (abgelaufener Token, Netzwerkfehler), bleibt der getippte Text erhalten
|
||||
// statt kommentarlos verloren zu gehen.
|
||||
if (data.error) { alert(data.error); return; }
|
||||
input.value = '';
|
||||
appendBubble({ message: text, created_at: data.createdAt }, true);
|
||||
lastId = data.id;
|
||||
})
|
||||
.catch(function () { alert('Senden fehlgeschlagen. Bitte erneut versuchen.'); })
|
||||
.finally(function () { if (sendBtn) sendBtn.disabled = false; });
|
||||
}
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', send);
|
||||
if (input) input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
||||
});
|
||||
|
||||
if (endBtn) endBtn.addEventListener('click', function () {
|
||||
if (!confirm('Diesen Chat wirklich beenden? Er bleibt als Ticket erhalten, verschwindet aber aus den aktiven Live-Chats.')) return;
|
||||
var body = new URLSearchParams();
|
||||
body.set('action', 'end');
|
||||
body.set('n', number);
|
||||
body.set('csrf_token', csrf);
|
||||
fetch('/staff/live_chat.php', { method: 'POST', body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
updateCsrf(data);
|
||||
if (data.error) { alert(data.error); return; }
|
||||
window.location.href = '/staff/live_chat.php';
|
||||
})
|
||||
.catch(function () { alert('Beenden fehlgeschlagen. Bitte erneut versuchen.'); });
|
||||
});
|
||||
|
||||
function poll() {
|
||||
fetch('/staff/live_chat.php?action=poll&n=' + encodeURIComponent(number) + '&since=' + lastId)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.error) return;
|
||||
if (typingEl) typingEl.style.display = data.customerTyping ? 'block' : 'none';
|
||||
if (!data.messages) return;
|
||||
data.messages.forEach(function (m) {
|
||||
appendBubble(m, m.author_type === 'staff');
|
||||
if (m.author_type === 'customer' && window.lcPlayNotifySound) window.lcPlayNotifySound();
|
||||
if (m.id > lastId) lastId = m.id;
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
setInterval(poll, 8000);
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/totp.php';
|
||||
|
||||
if (current_staff()) {
|
||||
redirect('/staff/reports.php');
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$need2fa = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? 'login';
|
||||
|
||||
if ($action === 'verify_totp') {
|
||||
$pendingId = $_SESSION['totp_pending_staff_id'] ?? null;
|
||||
$code = trim($_POST['totp_code'] ?? '');
|
||||
if (!$pendingId) {
|
||||
$errors[] = 'Sitzung abgelaufen. Bitte neu anmelden.';
|
||||
} else {
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$pendingId]);
|
||||
$staff = $stmt->fetch();
|
||||
if ($staff && totp_verify($staff['totp_secret'], $code)) {
|
||||
unset($_SESSION['totp_pending_staff_id']);
|
||||
staff_login($staff);
|
||||
redirect('/staff/reports.php');
|
||||
}
|
||||
$errors[] = 'Ungültiger Code. Bitte erneut versuchen.';
|
||||
$need2fa = true;
|
||||
}
|
||||
} else {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$pass = $_POST['password'] ?? '';
|
||||
if (!rate_limit_check('staff_login_attempt', client_ip(), 10, 15)) {
|
||||
$errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} elseif ($email && !rate_limit_check('staff_login_email', $email, 5, 15)) {
|
||||
// Konto-basierte Sperre – verhindert Brute-Force über wechselnde IPs
|
||||
$errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
|
||||
} else {
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
|
||||
$stmt->execute([$email]);
|
||||
$staff = $stmt->fetch();
|
||||
if ($staff && password_verify($pass, $staff['password_hash'])) {
|
||||
// Erfolgreicher Login – Zähler zurücksetzen
|
||||
rate_limit_reset('staff_login_email', $email);
|
||||
if ($staff['totp_enabled']) {
|
||||
$_SESSION['totp_pending_staff_id'] = $staff['id'];
|
||||
$need2fa = true;
|
||||
} else {
|
||||
staff_login($staff);
|
||||
redirect('/staff/reports.php');
|
||||
}
|
||||
} else {
|
||||
$errors[] = 'E-Mail-Adresse oder Passwort ist falsch.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Mitarbeiter-Login';
|
||||
$activePage = '';
|
||||
// Staff-Header hat Sidebar – für Login-Seite eigenes schlankes HTML
|
||||
$banner = get_setting('banner_path');
|
||||
$logo = get_setting('logo_path');
|
||||
$displayName = get_setting('display_name', config()['site']['name']);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Mitarbeiter-Login – <?= e($displayName) ?></title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
<?php $customThemeStylesheet = active_custom_theme_stylesheet(); ?>
|
||||
<?php if ($customThemeStylesheet): ?>
|
||||
<link rel="stylesheet" href="<?= e($customThemeStylesheet) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="manifest" href="/manifest-staff.php">
|
||||
<meta name="theme-color" content="#0f6f6a">
|
||||
<?php if ($logo): ?><link rel="icon" href="<?= e($logo) ?>"><?php endif; ?>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
|
||||
</head>
|
||||
<body class="login-page staff-login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh; background:#16202a <?php if($banner): ?>; background-image:url('<?= e($banner) ?>'); background-size:cover; background-position:center<?php endif; ?>">
|
||||
|
||||
<!-- Schlanker Header -->
|
||||
<header style="background:rgba(14,26,34,.95); border-bottom:1px solid rgba(255,255,255,.08); padding:0 28px; height:56px; display:flex; align-items:center; justify-content:space-between; flex-shrink:0">
|
||||
<div style="display:flex; align-items:center; gap:12px">
|
||||
<?php if ($logo): ?>
|
||||
<img src="<?= e($logo) ?>" alt="" style="height:32px; width:auto">
|
||||
<?php endif; ?>
|
||||
<span style="color:#fff; font-weight:700; font-size:.95rem"><?= e($displayName) ?></span>
|
||||
</div>
|
||||
<a href="/login.php" style="color:rgba(255,255,255,.5); font-size:.8rem; text-decoration:none">← Zum Kundenportal</a>
|
||||
</header>
|
||||
|
||||
<!-- Auth-Bereich -->
|
||||
<div class="login-screen staff-login-screen">
|
||||
<div class="auth-card login-card staff-login-card">
|
||||
<h1 style="font-size:1.4rem; font-weight:700; margin-bottom:4px; color:#0f1923">Mitarbeiterbereich</h1>
|
||||
<div class="sub">Anmeldung für Support-Mitarbeiter und Administratoren.</div>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach (get_flashes() as $f): ?>
|
||||
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($need2fa): ?>
|
||||
<p style="font-size:.88rem; color:#64748b; margin-bottom:14px">Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="verify_totp">
|
||||
<label for="totp_code">Authenticator-Code</label>
|
||||
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" pattern="\d{6}" maxlength="6" required autofocus placeholder="000000" style="letter-spacing:.2em; font-size:1.3rem; text-align:center">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn login-submit">Verifizieren</button>
|
||||
</div>
|
||||
</form>
|
||||
<p style="margin-top:14px; font-size:.8rem; text-align:center"><a href="/staff/login.php">← Zurück zum Login</a></p>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="login">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" required autofocus autocomplete="email">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<div style="text-align:right; margin-top:6px">
|
||||
<a href="/staff/forgot_password.php" style="font-size:.8rem">Passwort vergessen?</a>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn login-submit">Anmelden</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schlanker Footer -->
|
||||
<footer style="background:rgba(14,26,34,.95); border-top:1px solid rgba(255,255,255,.08); padding:12px 28px; text-align:center; flex-shrink:0">
|
||||
<span style="color:rgba(255,255,255,.3); font-size:.75rem">Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.</span>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
staff_logout();
|
||||
redirect('/staff/login.php');
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
// Alle als gelesen markieren bei Aufruf
|
||||
$pdo->prepare('UPDATE staff_mentions SET is_read=1 WHERE mentioned_staff_id=?')->execute([$staff['id']]);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT sm.*, t.ticket_number, t.subject, s.name AS by_name, sm.created_at
|
||||
FROM staff_mentions sm
|
||||
JOIN tickets t ON t.id = sm.ticket_id
|
||||
JOIN staff s ON s.id = sm.mentioned_by_staff_id
|
||||
WHERE sm.mentioned_staff_id = ?
|
||||
ORDER BY sm.created_at DESC
|
||||
LIMIT 100"
|
||||
);
|
||||
$stmt->execute([$staff['id']]);
|
||||
$mentions = $stmt->fetchAll();
|
||||
|
||||
$pageTitle = 'Meine Erwähnungen';
|
||||
$activePage = 'mentions';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>🔔 Meine Erwähnungen</h1>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<?php if (!$mentions): ?>
|
||||
<div class="empty-state">Keine Erwähnungen vorhanden.</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Betreff</th>
|
||||
<th>Erwähnt von</th>
|
||||
<th>Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($mentions as $m): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/ticket_view.php?n=<?= urlencode($m['ticket_number']) ?>" class="ticket-no">#<?= e($m['ticket_number']) ?></a></td>
|
||||
<td><a href="/staff/ticket_view.php?n=<?= urlencode($m['ticket_number']) ?>"><?= e($m['subject']) ?></a></td>
|
||||
<td><?= e($m['by_name']) ?></td>
|
||||
<td class="muted"><?= format_dt($m['created_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin_or_permission('manage_organizations');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO organizations (name, notes, created_at) VALUES (?,?,?)')
|
||||
->execute([$name, $notes, db_now()]);
|
||||
audit_log($admin['id'], "Organisation erstellt: {$name}");
|
||||
flash('success', 'Organisation erstellt.');
|
||||
} else {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('UPDATE organizations SET name=?, notes=? WHERE id=?')->execute([$name, $notes, $id]);
|
||||
audit_log($admin['id'], "Organisation aktualisiert: {$name}");
|
||||
flash('success', 'Organisation aktualisiert.');
|
||||
}
|
||||
redirect('/staff/organizations.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('UPDATE users SET organization_id = NULL WHERE organization_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM organizations WHERE id = ?')->execute([$id]);
|
||||
audit_log($admin['id'], "Organisation gelöscht: #{$id}");
|
||||
flash('success', 'Organisation gelöscht.');
|
||||
redirect('/staff/organizations.php');
|
||||
}
|
||||
}
|
||||
|
||||
$organizations = $pdo->query(
|
||||
'SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.organization_id = o.id) AS member_count
|
||||
FROM organizations o ORDER BY o.name'
|
||||
)->fetchAll();
|
||||
|
||||
$editId = $_GET['edit'] ?? null;
|
||||
$editOrg = null;
|
||||
$members = [];
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM organizations WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editOrg = $stmt->fetch();
|
||||
if ($editOrg) {
|
||||
$stmt = $pdo->prepare('SELECT id, name, email FROM users WHERE organization_id = ? ORDER BY name');
|
||||
$stmt->execute([$editId]);
|
||||
$members = $stmt->fetchAll();
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Organisationen';
|
||||
$activePage = 'organizations';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<h1>Kunden-Organisationen</h1>
|
||||
<p class="muted">Bündeln Sie mehrere Kundenkonten unter einer Firma/Organisation. Kontakte werden unter „Kunden“ zugeordnet.</p>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="display:flex; gap:24px; align-items:flex-start">
|
||||
<div style="flex:2">
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Kontakte</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($organizations as $o): ?>
|
||||
<tr>
|
||||
<td><?= e($o['name']) ?></td>
|
||||
<td><?= (int) $o['member_count'] ?></td>
|
||||
<td>
|
||||
<a href="/staff/organizations.php?edit=<?= $o['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Organisation wirklich löschen? Zugeordnete Kunden bleiben erhalten, verlieren aber die Zuordnung.');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $o['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$organizations): ?>
|
||||
<tr><td colspan="3" class="muted">Noch keine Organisationen angelegt.</td></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if ($editOrg): ?>
|
||||
<div class="card">
|
||||
<h3>Kontakte von „<?= e($editOrg['name']) ?>“</h3>
|
||||
<?php if (!$members): ?>
|
||||
<p class="muted">Noch keine Kunden zugeordnet. Das geht unter <a href="/staff/customers.php">Kunden</a>.</p>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>E-Mail</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($members as $m): ?>
|
||||
<tr><td><?= e($m['name']) ?></td><td><?= e($m['email']) ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div style="flex:1; min-width:280px">
|
||||
<div class="card">
|
||||
<h3><?= $editOrg ? 'Organisation bearbeiten' : 'Neue Organisation' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editOrg ? 'update' : 'create' ?>">
|
||||
<?php if ($editOrg): ?><input type="hidden" name="id" value="<?= $editOrg['id'] ?>"><?php endif; ?>
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($editOrg['name'] ?? '') ?>" required>
|
||||
<label for="notes">Notizen (optional)</label>
|
||||
<textarea id="notes" name="notes"><?= e($editOrg['notes'] ?? '') ?></textarea>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn"><?= $editOrg ? 'Speichern' : 'Erstellen' ?></button>
|
||||
<?php if ($editOrg): ?><a href="/staff/organizations.php" class="btn secondary">Abbrechen</a><?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
/**
|
||||
* Hostseite für Plugin-Seiten: /staff/plugin.php?p=<slug>&page=<seite>
|
||||
* Lädt plugins/<slug>/pages/<seite>.php innerhalb des normalen Mitarbeiter-Layouts.
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
$slug = (string) ($_GET['p'] ?? '');
|
||||
$page = (string) ($_GET['page'] ?? '');
|
||||
if (!plugin_valid_slug($slug) || !preg_match('/^[A-Za-z0-9_-]+$/', $page)) { http_response_code(404); exit('Seite nicht gefunden.'); }
|
||||
|
||||
$loaded = plugin_state()['loaded'][$slug] ?? null;
|
||||
$file = plugins_dir() . '/' . $slug . '/pages/' . $page . '.php';
|
||||
// Nur Seiten, die das Plugin selbst als Menüpunkt registriert hat (verhindert den Aufruf beliebiger Dateien)
|
||||
$registered = array_filter(plugin_state()['menus'], fn($m) => $m['plugin'] === $slug && $m['page'] === $page);
|
||||
if (!$loaded || !$registered || !is_file($file)) { http_response_code(404); exit('Seite nicht gefunden oder Plugin nicht aktiv.'); }
|
||||
$menu = reset($registered);
|
||||
if ($menu['permission'] !== null && !has_permission($staff, $menu['permission'])) { http_response_code(403); exit('Keine Berechtigung.'); }
|
||||
|
||||
$pageTitle = $menu['label'];
|
||||
$activePage = 'plugin_' . $slug;
|
||||
require __DIR__ . '/_header.php';
|
||||
plugin_guard($slug, function () use ($file, $staff, $pdo) {
|
||||
// Variablen für die Plugin-Seite: $staff, $pdo
|
||||
include $file;
|
||||
});
|
||||
require __DIR__ . '/_footer.php';
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/backup.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
$errors = [];
|
||||
$permLabels = plugin_known_permissions();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
$slug = (string) ($_POST['slug'] ?? '');
|
||||
|
||||
if ($action === 'save_settings' && plugin_strict_mode()) {
|
||||
$errors[] = 'Im geprüften Modus sind Katalog und Schlüssel fest vorgegeben.';
|
||||
} elseif ($action === 'save_settings') {
|
||||
$url = trim((string) ($_POST['catalog_url'] ?? ''));
|
||||
$key = trim((string) ($_POST['publisher_key'] ?? ''));
|
||||
if ($url !== '' && ($e = plugin_url_check($url))) $errors[] = 'Katalog-Adresse: ' . $e;
|
||||
if ($key !== '' && !plugin_public_key_valid($key)) $errors[] = 'Der Herausgeber-Schlüssel ist kein gültiger öffentlicher RSA-Schlüssel (PEM, mindestens 2048 Bit).';
|
||||
if (!empty($_POST['require_signature']) && $key === '') $errors[] = 'Für „Signatur erforderlich“ muss ein Herausgeber-Schlüssel hinterlegt sein.';
|
||||
if (!$errors) {
|
||||
set_setting('plugin_catalog_url', $url);
|
||||
set_setting('plugin_publisher_key', $key);
|
||||
set_setting('plugin_require_signature', !empty($_POST['require_signature']) ? '1' : '0');
|
||||
audit_log($staff['id'], 'Plugin-Einstellungen geändert (Katalog/Herausgeber-Schlüssel)');
|
||||
flash('success', 'Einstellungen gespeichert.');
|
||||
redirect('/staff/plugins.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'install') {
|
||||
$f = $_FILES['plugin_zip'] ?? [];
|
||||
if (empty($_POST['trust'])) {
|
||||
$errors[] = 'Bitte bestätigen, dass du der Quelle des Plugins vertraust.';
|
||||
} elseif (($f['error'] ?? 99) !== UPLOAD_ERR_OK) {
|
||||
$errors[] = 'Upload-Fehler (Code ' . ($f['error'] ?? 'unbekannt') . ').';
|
||||
} elseif (strtolower(pathinfo((string) ($f['name'] ?? ''), PATHINFO_EXTENSION)) !== 'zip') {
|
||||
$errors[] = 'Nur ZIP-Dateien sind erlaubt.';
|
||||
} else {
|
||||
$res = plugin_install_zip($pdo, $f['tmp_name'], trim((string) ($_POST['sha256'] ?? '')), trim((string) ($_POST['signature'] ?? '')), 'upload');
|
||||
if (!$res['ok']) {
|
||||
$errors[] = $res['error'];
|
||||
} else {
|
||||
audit_log($staff['id'], ($res['updated'] ? 'Plugin aktualisiert: ' : 'Plugin installiert: ') . "{$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
|
||||
flash('success', ($res['updated'] ? 'Plugin aktualisiert' : 'Plugin installiert') . ": {$res['name']} {$res['version']} (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.'
|
||||
. ($res['needs_reapproval'] ? ' Die neue Version fordert zusätzliche Berechtigungen an und wurde angehalten.' : ''));
|
||||
redirect('/staff/plugins.php');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'catalog_install' && plugin_valid_slug($slug)) {
|
||||
$res = plugin_catalog_install($pdo, $slug);
|
||||
if ($res['ok']) {
|
||||
audit_log($staff['id'], "Plugin aus dem Katalog installiert: {$res['slug']} {$res['version']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
|
||||
flash('success', "{$res['name']} {$res['version']} installiert (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.');
|
||||
redirect('/staff/plugins.php');
|
||||
}
|
||||
$errors[] = $res['error'];
|
||||
}
|
||||
|
||||
if (in_array($action, ['activate', 'deactivate'], true) && plugin_valid_slug($slug)) {
|
||||
if ($action === 'activate') {
|
||||
$bk = create_backup(); // vor Datenbank-Änderungen des Plugins
|
||||
if (!$bk['ok']) $errors[] = 'Backup fehlgeschlagen – das Plugin wurde nicht aktiviert: ' . ($bk['error'] ?? 'unbekannter Fehler');
|
||||
}
|
||||
if (!$errors) {
|
||||
$manifest = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
|
||||
$approve = !empty($_POST['confirm_permissions']) && $manifest ? $manifest['permissions'] : null;
|
||||
$res = plugin_set_active($pdo, $slug, $action === 'activate', $approve);
|
||||
if ($res['ok']) {
|
||||
audit_log($staff['id'], ($action === 'activate' ? 'Plugin aktiviert: ' : 'Plugin deaktiviert: ') . $slug);
|
||||
flash('success', $action === 'activate' ? 'Plugin aktiviert.' : 'Plugin deaktiviert.');
|
||||
redirect('/staff/plugins.php');
|
||||
}
|
||||
$errors[] = $res['error'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'uninstall' && plugin_valid_slug($slug)) {
|
||||
$res = plugin_uninstall($pdo, $slug, !empty($_POST['remove_data']));
|
||||
if ($res['ok']) {
|
||||
audit_log($staff['id'], 'Plugin entfernt: ' . $slug . (!empty($_POST['remove_data']) ? ' (inkl. Daten)' : ''));
|
||||
flash('success', 'Plugin entfernt.');
|
||||
redirect('/staff/plugins.php');
|
||||
}
|
||||
$errors[] = $res['error'];
|
||||
}
|
||||
}
|
||||
|
||||
$installed = plugins_installed($pdo);
|
||||
$manifests = [];
|
||||
foreach ($installed as $slug => $row) $manifests[$slug] = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
|
||||
$disabled = plugins_globally_disabled();
|
||||
$strict = plugin_strict_mode();
|
||||
$official = plugin_official_configured();
|
||||
$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh'])) : null;
|
||||
$hasKey = plugin_publisher_key() !== '';
|
||||
$catalogHost = (string) (parse_url(plugin_catalog_url(), PHP_URL_HOST) ?: '');
|
||||
|
||||
$pageTitle = 'Plugins';
|
||||
$activePage = 'plugins';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
<h1 style="margin-top:0">Plugins</h1>
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
|
||||
<?php $updates = plugin_updates_available($pdo); if ($updates): ?>
|
||||
<div class="flash warning"><strong>Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['installed'] . ' → ' . $u['available'], $updates))) ?>. Unten im Katalog mit „Aktualisieren“ installieren.</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($disabled): ?>
|
||||
<div class="flash error">Alle Plugins sind abgeschaltet (Datei <code>plugins/.disabled</code> oder Einstellung <code>plugins.enabled = false</code>). Bereits aktive Plugins werden nicht geladen.</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Plugin</th><th>Version</th><th>Status</th><th>Herkunft</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($installed as $slug => $p): $m = $manifests[$slug]; $perms = $m['permissions'] ?? []; ?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= e($p['name']) ?></strong> <span class="muted" style="font-size:.78rem"><?= e($slug) ?></span>
|
||||
<?php if ($m && $m['description'] !== ''): ?><div class="muted" style="font-size:.82rem"><?= e($m['description']) ?></div><?php endif; ?>
|
||||
<?php if ($m && $m['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($m['author']) ?></div><?php endif; ?>
|
||||
<?php if ($perms): ?>
|
||||
<details style="font-size:.78rem;margin-top:4px"><summary>Berechtigungen (<?= count($perms) ?>)</summary>
|
||||
<ul style="margin:4px 0 0;padding-left:18px"><?php foreach ($perms as $perm): ?><li><?= e($permLabels[$perm] ?? $perm) ?> <span class="muted">(<?= e($perm) ?>)</span></li><?php endforeach; ?></ul>
|
||||
</details>
|
||||
<?php elseif ($m): ?><div class="muted" style="font-size:.78rem">Fordert keine Berechtigungen an.</div><?php endif; ?>
|
||||
<?php if (!$m): ?><div style="color:#dc2626;font-size:.82rem">plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.</div><?php endif; ?>
|
||||
</td>
|
||||
<td><?= e($p['version']) ?></td>
|
||||
<td>
|
||||
<?php if ($p['is_active']): ?><span style="color:#15803d;font-weight:600">Aktiv</span>
|
||||
<?php elseif ((int) $p['fail_count'] >= PLUGIN_FAIL_LIMIT): ?><span style="color:#dc2626;font-weight:600">Wegen Fehlern deaktiviert</span>
|
||||
<?php else: ?><span class="muted">Inaktiv</span><?php endif; ?>
|
||||
</td>
|
||||
<td style="font-size:.82rem">
|
||||
<?php if (!empty($p['signature_ok'])): ?><span style="color:#15803d;font-weight:600">✓ Signiert</span>
|
||||
<?php else: ?><span style="color:#b45309;font-weight:600">Eigenes Plugin</span><?php endif; ?><br>
|
||||
<span class="muted"><?= ($p['source'] ?? '') === 'catalog' ? 'Aus dem Katalog' : (!empty($p['signature_ok']) ? 'Manuell hochgeladen' : 'Nicht vom Herausgeber geprüft') ?></span>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<?php if ($p['is_active']): ?>
|
||||
<form method="post" style="display:inline"><?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="deactivate"><button class="btn secondary" type="submit">Deaktivieren</button></form>
|
||||
<?php else: ?>
|
||||
<form method="post" style="display:inline-block;text-align:left" onsubmit="return confirm('Plugin aktivieren? Vorher wird ein Backup erstellt und evtl. Datenbank-Änderungen des Plugins eingespielt.')">
|
||||
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="activate">
|
||||
<?php if ($perms): ?><label style="font-size:.76rem;display:block"><input type="checkbox" name="confirm_permissions" value="1" required> Berechtigungen bestätigen</label><?php endif; ?>
|
||||
<button class="btn" type="submit" <?= $m ? '' : 'disabled' ?>>Aktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Plugin wirklich entfernen?')">
|
||||
<?= csrf_field() ?><input type="hidden" name="slug" value="<?= e($slug) ?>"><input type="hidden" name="action" value="uninstall">
|
||||
<label style="font-size:.78rem"><input type="checkbox" name="remove_data" value="1"> Daten löschen</label>
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer">Entfernen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$installed): ?><tr><td colspan="5" class="muted">Noch keine Plugins installiert.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<h3 style="margin:0"><?= $official ? 'Geprüfte Plugins' : 'Plugin-Katalog' ?></h3>
|
||||
<?php if ($catalog): ?><a class="btn secondary" href="/staff/plugins.php?refresh=1">Katalog aktualisieren</a><?php endif; ?>
|
||||
</div>
|
||||
<?php if (!$catalog): ?>
|
||||
<p class="muted" style="font-size:.85rem">Es ist noch keine Katalog-Adresse hinterlegt. Trage unten die Adresse der Plugin-Webseite ein, dann könnt ihr Plugins direkt von dort installieren.</p>
|
||||
<?php elseif (!$catalog['ok']): ?>
|
||||
<div class="flash error" style="margin-top:12px"><?= e($catalog['error']) ?></div>
|
||||
<?php else: ?>
|
||||
<?php if (!empty($catalog['cached_at'])): ?><div class="muted" style="font-size:.76rem;margin-top:6px">Stand: <?= e(date('d.m.Y H:i', (int) $catalog['cached_at'])) ?></div><?php endif; ?>
|
||||
<table style="margin-top:12px">
|
||||
<thead><tr><th>Plugin</th><th>Version</th><th>Signatur</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($catalog['plugins'] as $c): $have = $installed[$c['slug']] ?? null; $newer = $have && version_compare($c['version'], $have['version'], '>'); ?>
|
||||
<tr>
|
||||
<td><div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('plugin', $c) ?><div style="min-width:0">
|
||||
<strong><?= e($c['name']) ?></strong> <span class="muted" style="font-size:.78rem"><?= e($c['slug']) ?></span>
|
||||
<div class="muted" style="font-size:.82rem"><?= e($c['description']) ?></div>
|
||||
<?php if ($c['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($c['author']) ?></div><?php endif; ?>
|
||||
<?php if ($c['permissions']): ?><div class="muted" style="font-size:.76rem">Fordert an: <?= e(implode(', ', array_map(fn($p) => $permLabels[$p] ?? $p, $c['permissions']))) ?></div><?php endif; ?>
|
||||
</div></div></td>
|
||||
<td><?= e($c['version']) ?><?= $have ? '<div class="muted" style="font-size:.76rem">installiert: ' . e($have['version']) . '</div>' : '' ?></td>
|
||||
<td style="font-size:.82rem"><?= $c['signature'] !== '' ? ($hasKey ? '<span style="color:#15803d;font-weight:600">✓ Signiert</span>' : '<span style="color:#b45309">Signatur vorhanden, Schlüssel fehlt</span>') : '<span style="color:#b45309">Nicht signiert</span>' ?></td>
|
||||
<td>
|
||||
<?php if ($have && !$newer): ?><span class="muted">aktuell</span>
|
||||
<?php elseif ($strict && $c['signature'] === ''): ?><span class="muted" style="font-size:.78rem">nicht signiert,<br>nicht installierbar</span>
|
||||
<?php else: ?>
|
||||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="catalog_install"><input type="hidden" name="slug" value="<?= e($c['slug']) ?>">
|
||||
<button class="btn" type="submit"><?= $newer ? 'Aktualisieren' : 'Installieren' ?></button></form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$catalog['plugins']): ?><tr><td colspan="4" class="muted">Der Katalog enthält keine Plugins.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin-top:0">Eigenes Plugin hochladen</h3>
|
||||
<p class="muted" style="font-size:.85rem">Hier könnt ihr eigene Plugins als ZIP installieren oder aktualisieren, auch ohne Signatur. Diese Plugins sind <strong>nicht vom Herausgeber geprüft</strong> und laufen mit allen Rechten des Systems, auch mit Zugriff auf die gesamte Datenbank. Installiert nur Code, den ihr selbst geschrieben oder geprüft habt.</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="install">
|
||||
<label for="plugin_zip">ZIP-Datei</label>
|
||||
<input type="file" id="plugin_zip" name="plugin_zip" accept=".zip" required>
|
||||
<label for="signature">Signatur (optional, Base64)</label>
|
||||
<input type="text" id="signature" name="signature" placeholder="Nur ausfüllen, wenn der Anbieter eine Signatur nennt" autocomplete="off">
|
||||
<label for="sha256">Erwartete Prüfsumme (SHA-256, optional)</label>
|
||||
<input type="text" id="sha256" name="sha256" placeholder="Nur ausfüllen, wenn der Anbieter eine Prüfsumme nennt" autocomplete="off">
|
||||
<label style="margin-top:10px"><input type="checkbox" name="trust" value="1" required> Ich kenne den Inhalt dieses Plugins und vertraue ihm. Mir ist bewusst, dass es mit allen Rechten des Systems läuft.</label>
|
||||
<div class="btn-row" style="margin-top:12px"><button type="submit" class="btn">Hochladen & installieren</button></div>
|
||||
</form>
|
||||
<p class="muted" style="font-size:.82rem">Das Plugin wird nach der Installation nicht automatisch aktiviert. Vor dem Aktivieren bestätigt ihr die Berechtigungen, die es anfordert.</p>
|
||||
</div>
|
||||
|
||||
<?php if (!$strict): ?>
|
||||
<div class="card" style="margin-top:20px">
|
||||
<h3 style="margin-top:0">Katalog und Herausgeber-Schlüssel</h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="save_settings">
|
||||
<label for="catalog_url">Katalog-Adresse (https)</label>
|
||||
<input type="text" id="catalog_url" name="catalog_url" value="<?= e(plugin_catalog_url()) ?>" placeholder="https://plugins.example.de/catalog.php?type=plugins">
|
||||
<label for="publisher_key">Öffentlicher Herausgeber-Schlüssel (PEM)</label>
|
||||
<textarea id="publisher_key" name="publisher_key" rows="6" style="font-family:monospace;font-size:.78rem" placeholder="-----BEGIN PUBLIC KEY-----"><?= e(plugin_publisher_key()) ?></textarea>
|
||||
<label style="margin-top:8px"><input type="checkbox" name="require_signature" value="1" <?= plugin_signatures_required() ? 'checked' : '' ?>> Nur signierte Plugins installieren (empfohlen, sobald ein Schlüssel hinterlegt ist)</label>
|
||||
<p class="muted" style="font-size:.82rem">Den Schlüssel bekommst du von der Plugin-Webseite. Ist er hinterlegt, prüft DeskHub bei jeder Installation, ob das Paket wirklich vom Herausgeber stammt und unverändert ist.</p>
|
||||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin_or_permission('manage_priorities');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$color = trim($_POST['color'] ?? '#888888');
|
||||
$response = (int) ($_POST['response_hours'] ?? 0);
|
||||
$resolution = (int) ($_POST['resolution_hours'] ?? 0);
|
||||
$sort = (int) ($_POST['sort_order'] ?? 0);
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||||
if ($response <= 0 || $resolution <= 0) $errors[] = 'Bitte gültige SLA-Stunden angeben (größer als 0).';
|
||||
if ($response > $resolution) $errors[] = 'Die Reaktionszeit darf nicht länger als die Lösungszeit sein.';
|
||||
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Bitte eine gültige Farbe im Format #RRGGBB angeben.';
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM priorities WHERE LOWER(name) = LOWER(?) AND id <> ?');
|
||||
$dup->execute([$name, $id]);
|
||||
if ($dup->fetchColumn() > 0) $errors[] = 'Eine Priorität mit diesem Namen existiert bereits.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO priorities (name, color, response_hours, resolution_hours, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)')
|
||||
->execute([$name, $color, $response, $resolution, $sort, $notes ?: null, db_now(), db_now()]);
|
||||
audit_log($staff['id'], "Priorität erstellt: {$name}");
|
||||
flash('success', 'Priorität erstellt.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE priorities SET name=?, color=?, response_hours=?, resolution_hours=?, sort_order=?, notes=?, updated_at=? WHERE id=?')
|
||||
->execute([$name, $color, $response, $resolution, $sort, $notes ?: null, db_now(), $id]);
|
||||
audit_log($staff['id'], "Priorität aktualisiert: {$name}");
|
||||
flash('success', 'Änderungen gespeichert.');
|
||||
}
|
||||
redirect('/staff/priorities.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE priority_id = ?');
|
||||
$stmt->execute([$id]);
|
||||
if ((int) $stmt->fetchColumn() > 0) {
|
||||
flash('error', 'Diese Priorität wird noch von Tickets verwendet und kann nicht gelöscht werden.');
|
||||
} else {
|
||||
$pdo->prepare('DELETE FROM priorities WHERE id = ?')->execute([$id]);
|
||||
audit_log($staff['id'], "Priorität gelöscht: #{$id}");
|
||||
flash('success', 'Priorität gelöscht.');
|
||||
}
|
||||
redirect('/staff/priorities.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$priorities = $pdo->query('SELECT p.*, (SELECT COUNT(*) FROM tickets t WHERE t.priority_id = p.id) AS ticket_count FROM priorities p ORDER BY p.sort_order')->fetchAll();
|
||||
$editPrio = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editPrio = $stmt->fetch() ?: null;
|
||||
}
|
||||
$hours = fn(int $h) => $h >= 24 && $h % 24 === 0 ? ($h / 24) . ' Tag' . ($h / 24 === 1 ? '' : 'e') : $h . ' Std';
|
||||
|
||||
$pageTitle = 'Prioritäten / SLA';
|
||||
$activePage = 'priorities';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editPrio[$col] ?? $default);
|
||||
?>
|
||||
<h1>
|
||||
<?= $editPrio ? 'Priorität / SLA aktualisieren' : 'Neue Priorität' ?>
|
||||
<?php if ($editPrio): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editPrio['name']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editPrio ? 'update' : 'create' ?>">
|
||||
<?php if ($editPrio): ?><input type="hidden" name="id" value="<?= (int) $editPrio['id'] ?>"><?php endif; ?>
|
||||
|
||||
<?php ui_tab_bar(['prio' => 'Priorität', 'sla' => 'SLA-Zeiten']); ?>
|
||||
|
||||
<div class="tab-pane" data-pane="prio" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Prioritätsinformationen</h3>
|
||||
<label for="name">Name *</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" required>
|
||||
<label for="color">Farbe *</label>
|
||||
<div style="display:flex;align-items:center;gap:10px;max-width:260px">
|
||||
<input type="color" id="color_picker" value="<?= e($v('color', 'color', '#2563eb')) ?>" style="width:46px;padding:2px" oninput="document.getElementById('color').value=this.value">
|
||||
<input type="text" id="color" name="color" value="<?= e($v('color', 'color', '#2563eb')) ?>" placeholder="#2563eb" required oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))document.getElementById('color_picker').value=this.value">
|
||||
</div>
|
||||
<label for="sort_order">Sortierung</label>
|
||||
<input type="number" id="sort_order" name="sort_order" value="<?= e((string) $v('sort_order', 'sort_order', '0')) ?>" style="max-width:140px">
|
||||
<h3>Administratornotizen <span class="muted" style="font-weight:400;font-size:.85rem">– Interne Notizen, sichtbar für alle Administratoren</span></h3>
|
||||
<textarea name="notes" rows="5"><?= e($v('notes', 'notes')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="sla" style="padding:16px 20px;display:none">
|
||||
<p class="muted" style="margin-top:0">Zeiten in Stunden ab Ticketerstellung bzw. Prioritätsänderung. Sie gelten kalendarisch (rund um die Uhr) – <strong>sobald unter <a href="/staff/business_hours.php">Geschäftszeiten</a> „Geschäftszeiten für SLA-Berechnung verwenden“ aktiv ist, zählen nur die dort eingetragenen Öffnungszeiten</strong>.</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:520px">
|
||||
<div><label for="response_hours">Reaktionszeit (Std) *</label>
|
||||
<input type="number" min="1" id="response_hours" name="response_hours" value="<?= e((string) $v('response_hours', 'response_hours', '24')) ?>" required></div>
|
||||
<div><label for="resolution_hours">Lösungszeit (Std) *</label>
|
||||
<input type="number" min="1" id="resolution_hours" name="resolution_hours" value="<?= e((string) $v('resolution_hours', 'resolution_hours', '72')) ?>" required></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editPrio ? 'Änderungen speichern' : 'Erstellen' ?></button>
|
||||
<a href="/staff/priorities.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php ui_tab_assets('prio'); ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">Prioritäten & SLA-Zeiten</h1>
|
||||
<a href="/staff/priorities.php?new=1" class="btn">Neue Priorität hinzufügen</a>
|
||||
</div>
|
||||
<p class="muted">Reaktions- und Lösungszeit ab Ticketerstellung bzw. Prioritätsänderung. Mit aktivierten <a href="/staff/business_hours.php">Geschäftszeiten</a> zählen nur die dort eingetragenen Öffnungszeiten.</p>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Priorität</th><th>Reaktion</th><th>Lösung</th><th style="text-align:center">Tickets</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/priorities.php?edit=<?= (int) $p['id'] ?>"><span class="badge" style="background:<?= e($p['color']) ?>"><?= e($p['name']) ?></span></a></td>
|
||||
<td><?= e($hours((int) $p['response_hours'])) ?></td>
|
||||
<td><?= e($hours((int) $p['resolution_hours'])) ?></td>
|
||||
<td style="text-align:center"><?= (int) $p['ticket_count'] ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($p['created_at'] ?? null)) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($p['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/priorities.php?edit=<?= (int) $p['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Priorität wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $p['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,509 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/totp.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$tab = $_GET['tab'] ?? 'account';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
// ── Konto-Daten ─────────────────────────────────────────────
|
||||
if ($action === 'save_account') {
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$phone = trim($_POST['phone'] ?? '');
|
||||
|
||||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige E-Mail-Adresse angeben.';
|
||||
|
||||
// E-Mail-Duplikat prüfen (andere Mitarbeiter)
|
||||
if (!$errors) {
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE email = ? AND id != ?');
|
||||
$dup->execute([$email, $staff['id']]);
|
||||
if ((int)$dup->fetchColumn()) $errors[] = 'Diese E-Mail-Adresse wird bereits von einem anderen Mitarbeiter verwendet.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
$pdo->prepare('UPDATE staff SET name=?, email=?, phone=? WHERE id=?')
|
||||
->execute([$name, $email, $phone ?: null, $staff['id']]);
|
||||
audit_log($staff['id'], 'Profil aktualisiert: Name/E-Mail/Telefon');
|
||||
flash('success', 'Kontodaten gespeichert.');
|
||||
redirect('/staff/profile.php?tab=account');
|
||||
}
|
||||
$tab = 'account';
|
||||
}
|
||||
|
||||
// ── Passwort ─────────────────────────────────────────────────
|
||||
if ($action === 'save_password') {
|
||||
$current = $_POST['current_password'] ?? '';
|
||||
$new = $_POST['new_password'] ?? '';
|
||||
$confirm = $_POST['confirm_password'] ?? '';
|
||||
|
||||
if (!password_verify($current, $staff['password_hash'])) {
|
||||
$errors[] = 'Das aktuelle Passwort ist falsch.';
|
||||
}
|
||||
$pwError = validate_password_strength($new);
|
||||
if ($pwError) {
|
||||
$errors[] = $pwError;
|
||||
}
|
||||
if ($new !== $confirm) {
|
||||
$errors[] = 'Die Passwörter stimmen nicht überein.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
$pdo->prepare('UPDATE staff SET password_hash=? WHERE id=?')
|
||||
->execute([password_hash($new, PASSWORD_DEFAULT), $staff['id']]);
|
||||
audit_log($staff['id'], 'Passwort geändert');
|
||||
flash('success', 'Passwort geändert.');
|
||||
redirect('/staff/profile.php?tab=account');
|
||||
}
|
||||
$tab = 'account';
|
||||
}
|
||||
|
||||
// ── Avatar ────────────────────────────────────────────────────
|
||||
if ($action === 'save_avatar') {
|
||||
if (!empty($_FILES['avatar']['name'])) {
|
||||
$allowed = ['jpg','jpeg','png','gif','webp'];
|
||||
$ext = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowed)) {
|
||||
$errors[] = 'Erlaubte Bildformate: JPG, PNG, GIF, WEBP.';
|
||||
} elseif ($_FILES['avatar']['size'] > 2 * 1024 * 1024) {
|
||||
$errors[] = 'Maximale Dateigröße: 2 MB.';
|
||||
} else {
|
||||
$dir = __DIR__ . '/../assets/avatars/';
|
||||
if (!is_dir($dir)) mkdir($dir, 0755, true);
|
||||
// Alten Avatar löschen
|
||||
if ($staff['avatar_path']) {
|
||||
$old = __DIR__ . '/..' . $staff['avatar_path'];
|
||||
if (file_exists($old)) unlink($old);
|
||||
}
|
||||
$filename = 'avatar_' . $staff['id'] . '_' . time() . '.' . $ext;
|
||||
move_uploaded_file($_FILES['avatar']['tmp_name'], $dir . $filename);
|
||||
$path = '/assets/avatars/' . $filename;
|
||||
$pdo->prepare('UPDATE staff SET avatar_path=? WHERE id=?')->execute([$path, $staff['id']]);
|
||||
flash('success', 'Avatar aktualisiert.');
|
||||
redirect('/staff/profile.php?tab=account');
|
||||
}
|
||||
}
|
||||
$tab = 'account';
|
||||
}
|
||||
|
||||
if ($action === 'delete_avatar') {
|
||||
if ($staff['avatar_path']) {
|
||||
$old = __DIR__ . '/..' . $staff['avatar_path'];
|
||||
if (file_exists($old)) unlink($old);
|
||||
}
|
||||
$pdo->prepare('UPDATE staff SET avatar_path=NULL WHERE id=?')->execute([$staff['id']]);
|
||||
flash('success', 'Avatar entfernt.');
|
||||
redirect('/staff/profile.php?tab=account');
|
||||
}
|
||||
|
||||
// ── Urlaub/Abwesenheit ─────────────────────────────────────
|
||||
if ($action === 'save_settings') {
|
||||
$onVacation = !empty($_POST['is_on_vacation']) ? 1 : 0;
|
||||
$substituteId = ($_POST['vacation_substitute_id'] ?? '') !== '' ? (int)$_POST['vacation_substitute_id'] : null;
|
||||
$pdo->prepare('UPDATE staff SET is_on_vacation=?, vacation_substitute_id=? WHERE id=?')
|
||||
->execute([$onVacation, $substituteId, $staff['id']]);
|
||||
audit_log($staff['id'], $onVacation ? 'Abwesenheit aktiviert' : 'Abwesenheit deaktiviert');
|
||||
flash('success', 'Einstellungen gespeichert.');
|
||||
redirect('/staff/profile.php?tab=settings');
|
||||
}
|
||||
|
||||
// ── 2FA aktivieren ─────────────────────────────────────────────
|
||||
if ($action === 'enable_2fa') {
|
||||
$code = trim($_POST['totp_code'] ?? '');
|
||||
$secret = $_SESSION['totp_setup_secret'] ?? '';
|
||||
if (!$secret) {
|
||||
flash('error', 'Sitzungsfehler. Bitte nochmals versuchen.');
|
||||
} elseif (!totp_verify($secret, $code)) {
|
||||
flash('error', 'Code ungültig. Bitte erneut versuchen.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE staff SET totp_secret=?, totp_enabled=1 WHERE id=?')->execute([$secret, $staff['id']]);
|
||||
unset($_SESSION['totp_setup_secret']);
|
||||
audit_log($staff['id'], 'Zwei-Faktor-Authentifizierung aktiviert');
|
||||
flash('success', '2FA wurde erfolgreich aktiviert.');
|
||||
}
|
||||
redirect('/staff/profile.php?tab=security');
|
||||
}
|
||||
|
||||
// ── 2FA deaktivieren ───────────────────────────────────────────
|
||||
if ($action === 'disable_2fa') {
|
||||
$pass = $_POST['confirm_password'] ?? '';
|
||||
$me = $pdo->prepare('SELECT password_hash FROM staff WHERE id=?');
|
||||
$me->execute([$staff['id']]);
|
||||
$me = $me->fetch();
|
||||
if (!password_verify($pass, $me['password_hash'])) {
|
||||
flash('error', 'Falsches Passwort.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE staff SET totp_secret=NULL, totp_enabled=0 WHERE id=?')->execute([$staff['id']]);
|
||||
audit_log($staff['id'], 'Zwei-Faktor-Authentifizierung deaktiviert');
|
||||
flash('success', '2FA wurde deaktiviert.');
|
||||
}
|
||||
redirect('/staff/profile.php?tab=security');
|
||||
}
|
||||
|
||||
// ── Signatur ──────────────────────────────────────────────────
|
||||
if ($action === 'save_signature') {
|
||||
$sig = sanitize_staff_html(trim($_POST['signature'] ?? ''));
|
||||
$pdo->prepare('UPDATE staff SET signature=? WHERE id=?')->execute([$sig ?: null, $staff['id']]);
|
||||
flash('success', 'Signatur gespeichert.');
|
||||
redirect('/staff/profile.php?tab=signature');
|
||||
}
|
||||
}
|
||||
|
||||
// Aktuellen Stand neu laden (nach Redirect relevant wenn $_SESSION cached)
|
||||
$staffRow = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||||
$staffRow->execute([$staff['id']]);
|
||||
$me = $staffRow->fetch();
|
||||
|
||||
$pageTitle = 'Mein Profil';
|
||||
$activePage = 'profile';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div style="max-width:740px">
|
||||
|
||||
<h1>Mein Profil</h1>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Tab-Navigation -->
|
||||
<div style="display:flex; border-bottom:2px solid var(--border); margin-bottom:24px; gap:0">
|
||||
<?php foreach (['account' => '👤 Konto', 'settings' => '⚙ Einstellungen', 'signature' => '✍ Signatur', 'security' => '🔒 Sicherheit'] as $t => $label): ?>
|
||||
<a href="/staff/profile.php?tab=<?= $t ?>"
|
||||
style="padding:10px 22px; font-size:.9rem; font-weight:600; text-decoration:none; border-bottom:3px solid <?= $tab===$t ? 'var(--primary)' : 'transparent' ?>; margin-bottom:-2px; color:<?= $tab===$t ? 'var(--primary)' : 'var(--muted)' ?>">
|
||||
<?= $label ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- ══ KONTO ══════════════════════════════════════════════════ -->
|
||||
<?php if ($tab === 'account'): ?>
|
||||
|
||||
<div class="card">
|
||||
<h3>Profilbild</h3>
|
||||
<div style="display:flex; gap:20px; align-items:flex-start">
|
||||
<div style="flex-shrink:0">
|
||||
<?php if ($me['avatar_path']): ?>
|
||||
<img src="<?= e($me['avatar_path']) ?>" alt="Avatar"
|
||||
style="width:80px; height:80px; border-radius:50%; object-fit:cover; border:2px solid var(--border)">
|
||||
<?php else: ?>
|
||||
<div style="width:80px; height:80px; border-radius:50%; background:var(--primary-lt); display:flex; align-items:center; justify-content:center; font-size:2rem; border:2px solid var(--border)">
|
||||
<?= mb_strtoupper(mb_substr($me['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_avatar">
|
||||
<label for="avatar">Neues Bild hochladen (JPG, PNG, WEBP · max. 2 MB)</label>
|
||||
<div style="display:flex; gap:8px; align-items:center; margin-top:6px">
|
||||
<input type="file" id="avatar" name="avatar" accept="image/*" style="flex:1">
|
||||
<button type="submit" class="btn secondary" style="white-space:nowrap">Hochladen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php if ($me['avatar_path']): ?>
|
||||
<form method="post" style="margin-top:8px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete_avatar">
|
||||
<button type="submit" style="background:none;border:none;color:var(--danger);font-size:.82rem;cursor:pointer;padding:0">Bild entfernen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Kontodaten</h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_account">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($me['name']) ?>" required>
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" value="<?= e($me['email']) ?>" required>
|
||||
<label for="phone">Telefonnummer (optional)</label>
|
||||
<input type="text" id="phone" name="phone" value="<?= e($me['phone'] ?? '') ?>" placeholder="z.B. +49 89 123456">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Passwort ändern</h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_password">
|
||||
<label for="current_password">Aktuelles Passwort</label>
|
||||
<input type="password" id="current_password" name="current_password" required autocomplete="current-password">
|
||||
<div class="field-row">
|
||||
<div>
|
||||
<label for="new_password">Neues Passwort</label>
|
||||
<input type="password" id="new_password" name="new_password" required autocomplete="new-password" minlength="10" placeholder="mind. 10 Zeichen, Groß-/Klein, Ziffer, Sonderzeichen">
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm_password">Passwort wiederholen</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Passwort ändern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Push-Benachrichtigungen -->
|
||||
<?php
|
||||
$pushVapidKey = '';
|
||||
if (file_exists(__DIR__ . '/../includes/push.php')) {
|
||||
require_once __DIR__ . '/../includes/push.php';
|
||||
$__keys = get_or_create_vapid_keys();
|
||||
$pushVapidKey = $__keys['public_key'] ?? '';
|
||||
}
|
||||
?>
|
||||
<div class="card">
|
||||
<h3>Browser-Push-Benachrichtigungen</h3>
|
||||
<?php if ($pushVapidKey): ?>
|
||||
<p class="muted" style="font-size:.84rem;margin-bottom:16px">
|
||||
Erhalte Desktop-Benachrichtigungen bei neuen Tickets und Antworten — auch wenn der Browser-Tab nicht aktiv ist.
|
||||
Funktioniert in Chrome, Firefox und Edge. Erfordert HTTPS.
|
||||
</p>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px">
|
||||
<span id="push-status-icon" style="font-size:1.4rem">🔕</span>
|
||||
<div>
|
||||
<div id="push-status-text" style="font-size:.85rem;font-weight:500">Deaktiviert</div>
|
||||
<div id="push-status-sub" style="font-size:.76rem;color:var(--muted)">Klicken Sie auf Aktivieren um Benachrichtigungen zu erhalten</div>
|
||||
</div>
|
||||
<button type="button" id="push-toggle-btn" onclick="togglePushNotifications()"
|
||||
class="btn secondary" style="margin-left:auto">
|
||||
Aktivieren
|
||||
</button>
|
||||
</div>
|
||||
<div style="padding-top:14px;border-top:1px solid var(--border-soft)">
|
||||
<div style="font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:8px">Benachrichtigungen erhalten bei:</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;font-size:.83rem;color:var(--ink-soft)">
|
||||
<div>✅ Neues Ticket in meiner Abteilung</div>
|
||||
<div>✅ Neue Antwort auf mir zugewiesenes Ticket</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="push-vapid-key" value="<?= e($pushVapidKey) ?>">
|
||||
<?php else: ?>
|
||||
<p class="muted" style="font-size:.84rem">
|
||||
Push-Benachrichtigungen sind auf diesem Server nicht verfügbar (OpenSSL-Erweiterung fehlt oder PHP zu alt).
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- ══ EINSTELLUNGEN ══════════════════════════════════════════ -->
|
||||
<?php elseif ($tab === 'settings'): ?>
|
||||
|
||||
<div class="card">
|
||||
<h3>Abwesenheit / Urlaub</h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_settings">
|
||||
<?php
|
||||
$allAgents = get_db()->query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
|
||||
?>
|
||||
<label style="display:flex; align-items:flex-start; gap:12px; cursor:pointer; font-weight:400">
|
||||
<input type="checkbox" name="is_on_vacation" value="1" style="width:auto; margin-top:3px; flex-shrink:0" <?= $me['is_on_vacation'] ? 'checked' : '' ?>>
|
||||
<span>
|
||||
<strong>Urlaubsmodus aktiv</strong><br>
|
||||
<span class="muted" style="font-size:.84rem">Neue Tickets werden nicht automatisch diesem Konto zugewiesen, während der Urlaubsmodus aktiv ist. Bereits zugewiesene Tickets bleiben unverändert.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label style="margin-top:14px">Vertretung (optional)</label>
|
||||
<select name="vacation_substitute_id">
|
||||
<option value="">– keine Vertretung –</option>
|
||||
<?php foreach ($allAgents as $a): if ($a['id'] == $me['id']) continue; ?>
|
||||
<option value="<?= $a['id'] ?>" <?= ($me['vacation_substitute_id'] ?? '') == $a['id'] ? 'selected' : '' ?>><?= e($a['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="muted" style="font-size:.8rem">Neue Tickets werden während Ihrer Abwesenheit automatisch dieser Person zugewiesen.</p>
|
||||
<?php if ($me['is_on_vacation']): ?>
|
||||
<div class="flash info" style="margin-top:8px">Urlaubsmodus ist aktuell <strong>aktiv</strong>. Sie erhalten keine automatischen Ticket-Zuweisungen.</div>
|
||||
<?php endif; ?>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Übersicht</h3>
|
||||
<div style="display:flex; flex-direction:column; gap:12px; font-size:.9rem">
|
||||
<div style="display:flex; gap:16px">
|
||||
<span style="color:var(--muted); width:130px; flex-shrink:0">Rolle</span>
|
||||
<span><?= $me['role'] === 'admin' ? '<span class="badge" style="background:var(--accent)">Administrator</span>' : '<span class="badge" style="background:var(--primary)">Mitarbeiter</span>' ?></span>
|
||||
</div>
|
||||
<div style="display:flex; gap:16px">
|
||||
<span style="color:var(--muted); width:130px; flex-shrink:0">Mitglied seit</span>
|
||||
<span><?= format_dt($me['created_at']) ?></span>
|
||||
</div>
|
||||
<?php
|
||||
$ticketCount = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ?');
|
||||
$ticketCount->execute([$me['id']]);
|
||||
$myCount = $ticketCount->fetchColumn();
|
||||
?>
|
||||
<div style="display:flex; gap:16px">
|
||||
<span style="color:var(--muted); width:130px; flex-shrink:0">Zugewiesene Tickets</span>
|
||||
<span><a href="/staff/tickets.php?assigned=me"><?= (int)$myCount ?></a></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SIGNATUR ════════════════════════════════════════════════ -->
|
||||
<?php elseif ($tab === 'signature'): ?>
|
||||
|
||||
<div class="card">
|
||||
<h3>E-Mail-Signatur</h3>
|
||||
<p class="muted" style="margin-bottom:14px; font-size:.875rem">
|
||||
Wird automatisch an Ihre ausgehenden Ticket-Antworten angehängt.
|
||||
Ist keine Mitarbeiter-Signatur gesetzt, greift die Abteilungs-Signatur.
|
||||
</p>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_signature">
|
||||
<div class="richtext-wrap">
|
||||
<div class="richtext-toolbar" id="sig-toolbar">
|
||||
<button type="button" onclick="document.execCommand('bold')" title="Fett"><strong>B</strong></button>
|
||||
<button type="button" onclick="document.execCommand('italic')" title="Kursiv"><em>I</em></button>
|
||||
<button type="button" onclick="document.execCommand('underline')" title="Unterstrichen"><u>U</u></button>
|
||||
<button type="button" onclick="document.execCommand('insertUnorderedList')" title="Liste">•</button>
|
||||
<button type="button" onclick="insertLink()" title="Link">🔗</button>
|
||||
</div>
|
||||
<div class="richtext-editable" id="sig-editor" contenteditable="true" style="min-height:180px"><?= $me['signature'] ?? '' ?></div>
|
||||
</div>
|
||||
<input type="hidden" name="signature" id="sig-hidden">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" onclick="document.getElementById('sig-hidden').value=document.getElementById('sig-editor').innerHTML">Speichern</button>
|
||||
<button type="button" class="btn secondary" onclick="document.getElementById('sig-editor').innerHTML='';document.getElementById('sig-hidden').value=''">Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php if ($me['signature']): ?>
|
||||
<div style="margin-top:20px; padding-top:16px; border-top:1px solid var(--border-soft)">
|
||||
<p class="muted" style="font-size:.78rem; margin-bottom:8px">Vorschau:</p>
|
||||
<div style="border:1px solid var(--border); border-radius:8px; padding:14px 16px; font-size:.9rem; line-height:1.65">
|
||||
<?= $me['signature'] ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function insertLink() {
|
||||
var url = prompt('URL eingeben:', 'https://');
|
||||
if (url) document.execCommand('createLink', false, url);
|
||||
}
|
||||
document.querySelector('form').addEventListener('submit', function() {
|
||||
document.getElementById('sig-hidden').value = document.getElementById('sig-editor').innerHTML;
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php elseif ($tab === 'security'): ?>
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/totp.php';
|
||||
$me2 = get_db()->prepare('SELECT totp_enabled, totp_secret, email FROM staff WHERE id=?');
|
||||
$me2->execute([$staff['id']]);
|
||||
$me2 = $me2->fetch();
|
||||
if (!$me2['totp_enabled'] && empty($_SESSION['totp_setup_secret'])) {
|
||||
$_SESSION['totp_setup_secret'] = totp_generate_secret();
|
||||
}
|
||||
$setupSecret = $_SESSION['totp_setup_secret'] ?? '';
|
||||
?>
|
||||
<div class="card">
|
||||
<h3>🔒 Zwei-Faktor-Authentifizierung (2FA)</h3>
|
||||
<?php if ($me2['totp_enabled']): ?>
|
||||
<div class="flash info">2FA ist aktuell <strong>aktiv</strong>.</div>
|
||||
<p class="muted" style="margin-top:14px">Um 2FA zu deaktivieren, bestätigen Sie mit Ihrem Passwort:</p>
|
||||
<form method="post" style="margin-top:10px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="disable_2fa">
|
||||
<input type="password" name="confirm_password" placeholder="Aktuelles Passwort" required>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn secondary" style="border-color:var(--danger);color:var(--danger)" onclick="return confirm('2FA wirklich deaktivieren?')">2FA deaktivieren</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p class="muted">Schützen Sie Ihr Konto mit einem zeitbasierten Einmalcode (TOTP – kompatibel mit Google Authenticator, Authy, etc.).</p>
|
||||
<ol style="margin:16px 0; padding-left:20px; line-height:2">
|
||||
<li>Installieren Sie <strong>Google Authenticator</strong> oder <strong>Authy</strong></li>
|
||||
<li>Scannen Sie den QR-Code oder geben Sie den Schlüssel manuell ein</li>
|
||||
<li>Tragen Sie den 6-stelligen Code ein und klicken Sie „2FA aktivieren"</li>
|
||||
</ol>
|
||||
<div style="display:flex; gap:24px; align-items:flex-start; flex-wrap:wrap; margin:20px 0">
|
||||
<img src="<?= e(totp_qr_url($setupSecret, $me2['email'])) ?>" alt="QR-Code" style="width:160px;height:160px;border:4px solid var(--border);border-radius:8px">
|
||||
<div>
|
||||
<div style="font-size:.78rem; color:var(--muted); margin-bottom:6px">Manueller Schlüssel:</div>
|
||||
<code style="font-size:.95rem; letter-spacing:.1em; background:var(--bg-soft); padding:8px 12px; border-radius:6px; display:block"><?= e(chunk_split($setupSecret, 4, ' ')) ?></code>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="enable_2fa">
|
||||
<label for="totp_code">Code aus der App</label>
|
||||
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" pattern="\d{6}" maxlength="6" required placeholder="000000" style="letter-spacing:.2em;font-size:1.2rem;max-width:160px">
|
||||
<div class="btn-row"><button type="submit" class="btn">2FA aktivieren</button></div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Push-Status auf Profilseite prüfen und Button aktualisieren
|
||||
(function() {
|
||||
var keyInput = document.getElementById('push-vapid-key');
|
||||
var btn = document.getElementById('push-toggle-btn');
|
||||
var statusText = document.getElementById('push-status-text');
|
||||
var statusSub = document.getElementById('push-status-sub');
|
||||
var statusIcon = document.getElementById('push-status-icon');
|
||||
|
||||
if (!keyInput) return; // Push-Karte nicht sichtbar
|
||||
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
statusText.textContent = 'Nicht unterstützt';
|
||||
statusSub.textContent = 'Ihr Browser unterstützt keine Push-Benachrichtigungen.';
|
||||
statusIcon.textContent = '⚠️';
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
function setActive(active) {
|
||||
if (active) {
|
||||
statusIcon.textContent = '🔔';
|
||||
statusText.textContent = 'Aktiv';
|
||||
statusText.style.color = '#22c55e';
|
||||
statusSub.textContent = 'Sie erhalten Desktop-Benachrichtigungen';
|
||||
btn.textContent = 'Deaktivieren';
|
||||
btn.style.borderColor = '#ef4444';
|
||||
btn.style.color = '#ef4444';
|
||||
} else {
|
||||
statusIcon.textContent = '🔕';
|
||||
statusText.textContent = 'Deaktiviert';
|
||||
statusText.style.color = '';
|
||||
statusSub.textContent = 'Klicken Sie auf Aktivieren um Benachrichtigungen zu erhalten';
|
||||
btn.textContent = 'Aktivieren';
|
||||
btn.style.borderColor = '';
|
||||
btn.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
navigator.serviceWorker.ready.then(function(reg) {
|
||||
reg.pushManager.getSubscription().then(function(sub) {
|
||||
setActive(!!(sub && Notification.permission === 'granted'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
/**
|
||||
* staff/push_subscribe.php
|
||||
* Speichert oder löscht eine Push-Subscription für den eingeloggten Mitarbeiter.
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405); echo json_encode(['error' => 'POST only']); exit;
|
||||
}
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $body['action'] ?? 'subscribe';
|
||||
|
||||
if ($action === 'unsubscribe') {
|
||||
$endpoint = $body['endpoint'] ?? '';
|
||||
if ($endpoint) {
|
||||
$pdo->prepare('DELETE FROM push_subscriptions WHERE staff_id = ? AND endpoint = ?')
|
||||
->execute([$staff['id'], $endpoint]);
|
||||
}
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Subscribe
|
||||
$endpoint = $body['endpoint'] ?? '';
|
||||
$p256dh = $body['keys']['p256dh'] ?? '';
|
||||
$auth = $body['keys']['auth'] ?? '';
|
||||
|
||||
if (!$endpoint || !$p256dh || !$auth) {
|
||||
http_response_code(400); echo json_encode(['error' => 'Fehlende Felder']); exit;
|
||||
}
|
||||
|
||||
// Duplicate check
|
||||
$chk = $pdo->prepare('SELECT id FROM push_subscriptions WHERE endpoint = ?');
|
||||
$chk->execute([$endpoint]);
|
||||
if (!$chk->fetchColumn()) {
|
||||
$pdo->prepare(
|
||||
'INSERT INTO push_subscriptions (staff_id, endpoint, p256dh, auth, created_at) VALUES (?,?,?,?,?)'
|
||||
)->execute([$staff['id'], $endpoint, $p256dh, $auth, db_now()]);
|
||||
}
|
||||
|
||||
echo json_encode(['ok' => true]);
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'rename') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
if ($id && $name) {
|
||||
$pdo->prepare('UPDATE saved_queues SET name=?, updated_at=? WHERE id=? AND (staff_id=? OR ?=1)')
|
||||
->execute([$name, db_now(), $id, $staff['id'], $staff['role'] === 'admin' ? 1 : 0]);
|
||||
flash('success', 'Queue umbenannt.');
|
||||
}
|
||||
redirect('/staff/queues.php');
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id) {
|
||||
$pdo->prepare('DELETE FROM saved_queues WHERE id=? AND (staff_id=? OR ?=1)')
|
||||
->execute([$id, $staff['id'], $staff['role'] === 'admin' ? 1 : 0]);
|
||||
flash('success', 'Queue gelöscht.');
|
||||
}
|
||||
redirect('/staff/queues.php');
|
||||
}
|
||||
|
||||
if ($action === 'reorder') {
|
||||
$ids = array_map('intval', $_POST['ids'] ?? []);
|
||||
foreach ($ids as $i => $id) {
|
||||
$pdo->prepare('UPDATE saved_queues SET sort_order=? WHERE id=? AND staff_id=?')
|
||||
->execute([$i, $id, $staff['id']]);
|
||||
}
|
||||
echo json_encode(['ok' => true]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$queues = queues_for($pdo, (int) $staff['id']);
|
||||
|
||||
$pageTitle = 'Gespeicherte Ansichten';
|
||||
$activePage = 'tickets';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>Gespeicherte Ansichten</h1>
|
||||
<a href="/staff/tickets.php" class="btn secondary">← Zur Ticket-Liste</a>
|
||||
</div>
|
||||
|
||||
<?php if (!$queues): ?>
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<p>Noch keine gespeicherten Ansichten vorhanden.</p>
|
||||
<p class="muted" style="font-size:.85rem">Filter in der <a href="/staff/tickets.php">Ticket-Liste</a> setzen und auf „Ansicht speichern" klicken.</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card">
|
||||
<h3>Meine Ansichten</h3>
|
||||
<p class="muted" style="font-size:.85rem; margin-bottom:16px">Reihenfolge per Drag & Drop ändern. Klick auf den Namen öffnet die Ansicht.</p>
|
||||
<ul id="queue-list" style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px">
|
||||
<?php foreach ($queues as $q):
|
||||
$filters = $q['filters'];
|
||||
$href = '/staff/tickets.php?' . http_build_query(queue_filters_to_web($filters));
|
||||
$summary = [];
|
||||
if (!empty($filters['status'])) $summary[] = 'Status: ' . $filters['status'];
|
||||
if (!empty($filters['assigned'])) $summary[] = 'Zugewiesen: ' . ($filters['assigned'] === 'me' ? 'mir' : ($filters['assigned'] === 'unassigned' ? 'niemand' : 'ID ' . $filters['assigned']));
|
||||
if (!empty($filters['departmentId'])) $summary[] = 'Abteilung: ' . $filters['departmentId'];
|
||||
if (!empty($filters['priorityId'])) $summary[] = 'Priorität: ' . $filters['priorityId'];
|
||||
if (!empty($filters['sla'])) $summary[] = 'SLA: ' . $filters['sla'];
|
||||
if (!empty($filters['q'])) $summary[] = 'Suche: "' . $filters['q'] . '"';
|
||||
?>
|
||||
<li data-id="<?= $q['id'] ?>"
|
||||
style="background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:14px 16px; display:flex; align-items:center; gap:12px; cursor:grab">
|
||||
<span style="color:var(--muted); font-size:1rem; cursor:grab">⠿</span>
|
||||
<div style="flex:1; min-width:0">
|
||||
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px">
|
||||
<a href="<?= e($href) ?>" style="font-weight:600; font-size:.92rem; color:var(--ink)"><?= e($q['name']) ?></a>
|
||||
<?php if ($q['is_shared']): ?><span class="muted" style="font-size:.75rem">⇄ geteilt<?= $q['mine'] ? '' : ' von ' . e($q['owner_name'] ?? '') ?></span><?php endif; ?>
|
||||
</div>
|
||||
<?php if ($summary): ?>
|
||||
<div class="muted" style="font-size:.78rem"><?= e(implode(' · ', $summary)) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="muted" style="font-size:.78rem">Alle Tickets</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (queue_can_edit($q, $staff)): ?>
|
||||
<div style="display:flex; gap:6px; flex-shrink:0">
|
||||
<!-- Umbenennen -->
|
||||
<button onclick="renameQueue(<?= $q['id'] ?>, '<?= e(addslashes($q['name'])) ?>')"
|
||||
class="btn secondary" style="padding:5px 10px; font-size:.78rem">✏️ Umbenennen</button>
|
||||
<!-- Löschen -->
|
||||
<form method="post" onsubmit="return confirm('Ansicht löschen?')" style="margin:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $q['id'] ?>">
|
||||
<button type="submit" class="btn secondary"
|
||||
style="padding:5px 10px; font-size:.78rem; border-color:var(--danger); color:var(--danger)">🗑️</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Umbenennen Modal -->
|
||||
<div id="rename-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:9999; align-items:center; justify-content:center">
|
||||
<div style="background:#fff; border-radius:12px; padding:24px; width:100%; max-width:400px; box-shadow:0 8px 32px rgba(0,0,0,.2)">
|
||||
<h3 style="margin:0 0 16px">Ansicht umbenennen</h3>
|
||||
<form method="post" id="rename-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="rename">
|
||||
<input type="hidden" name="id" id="rename-id">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" id="rename-name" required style="margin-bottom:16px">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<button type="button" class="btn secondary" onclick="document.getElementById('rename-modal').style.display='none'">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function renameQueue(id, currentName) {
|
||||
document.getElementById('rename-id').value = id;
|
||||
document.getElementById('rename-name').value = currentName;
|
||||
document.getElementById('rename-modal').style.display = 'flex';
|
||||
setTimeout(() => document.getElementById('rename-name').focus(), 50);
|
||||
}
|
||||
|
||||
// Drag & Drop Reihenfolge
|
||||
(function() {
|
||||
var list = document.getElementById('queue-list');
|
||||
if (!list) return;
|
||||
var dragging = null;
|
||||
|
||||
list.querySelectorAll('li').forEach(function(li) {
|
||||
li.addEventListener('dragstart', function(e) {
|
||||
dragging = li;
|
||||
li.style.opacity = '.4';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
li.addEventListener('dragend', function() {
|
||||
li.style.opacity = '1';
|
||||
dragging = null;
|
||||
saveOrder();
|
||||
});
|
||||
li.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
if (dragging && dragging !== li) {
|
||||
var rect = li.getBoundingClientRect();
|
||||
var mid = rect.top + rect.height / 2;
|
||||
if (e.clientY < mid) {
|
||||
list.insertBefore(dragging, li);
|
||||
} else {
|
||||
list.insertBefore(dragging, li.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
li.setAttribute('draggable', 'true');
|
||||
});
|
||||
|
||||
function saveOrder() {
|
||||
var ids = Array.from(list.querySelectorAll('li')).map(li => li.dataset.id);
|
||||
var fd = new FormData();
|
||||
fd.append('action', 'reorder');
|
||||
fd.append('csrf_token', document.querySelector('[name=csrf_token]').value);
|
||||
ids.forEach(id => fd.append('ids[]', id));
|
||||
fetch('/staff/queues.php', { method: 'POST', body: fd });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
$departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM recurring_tickets')->fetchAll(PDO::FETCH_COLUMN)));
|
||||
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
|
||||
$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$subject = trim($_POST['subject'] ?? '');
|
||||
$message = trim($_POST['message'] ?? '');
|
||||
$deptId = (int) ($_POST['department_id'] ?? 0);
|
||||
$prioId = (int) ($_POST['priority_id'] ?? 0);
|
||||
$assignTo = ($_POST['assigned_staff_id'] ?? '') !== '' ? (int)$_POST['assigned_staff_id'] : null;
|
||||
$days = max(1, (int) ($_POST['interval_days'] ?? 30));
|
||||
$nextRun = trim($_POST['next_run'] ?? date('Y-m-d'));
|
||||
$isActive = !empty($_POST['is_active']) ? 1 : 0;
|
||||
|
||||
if ($subject === '') $errors[] = 'Bitte einen Betreff angeben.';
|
||||
if ($message === '') $errors[] = 'Bitte eine Nachricht eingeben.';
|
||||
if (!$deptId) $errors[] = 'Bitte eine Abteilung wählen.';
|
||||
if (!$prioId) $errors[] = 'Bitte eine Priorität wählen.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO recurring_tickets (subject,message,department_id,priority_id,assigned_staff_id,interval_days,next_run,is_active,created_by,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$subject, $message, $deptId, $prioId, $assignTo, $days, $nextRun, $isActive, $staff['id'], db_now()]);
|
||||
audit_log($staff['id'], "Wiederkehrendes Ticket erstellt: {$subject}");
|
||||
flash('success', 'Wiederkehrendes Ticket erstellt.');
|
||||
} else {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('UPDATE recurring_tickets SET subject=?,message=?,department_id=?,priority_id=?,assigned_staff_id=?,interval_days=?,next_run=?,is_active=? WHERE id=?')
|
||||
->execute([$subject, $message, $deptId, $prioId, $assignTo, $days, $nextRun, $isActive, $id]);
|
||||
audit_log($staff['id'], "Wiederkehrendes Ticket bearbeitet: {$subject}");
|
||||
flash('success', 'Gespeichert.');
|
||||
}
|
||||
redirect('/staff/recurring_tickets.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('DELETE FROM recurring_tickets WHERE id=?')->execute([$id]);
|
||||
audit_log($staff['id'], 'Wiederkehrendes Ticket gelöscht');
|
||||
flash('success', 'Gelöscht.');
|
||||
redirect('/staff/recurring_tickets.php');
|
||||
}
|
||||
|
||||
if ($action === 'toggle') {
|
||||
$id = (int) $_POST['id'];
|
||||
$pdo->prepare('UPDATE recurring_tickets SET is_active = 1 - is_active WHERE id=?')->execute([$id]);
|
||||
redirect('/staff/recurring_tickets.php');
|
||||
}
|
||||
|
||||
if ($action === 'run_now') {
|
||||
$id = (int) $_POST['id'];
|
||||
$rt = $pdo->prepare('SELECT * FROM recurring_tickets WHERE id=?');
|
||||
$rt->execute([$id]); $rt = $rt->fetch();
|
||||
if ($rt) {
|
||||
$tn = generate_ticket_number();
|
||||
$now = db_now();
|
||||
$nextRun = (new DateTimeImmutable('today'))
|
||||
->modify('+' . (int) $rt['interval_days'] . ' days')
|
||||
->format('Y-m-d');
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO tickets (ticket_number,subject,requester_name,requester_email,department_id,priority_id,assigned_staff_id,status,source,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,\'open\',\'staff\',?,?)'
|
||||
)->execute([$tn, $rt['subject'], 'System', 'system@recurring', $rt['department_id'], $rt['priority_id'], $rt['assigned_staff_id'], $now, $now]);
|
||||
$ticketId = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare('INSERT INTO ticket_threads (ticket_id,author_type,staff_id,message,is_internal_note,created_at) VALUES (?,\'staff\',?,?,0,?)')
|
||||
->execute([$ticketId, $staff['id'], $rt['message'], $now]);
|
||||
$pdo->prepare('UPDATE recurring_tickets SET next_run = ? WHERE id = ?')
|
||||
->execute([$nextRun, $id]);
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
flash('success', "Ticket #{$tn} sofort erstellt.");
|
||||
}
|
||||
redirect('/staff/recurring_tickets.php');
|
||||
}
|
||||
}
|
||||
|
||||
$items = $pdo->query(
|
||||
'SELECT rt.*, d.name AS dept_name, p.name AS prio_name, s.name AS agent_name
|
||||
FROM recurring_tickets rt
|
||||
JOIN departments d ON d.id=rt.department_id
|
||||
JOIN priorities p ON p.id=rt.priority_id
|
||||
LEFT JOIN staff s ON s.id=rt.assigned_staff_id
|
||||
ORDER BY rt.next_run ASC'
|
||||
)->fetchAll();
|
||||
|
||||
$editing = null;
|
||||
if (!empty($_GET['edit'])) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM recurring_tickets WHERE id=?');
|
||||
$stmt->execute([(int)$_GET['edit']]);
|
||||
$editing = $stmt->fetch();
|
||||
}
|
||||
|
||||
$pageTitle = 'Wiederkehrende Tickets';
|
||||
$activePage = 'recurring';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>Wiederkehrende Tickets</h1>
|
||||
<div class="btn-row">
|
||||
<a href="?new=1" class="btn">+ Neues Muster</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Tickets die automatisch nach einem festen Intervall neu erstellt werden (z.B. monatliche Wartung). Erstellung per Cronjob: <code>php cron/process_recurring.php</code></p>
|
||||
|
||||
<?php foreach ($errors as $e): ?><div class="flash error"><?= e($e) ?></div><?php endforeach; ?>
|
||||
|
||||
<?php if (!empty($_GET['new']) || $editing): ?>
|
||||
<div class="card">
|
||||
<h3><?= $editing ? 'Muster bearbeiten' : 'Neues Muster' ?></h3>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editing ? 'update' : 'create' ?>">
|
||||
<?php if ($editing): ?><input type="hidden" name="id" value="<?= $editing['id'] ?>"><?php endif; ?>
|
||||
|
||||
<label>Betreff</label>
|
||||
<input type="text" name="subject" value="<?= e($editing['subject'] ?? '') ?>" required>
|
||||
|
||||
<label>Nachricht (erster Thread-Eintrag)</label>
|
||||
<textarea name="message" rows="5" required><?= e($editing['message'] ?? '') ?></textarea>
|
||||
|
||||
<div class="field-row">
|
||||
<div>
|
||||
<label>Abteilung</label>
|
||||
<select name="department_id" required>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= ($editing['department_id'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Priorität</label>
|
||||
<select name="priority_id" required>
|
||||
<?php foreach ($priorities as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= ($editing['priority_id'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div>
|
||||
<label>Zugewiesen an (optional)</label>
|
||||
<select name="assigned_staff_id">
|
||||
<option value="">– nicht zugewiesen –</option>
|
||||
<?php foreach ($agents as $a): ?>
|
||||
<option value="<?= $a['id'] ?>" <?= ($editing['assigned_staff_id'] ?? '') == $a['id'] ? 'selected' : '' ?>><?= e($a['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Intervall (Tage)</label>
|
||||
<input type="number" name="interval_days" min="1" value="<?= e($editing['interval_days'] ?? 30) ?>" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Nächste Ausführung</label>
|
||||
<input type="date" name="next_run" value="<?= e($editing['next_run'] ?? date('Y-m-d')) ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:12px">
|
||||
<input type="checkbox" name="is_active" style="width:auto" <?= ($editing['is_active'] ?? 1) ? 'checked' : '' ?>>
|
||||
Aktiv
|
||||
</label>
|
||||
|
||||
<div class="btn-row" style="margin-top:16px">
|
||||
<button type="submit" class="btn">Speichern</button>
|
||||
<a href="/staff/recurring_tickets.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<?php if (!$items): ?>
|
||||
<div class="empty-state">Noch keine Muster angelegt.</div>
|
||||
<?php else: ?>
|
||||
<table>
|
||||
<thead><tr><th>Betreff</th><th>Abteilung</th><th>Intervall</th><th>Nächste Ausführung</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($items as $rt): ?>
|
||||
<tr>
|
||||
<td><strong><?= e($rt['subject']) ?></strong><br><span class="muted" style="font-size:.78rem"><?= e($rt['agent_name'] ?? '–') ?></span></td>
|
||||
<td><?= e($rt['dept_name']) ?></td>
|
||||
<td>Alle <?= $rt['interval_days'] ?> Tage</td>
|
||||
<td class="<?= $rt['next_run'] <= date('Y-m-d') ? 'muted' : '' ?>"><?= e($rt['next_run']) ?></td>
|
||||
<td><span class="badge" style="background:<?= $rt['is_active'] ? '#16a34a' : '#6b7280' ?>"><?= $rt['is_active'] ? 'Aktiv' : 'Pausiert' ?></span></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="?edit=<?= $rt['id'] ?>" class="btn secondary" style="font-size:.78rem; padding:4px 10px">Bearbeiten</a>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="toggle">
|
||||
<input type="hidden" name="id" value="<?= $rt['id'] ?>">
|
||||
<button type="submit" class="btn secondary" style="font-size:.78rem; padding:4px 10px"><?= $rt['is_active'] ? 'Pausieren' : 'Aktivieren' ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="run_now">
|
||||
<input type="hidden" name="id" value="<?= $rt['id'] ?>">
|
||||
<button type="submit" class="btn secondary" style="font-size:.78rem; padding:4px 10px" title="Jetzt sofort ein Ticket erstellen">▶ Jetzt</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= $rt['id'] ?>">
|
||||
<button type="submit" style="background:none;border:1.5px solid var(--danger);color:var(--danger);border-radius:var(--radius);padding:4px 10px;font-size:.78rem;cursor:pointer">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_staff_login();
|
||||
if (!has_permission($staff, 'view_reports')) {
|
||||
flash('error', 'Für Berichte fehlt die erforderliche Berechtigung.');
|
||||
redirect('/staff/dashboard.php');
|
||||
}
|
||||
$pdo = get_db();
|
||||
$scope = staff_department_ids($staff); // null = alle Abteilungen
|
||||
|
||||
$dimensions = report_dimensions($pdo);
|
||||
$metrics = report_metrics($pdo);
|
||||
|
||||
// ── Gespeicherte Berichte ───────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
if ($action === 'save') {
|
||||
$name = trim($_POST['report_name'] ?? '');
|
||||
if ($name === '') {
|
||||
flash('error', 'Bitte einen Namen für den Bericht angeben.');
|
||||
} else {
|
||||
parse_str((string) ($_POST['return_query'] ?? ''), $cfgIn);
|
||||
$cfg = report_clean_config($pdo, $cfgIn);
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), !empty($_POST['shared']) ? 1 : 0, $now, $now]);
|
||||
flash('success', 'Bericht gespeichert.');
|
||||
}
|
||||
redirect('/staff/report_builder.php?' . ltrim((string) ($_POST['return_query'] ?? ''), '?'));
|
||||
}
|
||||
if ($action === 'delete') {
|
||||
$row = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
|
||||
$row->execute([(int) $_POST['id']]);
|
||||
if (($r = $row->fetch()) && ((int) $r['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin')) {
|
||||
$pdo->prepare('DELETE FROM saved_reports WHERE id = ?')->execute([$r['id']]);
|
||||
flash('success', 'Bericht gelöscht.');
|
||||
}
|
||||
redirect('/staff/report_builder.php');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Konfiguration: aus gespeichertem Bericht oder aus der URL ───────────────
|
||||
$saved = $pdo->prepare('SELECT r.*, s.name AS owner FROM saved_reports r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.staff_id = ? OR r.is_shared = 1 ORDER BY r.name');
|
||||
$saved->execute([$staff['id']]);
|
||||
$savedReports = $saved->fetchAll();
|
||||
$cfgIn = $_GET;
|
||||
if (isset($_GET['saved'])) {
|
||||
foreach ($savedReports as $sr) {
|
||||
if ((int) $sr['id'] === (int) $_GET['saved']) { $cfgIn = json_decode($sr['config'], true) ?: []; break; }
|
||||
}
|
||||
}
|
||||
$cfg = report_clean_config($pdo, $cfgIn);
|
||||
$report = report_run($pdo, $cfg, $scope);
|
||||
|
||||
$queryOf = fn(array $c) => http_build_query([
|
||||
'dimension' => $c['dimension'], 'from' => $c['from'], 'to' => $c['to'], 'sort' => $c['sort'], 'desc' => $c['desc'] ? 1 : 0,
|
||||
'metrics' => $c['metrics'], 'filters' => $c['filters'],
|
||||
]);
|
||||
|
||||
if (($_GET['export'] ?? '') === 'csv') {
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="bericht-' . $cfg['dimension'] . '-' . $cfg['from'] . '_' . $cfg['to'] . '.csv"');
|
||||
echo report_to_csv($report);
|
||||
exit;
|
||||
}
|
||||
|
||||
$departments = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
|
||||
$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
|
||||
$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
|
||||
$topics = help_topic_options($pdo, false, false);
|
||||
$fmtValue = function ($v, string $unit) {
|
||||
if ($v === null) return '–';
|
||||
return ($unit === '%' || $unit === 'Std' ? number_format((float) $v, 1, ',', '.') : (string) $v) . ($unit === '%' ? ' %' : ($unit === 'Std' ? ' Std' : ''));
|
||||
};
|
||||
$maxByMetric = [];
|
||||
foreach (array_slice($report['columns'], 1) as $c) $maxByMetric[$c['key']] = max(1, ...array_map(fn($r) => (float) ($r[$c['key']] ?? 0), $report['rows'] ?: [[$c['key'] => 0]]));
|
||||
|
||||
$pageTitle = 'Bericht-Baukasten';
|
||||
$activePage = 'report_builder';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<h1 style="margin:0">Bericht-Baukasten</h1>
|
||||
<div style="display:flex;gap:8px">
|
||||
<a href="/staff/reports.php" class="btn secondary">Standard-Berichte</a>
|
||||
<a href="/staff/report_builder.php?<?= e($queryOf($cfg)) ?>&export=csv" class="btn secondary">CSV exportieren</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Wähle Gruppierung, Kennzahlen, Zeitraum und Filter – nach dem Erstellungsdatum der Tickets. Berichte lassen sich speichern und mit allen teilen.</p>
|
||||
|
||||
<?php if ($savedReports): ?>
|
||||
<div class="card" style="padding:10px 14px;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<strong style="font-size:.8rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em">Gespeichert</strong>
|
||||
<?php foreach ($savedReports as $sr): ?>
|
||||
<span style="display:inline-flex;align-items:center;gap:2px">
|
||||
<a class="btn secondary" style="padding:3px 12px;font-size:.82rem" href="/staff/report_builder.php?saved=<?= (int) $sr['id'] ?>" title="<?= (int) $sr['staff_id'] === (int) $staff['id'] ? 'Mein Bericht' : 'Geteilt von ' . e($sr['owner'] ?? '') ?>"><?= e($sr['name']) ?><?= $sr['is_shared'] ? ' ⇄' : '' ?></a>
|
||||
<?php if ((int) $sr['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin'): ?>
|
||||
<form method="post" style="margin:0" onsubmit="return confirm('Bericht löschen?')"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= (int) $sr['id'] ?>">
|
||||
<button type="submit" title="Löschen" style="background:none;border:none;color:var(--muted);cursor:pointer;padding:0 4px">✕</button></form>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="get" class="card">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;align-items:end">
|
||||
<div><label for="dimension" style="margin-top:0">Gruppieren nach</label>
|
||||
<select id="dimension" name="dimension"><?php foreach ($dimensions as $k => $d): ?><option value="<?= e($k) ?>" <?= $cfg['dimension'] === $k ? 'selected' : '' ?>><?= e($d[0]) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="from" style="margin-top:0">Von</label><input type="date" id="from" name="from" value="<?= e($cfg['from']) ?>"></div>
|
||||
<div><label for="to" style="margin-top:0">Bis</label><input type="date" id="to" name="to" value="<?= e($cfg['to']) ?>"></div>
|
||||
<div><label for="f_dept" style="margin-top:0">Abteilung</label>
|
||||
<select id="f_dept" name="filters[departmentId]"><option value="">Alle</option><?php foreach ($departments as $d): ?><option value="<?= $d['id'] ?>" <?= ($cfg['filters']['departmentId'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="f_prio" style="margin-top:0">Priorität</label>
|
||||
<select id="f_prio" name="filters[priorityId]"><option value="">Alle</option><?php foreach ($priorities as $p): ?><option value="<?= $p['id'] ?>" <?= ($cfg['filters']['priorityId'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="f_agent" style="margin-top:0">Mitarbeiter</label>
|
||||
<select id="f_agent" name="filters[agentId]"><option value="">Alle</option><?php foreach ($agents as $a): ?><option value="<?= $a['id'] ?>" <?= ($cfg['filters']['agentId'] ?? '') == $a['id'] ? 'selected' : '' ?>><?= e($a['name']) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="f_topic" style="margin-top:0">Hilfethema</label>
|
||||
<select id="f_topic" name="filters[helpTopicId]"><option value="">Alle</option><?php foreach ($topics as $t): ?><option value="<?= $t['id'] ?>" <?= ($cfg['filters']['helpTopicId'] ?? '') == $t['id'] ? 'selected' : '' ?>><?= e($t['path']) ?></option><?php endforeach; ?></select></div>
|
||||
<div><label for="f_source" style="margin-top:0">Quelle</label>
|
||||
<select id="f_source" name="filters[source]"><option value="">Alle</option><?php foreach (['web' => 'Web', 'email' => 'E-Mail', 'phone' => 'Telefon', 'staff' => 'Mitarbeiter', 'chat' => 'Live-Chat', 'api' => 'API'] as $k => $l): ?><option value="<?= $k ?>" <?= ($cfg['filters']['source'] ?? '') === $k ? 'selected' : '' ?>><?= $l ?></option><?php endforeach; ?></select></div>
|
||||
</div>
|
||||
<label style="margin-top:14px">Kennzahlen</label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px 18px">
|
||||
<?php foreach ($metrics as $k => $m): ?>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-weight:400;margin:0"><input type="checkbox" name="metrics[]" value="<?= e($k) ?>" style="width:auto" <?= in_array($k, $cfg['metrics'], true) ? 'checked' : '' ?>> <?= e($m[0]) ?></label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;align-items:end;flex-wrap:wrap;margin-top:12px">
|
||||
<div><label for="sort" style="margin-top:0">Sortieren nach</label>
|
||||
<select id="sort" name="sort"><option value="label" <?= $cfg['sort'] === 'label' ? 'selected' : '' ?>>Gruppe</option><?php foreach ($metrics as $k => $m): ?><option value="<?= e($k) ?>" <?= $cfg['sort'] === $k ? 'selected' : '' ?>><?= e($m[0]) ?></option><?php endforeach; ?></select></div>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-weight:400;margin:0 0 8px"><input type="checkbox" name="desc" value="1" style="width:auto" <?= $cfg['desc'] ? 'checked' : '' ?>> absteigend</label>
|
||||
<button type="submit" class="btn">Bericht erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card" style="padding:0;overflow:auto">
|
||||
<table>
|
||||
<thead><tr><?php foreach ($report['columns'] as $c): ?><th><?= e($c['label']) ?></th><?php endforeach; ?></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($report['rows'] as $row): ?>
|
||||
<tr>
|
||||
<td><strong><?= e((string) $row['label']) ?></strong></td>
|
||||
<?php foreach (array_slice($report['columns'], 1) as $c): $v = $row[$c['key']]; ?>
|
||||
<td>
|
||||
<?= e($fmtValue($v, $c['unit'])) ?>
|
||||
<?php if ($v !== null && $c['unit'] === ''): ?><div style="height:4px;background:var(--panel2,#eef1f5);border-radius:3px;margin-top:3px"><div style="height:100%;width:<?= (int) round(($v / $maxByMetric[$c['key']]) * 100) ?>%;background:var(--primary,#1f8fe0);border-radius:3px"></div></div><?php endif; ?>
|
||||
</td>
|
||||
<?php endforeach; ?>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$report['rows']): ?><tr><td colspan="<?= count($report['columns']) ?>" class="muted">Keine Tickets im gewählten Zeitraum.</td></tr><?php else: ?>
|
||||
<tr style="font-weight:700;background:var(--panel2,#f4f6f9)"><td>Gesamt</td>
|
||||
<?php foreach (array_slice($report['columns'], 1) as $c): ?><td><?= e($fmtValue($report['totals'][$c['key']] ?? null, $c['unit'])) ?><?= $c['unit'] !== '' ? ' <span class="muted" style="font-weight:400;font-size:.72rem">Ø</span>' : '' ?></td><?php endforeach; ?></tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="card" style="margin-top:14px">
|
||||
<summary style="cursor:pointer;font-weight:600">Diesen Bericht speichern</summary>
|
||||
<form method="post" style="margin-top:10px;max-width:420px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="return_query" value="<?= e($queryOf($cfg)) ?>">
|
||||
<label for="report_name" style="margin-top:0">Name</label>
|
||||
<input type="text" id="report_name" name="report_name" maxlength="120" placeholder="z. B. Monatsbericht Support" required>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="shared" value="1" style="width:auto"> Für alle Mitarbeiter sichtbar</label>
|
||||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,758 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$pdo = get_db();
|
||||
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
|
||||
|
||||
/** Einfaches Balkendiagramm als SVG */
|
||||
function svg_bar_chart(array $data, int $height = 200): string
|
||||
{
|
||||
if (!$data) return '<p class="muted">Keine Daten vorhanden.</p>';
|
||||
$palette = ['#0f6f6a', '#2563eb', '#d97706', '#dc2626', '#a855f7', '#16a34a', '#6b7280'];
|
||||
$max = max(max($data), 1);
|
||||
$count = count($data);
|
||||
$barWidth = 38;
|
||||
$gap = 16;
|
||||
$width = $count * ($barWidth + $gap) + $gap;
|
||||
$chartHeight = $height - 46;
|
||||
|
||||
$svg = "<svg viewBox=\"0 0 {$width} {$height}\" xmlns=\"http://www.w3.org/2000/svg\" style=\"width:100%; height:auto; max-width:{$width}px; display:block\">";
|
||||
$x = $gap; $i = 0;
|
||||
foreach ($data as $label => $value) {
|
||||
$barH = (int) round(($value / $max) * $chartHeight);
|
||||
$y = $chartHeight - $barH + 18;
|
||||
$color = $palette[$i % count($palette)];
|
||||
$svg .= "<rect x=\"{$x}\" y=\"{$y}\" width=\"{$barWidth}\" height=\"{$barH}\" rx=\"3\" fill=\"{$color}\" />";
|
||||
$svg .= "<text x=\"" . ($x + $barWidth / 2) . "\" y=\"" . ($y - 6) . "\" text-anchor=\"middle\" font-size=\"12\" fill=\"#16202a\" font-family=\"sans-serif\">" . (int) $value . "</text>";
|
||||
$svg .= "<text x=\"" . ($x + $barWidth / 2) . "\" y=\"" . ($height - 6) . "\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"#64748b\" font-family=\"sans-serif\">" . htmlspecialchars((string) $label) . "</text>";
|
||||
$x += $barWidth + $gap;
|
||||
$i++;
|
||||
}
|
||||
$svg .= '</svg>';
|
||||
return $svg;
|
||||
}
|
||||
|
||||
/** Mehrlinien-Diagramm als SVG. $buckets: [label => [serieKey => wert]]. $series: [serieKey => ['label'=>..,'color'=>..]] */
|
||||
function svg_multiline_chart(array $buckets, array $series): string
|
||||
{
|
||||
if (!$buckets) return '<p class="muted">Keine Daten im gewählten Zeitraum.</p>';
|
||||
$labels = array_keys($buckets);
|
||||
$n = count($labels);
|
||||
$width = max(640, $n * 48);
|
||||
$height = 260;
|
||||
$padLeft = 30; $padBottom = 36; $padTop = 16; $padRight = 16;
|
||||
$chartW = $width - $padLeft - $padRight;
|
||||
$chartH = $height - $padTop - $padBottom;
|
||||
|
||||
$maxVal = 1;
|
||||
foreach ($buckets as $b) {
|
||||
foreach (array_keys($series) as $key) {
|
||||
$maxVal = max($maxVal, (int) ($b[$key] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$svg = "<svg viewBox=\"0 0 {$width} {$height}\" xmlns=\"http://www.w3.org/2000/svg\" style=\"width:100%;height:auto;max-width:{$width}px;display:block\">";
|
||||
$svg .= "<line x1=\"{$padLeft}\" y1=\"" . ($padTop + $chartH) . "\" x2=\"" . ($padLeft + $chartW) . "\" y2=\"" . ($padTop + $chartH) . "\" stroke=\"#cbd5e1\" stroke-width=\"1\" />";
|
||||
|
||||
$stepX = $n > 1 ? $chartW / ($n - 1) : 0;
|
||||
|
||||
foreach ($series as $key => $info) {
|
||||
$points = [];
|
||||
foreach ($labels as $i => $label) {
|
||||
$val = (int) ($buckets[$label][$key] ?? 0);
|
||||
$x = round($padLeft + $i * $stepX, 1);
|
||||
$y = round($padTop + $chartH - ($val / $maxVal) * $chartH, 1);
|
||||
$points[] = "{$x},{$y}";
|
||||
}
|
||||
$svg .= '<polyline fill="none" stroke="' . $info['color'] . '" stroke-width="2" points="' . implode(' ', $points) . '" />';
|
||||
foreach ($labels as $i => $label) {
|
||||
$val = (int) ($buckets[$label][$key] ?? 0);
|
||||
$x = round($padLeft + $i * $stepX, 1);
|
||||
$y = round($padTop + $chartH - ($val / $maxVal) * $chartH, 1);
|
||||
$svg .= "<circle cx=\"{$x}\" cy=\"{$y}\" r=\"2.6\" fill=\"{$info['color']}\" />";
|
||||
}
|
||||
}
|
||||
|
||||
$labelEvery = max(1, (int) ceil($n / 12));
|
||||
foreach ($labels as $i => $label) {
|
||||
if ($i % $labelEvery !== 0 && $i !== $n - 1) continue;
|
||||
$x = round($padLeft + $i * $stepX, 1);
|
||||
$svg .= "<text x=\"{$x}\" y=\"" . ($height - 10) . "\" font-size=\"10\" fill=\"#64748b\" text-anchor=\"middle\" font-family=\"sans-serif\">" . htmlspecialchars((string) $label) . "</text>";
|
||||
}
|
||||
$svg .= '</svg>';
|
||||
return $svg;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Zeitraum bestimmen
|
||||
// ---------------------------------------------------------------
|
||||
$preset = $_GET['range'] ?? '30d';
|
||||
$today = new DateTime('today');
|
||||
switch ($preset) {
|
||||
case '7d': $from = (clone $today)->modify('-6 days'); $to = clone $today; break;
|
||||
case 'thismonth': $from = new DateTime(date('Y-m-01')); $to = clone $today; break;
|
||||
case 'thisyear': $from = new DateTime(date('Y-01-01')); $to = clone $today; break;
|
||||
case 'custom':
|
||||
$from = !empty($_GET['from']) ? new DateTime($_GET['from']) : (clone $today)->modify('-29 days');
|
||||
$to = !empty($_GET['to']) ? new DateTime($_GET['to']) : clone $today;
|
||||
break;
|
||||
case '30d':
|
||||
default:
|
||||
$preset = '30d';
|
||||
$from = (clone $today)->modify('-29 days'); $to = clone $today;
|
||||
}
|
||||
if ($from > $to) { [$from, $to] = [$to, $from]; }
|
||||
$rangeFrom = $from->format('Y-m-d 00:00:00');
|
||||
$rangeTo = $to->format('Y-m-d 23:59:59');
|
||||
$rangeDays = (int) $from->diff($to)->days + 1;
|
||||
$useWeekly = $rangeDays > 45;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Kennzahlen (gesamt, nicht zeitraumgefiltert – schneller Überblick)
|
||||
// ---------------------------------------------------------------
|
||||
[$deptFilterT, $deptParamsT] = dept_filter_sql($myDeptIds, 'department_id');
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE 1=1{$deptFilterT}");
|
||||
$stmt->execute($deptParamsT);
|
||||
$totalTickets = (int) $stmt->fetchColumn();
|
||||
|
||||
$monthStart = date('Y-m-01 00:00:00');
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolved_at >= ?{$deptFilterT}");
|
||||
$stmt->execute([$monthStart, ...$deptParamsT]);
|
||||
$resolvedThisMonth = (int) $stmt->fetchColumn();
|
||||
|
||||
$respTimes = [];
|
||||
$stmt = $pdo->prepare("SELECT created_at, first_responded_at FROM tickets WHERE first_responded_at IS NOT NULL{$deptFilterT}");
|
||||
$stmt->execute($deptParamsT);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$respTimes[] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600;
|
||||
}
|
||||
$avgResponse = $respTimes ? array_sum($respTimes) / count($respTimes) : null;
|
||||
|
||||
$resolTimes = [];
|
||||
$stmt = $pdo->prepare("SELECT created_at, resolved_at FROM tickets WHERE resolved_at IS NOT NULL{$deptFilterT}");
|
||||
$stmt->execute($deptParamsT);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$resolTimes[] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600;
|
||||
}
|
||||
$avgResolution = $resolTimes ? array_sum($resolTimes) / count($resolTimes) : null;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mehrlinien-Aktivitätsdiagramm für den gewählten Zeitraum
|
||||
// ---------------------------------------------------------------
|
||||
function bucket_key_for(string $datetime, bool $weekly): string
|
||||
{
|
||||
$d = new DateTime($datetime);
|
||||
return $weekly ? 'KW' . $d->format('W') : $d->format('d.m');
|
||||
}
|
||||
|
||||
$buckets = [];
|
||||
$cursor = clone $from;
|
||||
while ($cursor <= $to) {
|
||||
$key = $useWeekly ? 'KW' . $cursor->format('W') : $cursor->format('d.m');
|
||||
if (!isset($buckets[$key])) {
|
||||
$buckets[$key] = ['erstellt' => 0, 'zugewiesen' => 0, 'geschlossen' => 0, 'geloescht' => 0, 'ueberfaellig' => 0];
|
||||
}
|
||||
$cursor->modify('+1 day');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT created_at FROM tickets WHERE created_at BETWEEN ? AND ?{$deptFilterT}");
|
||||
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$k = bucket_key_for($r['created_at'], $useWeekly);
|
||||
if (isset($buckets[$k])) $buckets[$k]['erstellt']++;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT al.created_at FROM activity_log al JOIN tickets t ON t.id = al.ticket_id
|
||||
WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT}"
|
||||
);
|
||||
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$k = bucket_key_for($r['created_at'], $useWeekly);
|
||||
if (isset($buckets[$k])) $buckets[$k]['zugewiesen']++;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT closed_at FROM tickets WHERE closed_at BETWEEN ? AND ?{$deptFilterT}");
|
||||
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$k = bucket_key_for($r['closed_at'], $useWeekly);
|
||||
if (isset($buckets[$k])) $buckets[$k]['geschlossen']++;
|
||||
}
|
||||
|
||||
[$deptFilterDel, $deptParamsDel] = dept_filter_sql($myDeptIds, 'department_id');
|
||||
$stmt = $pdo->prepare("SELECT deleted_at FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterDel}");
|
||||
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsDel));
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$k = bucket_key_for($r['deleted_at'], $useWeekly);
|
||||
if (isset($buckets[$k])) $buckets[$k]['geloescht']++;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT al.created_at FROM activity_log al JOIN tickets t ON t.id = al.ticket_id
|
||||
WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT}"
|
||||
);
|
||||
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$k = bucket_key_for($r['created_at'], $useWeekly);
|
||||
if (isset($buckets[$k])) $buckets[$k]['ueberfaellig']++;
|
||||
}
|
||||
|
||||
$chartSeries = [
|
||||
'erstellt' => ['label' => 'Erstellt', 'color' => '#16a34a'],
|
||||
'zugewiesen' => ['label' => 'Zugewiesen', 'color' => '#2563eb'],
|
||||
'geschlossen' => ['label' => 'Geschlossen', 'color' => '#0f6f6a'],
|
||||
'geloescht' => ['label' => 'Gelöscht', 'color' => '#a855f7'],
|
||||
'ueberfaellig' => ['label' => 'Überfällig', 'color' => '#dc2626'],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Nach Status / Priorität (Gesamtüberblick, unabhängig vom Zeitraum)
|
||||
// ---------------------------------------------------------------
|
||||
$byStatus = [];
|
||||
foreach (['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'] as $s) {
|
||||
[$label] = status_label($s);
|
||||
$byStatus[$label] = 0;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT status, COUNT(*) c FROM tickets WHERE 1=1{$deptFilterT} GROUP BY status");
|
||||
$stmt->execute($deptParamsT);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
[$label] = status_label($r['status']);
|
||||
$byStatus[$label] = (int) $r['c'];
|
||||
}
|
||||
|
||||
$byPrio = [];
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT p.name, p.sort_order, COUNT(t.id) c FROM priorities p
|
||||
LEFT JOIN tickets t ON t.priority_id = p.id{$deptFilterT}
|
||||
GROUP BY p.id, p.name, p.sort_order ORDER BY p.sort_order"
|
||||
);
|
||||
$stmt->execute($deptParamsT);
|
||||
foreach ($stmt->fetchAll() as $r) {
|
||||
$byPrio[$r['name']] = (int) $r['c'];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// SLA-Einhaltung im Zeitraum
|
||||
// ---------------------------------------------------------------
|
||||
$slaStmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN resolved_at <= resolution_due_at THEN 1 ELSE 0 END) AS on_time
|
||||
FROM tickets
|
||||
WHERE status IN ('resolved','closed')
|
||||
AND resolved_at IS NOT NULL AND resolution_due_at IS NOT NULL
|
||||
AND resolved_at BETWEEN ? AND ?{$deptFilterT}"
|
||||
);
|
||||
$slaStmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
|
||||
$slaRow = $slaStmt->fetch();
|
||||
$slaTotal = (int)($slaRow['total'] ?? 0);
|
||||
$slaOk = (int)($slaRow['on_time'] ?? 0);
|
||||
$slaRate = $slaTotal > 0 ? round($slaOk / $slaTotal * 100) : null;
|
||||
|
||||
// Mitarbeiter-Performance im Zeitraum
|
||||
$agentPerf = [];
|
||||
if ($staff['role'] === 'admin') {
|
||||
$avgResponseHours = report_avg_response_hours_expression(
|
||||
$pdo->getAttribute(PDO::ATTR_DRIVER_NAME)
|
||||
);
|
||||
$apStmt = $pdo->prepare(
|
||||
"SELECT s.name,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN t.status IN ('resolved','closed') THEN 1 ELSE 0 END) AS resolved,
|
||||
AVG(CASE WHEN t.first_responded_at IS NOT NULL
|
||||
THEN {$avgResponseHours} END) AS avg_resp
|
||||
FROM tickets t
|
||||
JOIN staff s ON s.id = t.assigned_staff_id
|
||||
WHERE t.created_at BETWEEN ? AND ?{$deptFilterT}
|
||||
GROUP BY t.assigned_staff_id, s.name
|
||||
ORDER BY resolved DESC LIMIT 10"
|
||||
);
|
||||
$apStmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
|
||||
$agentPerf = $apStmt->fetchAll();
|
||||
}
|
||||
|
||||
// Chart.js-Daten für Zeitreihe
|
||||
$chartLabels = array_keys($buckets);
|
||||
$chartCreated = array_column(array_values($buckets), 'erstellt');
|
||||
$chartClosed = array_column(array_values($buckets), 'geschlossen');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Statistik-Tabellen je Abteilung / Thema / Agent
|
||||
// ---------------------------------------------------------------
|
||||
$deptRows = build_report_table($pdo, 'department_id', $rangeFrom, $rangeTo, $myDeptIds);
|
||||
$topicRows = build_report_table($pdo, 'help_topic_id', $rangeFrom, $rangeTo, $myDeptIds);
|
||||
|
||||
$departments = $pdo->query('SELECT id, name FROM departments')->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$agentNames = $pdo->query('SELECT id, name FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$helpTopics = $pdo->query('SELECT id, name FROM help_topics')->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
function render_report_table(array $rows, array $labelMap, string $noneLabel): string
|
||||
{
|
||||
if (!$rows) return '<p class="muted">Keine Daten im gewählten Zeitraum.</p>';
|
||||
ob_start();
|
||||
?>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Bezeichnung</th><th>Geöffnet</th><th>Zugewiesen</th><th>Überfällig</th><th>Geschlossen</th>
|
||||
<th>Wieder geöffnet</th><th>Gelöscht</th><th>Servicezeit (Std)</th><th>Reaktionszeit (Std)</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $key => $r): ?>
|
||||
<tr>
|
||||
<td><?= $key === 'none' ? e($noneLabel) : e($labelMap[(int) $key] ?? "#{$key}") ?></td>
|
||||
<td><?= (int)$r['geoeffnet'] ?></td>
|
||||
<td><?= (int)$r['zugewiesen'] ?></td>
|
||||
<td><?= (int)$r['ueberfaellig'] ?></td>
|
||||
<td><?= (int)$r['geschlossen'] ?></td>
|
||||
<td><?= (int)$r['wieder_geoeffnet'] ?></td>
|
||||
<td><?= (int)$r['geloescht'] ?></td>
|
||||
<td><?= $r['servicezeit'] !== null ? number_format((float)$r['servicezeit'], 1) : '–' ?></td>
|
||||
<td><?= $r['reaktionszeit'] !== null ? number_format((float)$r['reaktionszeit'], 1) : '–' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Übersicht: "Mein Status" + Team + Handlungsbedarf-Kennzahlen. Bis vor kurzem eine eigene
|
||||
// Seite (staff/dashboard.php) – zusammengelegt hierher, weil der Login ohnehin schon immer
|
||||
// auf Berichte landete und die frühere "Priorisiert"-Ticketliste dort nur eine Untermenge
|
||||
// dessen war, was der Tickets-Reiter längst zeigt. dashboard.php bleibt als Weiterleitung
|
||||
// hierher bestehen, damit alte Links/Lesezeichen weiter funktionieren.
|
||||
// ---------------------------------------------------------------
|
||||
$now = db_now();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed'){$deptFilterT}");
|
||||
$stmt->execute($deptParamsT);
|
||||
$dashOpenTickets = (int) $stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed'){$deptFilterT}");
|
||||
$stmt->execute(array_merge([$staff['id']], $deptParamsT));
|
||||
$dashMine = (int) $stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed'){$deptFilterT}");
|
||||
$stmt->execute($deptParamsT);
|
||||
$dashUnassigned = (int) $stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND resolution_due_at IS NOT NULL AND resolution_due_at < ?{$deptFilterT}");
|
||||
$stmt->execute(array_merge([$now], $deptParamsT));
|
||||
$dashOverdue = (int) $stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'{$deptFilterT}");
|
||||
$stmt->execute($deptParamsT);
|
||||
$dashUnanswered = (int) $stmt->fetchColumn();
|
||||
|
||||
$mentionStmt = $pdo->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
|
||||
$mentionStmt->execute([$staff['id']]);
|
||||
$unreadMentions = (int) $mentionStmt->fetchColumn();
|
||||
|
||||
$tsColors = ['available'=>'#22c55e','busy'=>'#f59e0b','break'=>'#6366f1','offline'=>'#94a3b8'];
|
||||
$tsLabels = ['available'=>'Verfügbar','busy'=>'Beschäftigt','break'=>'Pause','offline'=>'Offline'];
|
||||
$teamStatus = [];
|
||||
$myAvailStatus = 'available';
|
||||
try {
|
||||
$tsStmt = $pdo->query(
|
||||
"SELECT s.id, s.name, s.is_on_vacation, sa.status AS avail_status, sa.note AS avail_note
|
||||
FROM staff s
|
||||
LEFT JOIN staff_availability sa ON sa.staff_id = s.id
|
||||
WHERE s.is_active = 1 ORDER BY s.name"
|
||||
);
|
||||
foreach ($tsStmt->fetchAll() as $row) {
|
||||
if ($row['id'] === $staff['id']) {
|
||||
$myAvailStatus = $row['avail_status'] ?? 'available';
|
||||
} else {
|
||||
$teamStatus[] = $row;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
$pageTitle = 'Übersicht';
|
||||
$activePage = 'reports';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:4px">
|
||||
<h1>Willkommen, <?= e($staff['name']) ?></h1>
|
||||
<?php if ($staff['role'] === 'admin'): ?>
|
||||
<div style="display:flex; align-items:center; gap:10px">
|
||||
<span style="font-size:.78rem; color:var(--muted)">v<?= DESKHUB_VERSION ?></span>
|
||||
<a href="/staff/update.php" class="btn secondary" style="font-size:.8rem; padding:6px 12px">🔄 Updates</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($staff['role'] === 'admin'):
|
||||
// Update-Check: neuestes Release von Gitea holen
|
||||
$__latestVersion = null;
|
||||
if (function_exists('curl_init')) {
|
||||
$__ch = curl_init('https://git.viper.ipv64.net/api/v1/repos/M_Viper/deskhub/releases?limit=1');
|
||||
curl_setopt_array($__ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>4, CURLOPT_SSL_VERIFYPEER=>true, CURLOPT_PROTOCOLS=>CURLPROTO_HTTPS, CURLOPT_HTTPHEADER=>update_http_headers()]);
|
||||
$__resp = curl_exec($__ch); curl_close($__ch);
|
||||
$__rel = @json_decode($__resp, true);
|
||||
if (!empty($__rel[0]['tag_name'])) $__latestVersion = $__rel[0]['tag_name'];
|
||||
}
|
||||
$__hasUpdate = $__latestVersion && version_compare($__latestVersion, DESKHUB_VERSION, '>');
|
||||
?>
|
||||
<?php if ($__hasUpdate): ?>
|
||||
<div style="background:linear-gradient(135deg,#0f6f6a,#0b5a56); color:#fff; border-radius:10px; padding:16px 20px; margin-bottom:20px; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap">
|
||||
<div style="display:flex; align-items:center; gap:14px">
|
||||
<span style="font-size:1.8rem">🆕</span>
|
||||
<div>
|
||||
<div style="font-weight:700; font-size:.95rem">Update verfügbar: Version <?= e($__latestVersion) ?></div>
|
||||
<div style="font-size:.82rem; opacity:.85; margin-top:2px">Aktuell installiert: v<?= DESKHUB_VERSION ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/staff/update.php" style="background:rgba(255,255,255,.2); color:#fff; border:1px solid rgba(255,255,255,.3); border-radius:8px; padding:8px 16px; font-size:.85rem; font-weight:600; text-decoration:none; white-space:nowrap; flex-shrink:0">
|
||||
Zum Update-Bereich →
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php'"><div class="num"><?= $dashOpenTickets ?></div><div class="lbl">Offene Tickets</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=me'"><div class="num"><?= $dashMine ?></div><div class="lbl">Mir zugewiesen</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=unassigned'"><div class="num"><?= $dashUnassigned ?></div><div class="lbl">Nicht zugewiesen</div></div>
|
||||
<div class="stat-card overdue" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashOverdue ?></div><div class="lbl">Überfällig</div></div>
|
||||
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashUnanswered ?></div><div class="lbl">Unbeantwortet</div></div>
|
||||
<?php if ($unreadMentions > 0): ?>
|
||||
<div class="stat-card" style="cursor:pointer; border-color:#f59e0b" onclick="location.href='/staff/mentions.php'"><div class="num" style="color:#f59e0b"><?= $unreadMentions ?></div><div class="lbl">🔔 Neue Erwähnungen</div></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- ── Mein Status + Team ─────────────────────────────────────────────────── -->
|
||||
<form id="csrf-dash" style="display:none"><?= csrf_field() ?></form>
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
|
||||
|
||||
<!-- Mein Status -->
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
||||
<span style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">Mein Status:</span>
|
||||
<?php foreach ($tsColors as $sk => $sc):
|
||||
$active = $myAvailStatus === $sk;
|
||||
?>
|
||||
<button type="button" onclick="setDashStatus('<?= $sk ?>')"
|
||||
data-status="<?= $sk ?>"
|
||||
class="dash-status-btn"
|
||||
style="display:flex;align-items:center;gap:5px;padding:5px 12px;border:2px solid <?= $active ? $sc : 'var(--border)' ?>;border-radius:20px;background:<?= $active ? $sc.'18' : 'transparent' ?>;cursor:pointer;font-size:.8rem;font-weight:<?= $active ? '600' : '400' ?>;color:<?= $active ? $sc : 'var(--muted)' ?>;transition:all .15s">
|
||||
<span style="width:7px;height:7px;border-radius:50%;background:<?= $sc ?>"></span>
|
||||
<?= $tsLabels[$sk] ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Trenner -->
|
||||
<?php if ($teamStatus): ?>
|
||||
<div style="width:1px;height:28px;background:var(--border);flex-shrink:0"></div>
|
||||
|
||||
<!-- Team -->
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1">
|
||||
<span style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0">Team:</span>
|
||||
<?php foreach ($teamStatus as $tm):
|
||||
$ts = $tm['is_on_vacation'] ? 'offline' : ($tm['avail_status'] ?? 'offline');
|
||||
$tsc = $tsColors[$ts] ?? '#94a3b8';
|
||||
$tsl = $tm['is_on_vacation'] ? 'Urlaub' : ($tsLabels[$ts] ?? 'Offline');
|
||||
?>
|
||||
<div id="team-member-<?= $tm['id'] ?>"
|
||||
title="<?= e($tm['name']) ?>: <?= e($tsl) ?><?= $tm['avail_note'] ? ' – '.e($tm['avail_note']) : '' ?>"
|
||||
style="display:flex;align-items:center;gap:5px;padding:4px 10px;border:1px solid var(--border);border-radius:20px;font-size:.78rem;background:var(--surface)">
|
||||
<span class="team-dot" style="width:7px;height:7px;border-radius:50%;background:<?= $tsc ?>;flex-shrink:0"></span>
|
||||
<span style="font-weight:500"><?= e($tm['name']) ?></span>
|
||||
<span class="team-label" style="color:var(--muted);font-size:.72rem"><?= e($tsl) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var dashStatusColors = <?= json_encode($tsColors) ?>;
|
||||
var dashStatusLabels = <?= json_encode($tsLabels) ?>;
|
||||
|
||||
function setDashStatus(s) {
|
||||
// Sofort visuell aktualisieren
|
||||
document.querySelectorAll('.dash-status-btn').forEach(function(btn) {
|
||||
var active = btn.dataset.status === s;
|
||||
var c = dashStatusColors[btn.dataset.status] || '#94a3b8';
|
||||
btn.style.borderColor = active ? c : 'var(--border)';
|
||||
btn.style.background = active ? c + '18' : 'transparent';
|
||||
btn.style.color = active ? c : 'var(--muted)';
|
||||
btn.style.fontWeight = active ? '600' : '400';
|
||||
});
|
||||
// An Server senden
|
||||
var fd = new FormData();
|
||||
fd.append('status', s);
|
||||
fd.append('csrf_token', document.querySelector('#csrf-dash input[name=csrf_token]')?.value || '');
|
||||
fetch('/staff/availability.php', { method:'POST', body:fd, credentials:'same-origin' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){ if (d.error) console.error('Status:', d.error); })
|
||||
.catch(function(e){ console.error('Status-Update:', e); });
|
||||
}
|
||||
|
||||
// Team-Status alle 20 Sek. aktualisieren
|
||||
function refreshDashTeam() {
|
||||
fetch('/staff/availability_status.php', { credentials:'same-origin' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
data.forEach(function(ag) {
|
||||
var el = document.getElementById('team-member-' + ag.id);
|
||||
if (!el) return;
|
||||
var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline');
|
||||
var col = dashStatusColors[s] || '#94a3b8';
|
||||
var lbl = ag.is_on_vacation ? 'Urlaub' : (dashStatusLabels[s] || 'Offline');
|
||||
el.querySelector('.team-dot').style.background = col;
|
||||
el.querySelector('.team-label').textContent = lbl;
|
||||
el.title = ag.name + ': ' + lbl + (ag.note ? ' – ' + ag.note : '');
|
||||
});
|
||||
}).catch(function(){});
|
||||
}
|
||||
setInterval(refreshDashTeam, 20000);
|
||||
</script>
|
||||
|
||||
<h2 style="margin-top:8px">Berichte</h2>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card"><div class="num"><?= $totalTickets ?></div><div class="lbl">Tickets gesamt</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $resolvedThisMonth ?></div><div class="lbl">Gelöst diesen Monat</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $avgResponse !== null ? round($avgResponse, 1) . ' Std' : '–' ?></div><div class="lbl">Ø Erstreaktion</div></div>
|
||||
<div class="stat-card"><div class="num"><?= $avgResolution !== null ? round($avgResolution, 1) . ' Std' : '–' ?></div><div class="lbl">Ø Lösungszeit</div></div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="filters card">
|
||||
<div>
|
||||
<label>Berichtszeitraum</label>
|
||||
<select name="range" onchange="document.getElementById('custom-range').style.display = this.value==='custom' ? 'flex' : 'none'">
|
||||
<option value="7d" <?= $preset==='7d'?'selected':'' ?>>Letzte 7 Tage</option>
|
||||
<option value="30d" <?= $preset==='30d'?'selected':'' ?>>Letzte 30 Tage</option>
|
||||
<option value="thismonth" <?= $preset==='thismonth'?'selected':'' ?>>Dieser Monat</option>
|
||||
<option value="thisyear" <?= $preset==='thisyear'?'selected':'' ?>>Dieses Jahr</option>
|
||||
<option value="custom" <?= $preset==='custom'?'selected':'' ?>>Benutzerdefiniert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="custom-range" style="display:<?= $preset==='custom'?'flex':'none' ?>; gap:14px">
|
||||
<div>
|
||||
<label>Von</label>
|
||||
<input type="date" name="from" value="<?= e($from->format('Y-m-d')) ?>">
|
||||
</div>
|
||||
<div>
|
||||
<label>Bis</label>
|
||||
<input type="date" name="to" value="<?= e($to->format('Y-m-d')) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div><button type="submit" class="btn secondary">Aktualisieren</button></div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<h3>Ticketaktivität <span class="muted" style="font-weight:400; font-size:.8rem">(<?= e($from->format('d.m.Y')) ?> – <?= e($to->format('d.m.Y')) ?>)</span></h3>
|
||||
<canvas id="chart-activity" style="max-height:100px"></canvas>
|
||||
<div style="display:flex; gap:16px; flex-wrap:wrap; margin-top:10px">
|
||||
<?php foreach ($chartSeries as $info): ?>
|
||||
<span style="font-size:.78rem; display:flex; align-items:center; gap:6px">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:<?= e($info['color']) ?>;display:inline-block"></span><?= e($info['label']) ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── SLA-Kennzahl ────────────────────────────────────────────────────── -->
|
||||
<?php if ($slaTotal > 0): ?>
|
||||
<div class="card" style="padding:20px;margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px">
|
||||
<div>
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">SLA-Einhaltung im Zeitraum</div>
|
||||
<div style="font-size:2.2rem;font-weight:900;line-height:1.1;margin-top:4px;color:<?= $slaRate >= 90 ? '#22c55e' : ($slaRate >= 70 ? '#f59e0b' : '#ef4444') ?>"><?= $slaRate ?>%</div>
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px"><?= $slaOk ?> von <?= $slaTotal ?> Tickets innerhalb der Frist gelöst</div>
|
||||
</div>
|
||||
<div style="flex:1;max-width:400px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:.75rem;color:var(--muted);margin-bottom:4px">
|
||||
<span>Im Zeitlimit</span><span>Überfällig</span>
|
||||
</div>
|
||||
<div style="background:var(--border);border-radius:20px;height:12px;overflow:hidden">
|
||||
<div style="height:100%;width:<?= $slaRate ?>%;background:<?= $slaRate >= 90 ? '#22c55e' : ($slaRate >= 70 ? '#f59e0b' : '#ef4444') ?>;border-radius:20px;transition:width .6s"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:.75rem;margin-top:4px">
|
||||
<span style="color:#22c55e;font-weight:600"><?= $slaOk ?> ✓</span>
|
||||
<span style="color:#ef4444;font-weight:600"><?= $slaTotal - $slaOk ?> ✗</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ── Status & Priorität als Chart.js ─────────────────────────────────── -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px">
|
||||
<div class="card">
|
||||
<h3 style="margin:0 0 14px">Nach Status</h3>
|
||||
<canvas id="chart-status" style="max-height:140px"></canvas>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin:0 0 14px">Nach Priorität</h3>
|
||||
<canvas id="chart-prio" style="max-height:140px"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="card">
|
||||
<h3>Statistiken <span class="muted" style="font-weight:400; font-size:.8rem">(<?= e($from->format('d.m.Y')) ?> – <?= e($to->format('d.m.Y')) ?>)</span></h3>
|
||||
<div class="tabs">
|
||||
<button type="button" class="tab-btn active" onclick="showTab('dept')">Abteilung</button>
|
||||
<button type="button" class="tab-btn" onclick="showTab('topic')">Themen</button>
|
||||
<?php if ($staff['role'] === 'admin'): ?>
|
||||
<button type="button" class="tab-btn" onclick="showTab('agent')">Mitarbeiter</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
$exportParams = ['range' => $preset, 'from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d')];
|
||||
?>
|
||||
<div id="tab-dept" class="tab-pane" style="overflow-x:auto">
|
||||
<?= render_report_table($deptRows, $departments, '–') ?>
|
||||
<p style="margin-top:8px"><a href="/staff/export.php?type=report&dim=dept&<?= http_build_query($exportParams) ?>" class="btn secondary" style="font-size:.8rem; padding:6px 12px">Exportieren (CSV)</a></p>
|
||||
</div>
|
||||
<div id="tab-topic" class="tab-pane" style="display:none; overflow-x:auto">
|
||||
<?= render_report_table($topicRows, $helpTopics, 'Kein Hilfethema') ?>
|
||||
<p style="margin-top:8px"><a href="/staff/export.php?type=report&dim=topic&<?= http_build_query($exportParams) ?>" class="btn secondary" style="font-size:.8rem; padding:6px 12px">Exportieren (CSV)</a></p>
|
||||
</div>
|
||||
<?php if ($staff['role'] === 'admin' && $agentPerf): ?>
|
||||
<div id="tab-agent" class="tab-pane" style="display:none;overflow-x:auto">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th style="text-align:right">Tickets</th>
|
||||
<th style="text-align:right">Gelöst</th>
|
||||
<th style="text-align:right">Lösungsrate</th>
|
||||
<th style="text-align:right">Ø Erstantwort</th>
|
||||
<th>Leistung</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$maxResolved = max(array_column($agentPerf, 'resolved') ?: [1]);
|
||||
foreach ($agentPerf as $ap):
|
||||
$rate = $ap['total'] > 0 ? round($ap['resolved'] / $ap['total'] * 100) : 0;
|
||||
$barPct = $maxResolved > 0 ? round($ap['resolved'] / $maxResolved * 100) : 0;
|
||||
$avgR = $ap['avg_resp'] !== null ? round((float)$ap['avg_resp'], 1) . ' Std' : '–';
|
||||
?>
|
||||
<tr>
|
||||
<td style="font-weight:500"><?= e($ap['name']) ?></td>
|
||||
<td style="text-align:right"><?= (int)$ap['total'] ?></td>
|
||||
<td style="text-align:right;color:var(--primary);font-weight:600"><?= (int)$ap['resolved'] ?></td>
|
||||
<td style="text-align:right">
|
||||
<span style="color:<?= $rate >= 80 ? '#22c55e' : ($rate >= 50 ? '#f59e0b' : '#ef4444') ?>;font-weight:600"><?= $rate ?>%</span>
|
||||
</td>
|
||||
<td style="text-align:right;color:var(--muted)"><?= $avgR ?></td>
|
||||
<td style="min-width:120px">
|
||||
<div style="background:var(--border);border-radius:20px;height:6px;overflow:hidden">
|
||||
<div style="height:100%;width:<?= $barPct ?>%;background:var(--primary);border-radius:20px"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top:8px"><a href="/staff/export.php?type=report&dim=agent&<?= http_build_query($exportParams) ?>" class="btn secondary" style="font-size:.8rem;padding:6px 12px">Exportieren (CSV)</a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$prioColorMap = [];
|
||||
foreach ($pdo->query('SELECT name, color FROM priorities ORDER BY sort_order')->fetchAll() as $pc) {
|
||||
$prioColorMap[$pc['name']] = $pc['color'];
|
||||
}
|
||||
$jsChartLabels = json_encode($chartLabels);
|
||||
$jsChartCreated = json_encode($chartCreated);
|
||||
$jsChartClosed = json_encode($chartClosed);
|
||||
$jsStatusLabels = json_encode(array_keys($byStatus));
|
||||
$jsStatusData = json_encode(array_values($byStatus));
|
||||
$jsPrioLabels = json_encode(array_keys($byPrio));
|
||||
$jsPrioData = json_encode(array_values($byPrio));
|
||||
$jsPrioColors = json_encode($prioColorMap);
|
||||
?>
|
||||
<script src="/assets/js/chart.min.js"></script>
|
||||
<script>
|
||||
(function initCharts() {
|
||||
if (typeof Chart === 'undefined') {
|
||||
setTimeout(initCharts, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
var primary = '#0f6f6a';
|
||||
var muted = '#64748b';
|
||||
var border = '#e2e8f0';
|
||||
|
||||
Chart.defaults.maintainAspectRatio = true;
|
||||
var scaleOpts = {
|
||||
x: { ticks: { color: muted, font: { size: 11 } }, grid: { color: border } },
|
||||
y: { beginAtZero: true, ticks: { color: muted, precision: 0, font: { size: 11 } }, grid: { color: border } }
|
||||
};
|
||||
|
||||
// Aktivitätsdiagramm
|
||||
var ctxAct = document.getElementById('chart-activity');
|
||||
if (ctxAct) {
|
||||
new Chart(ctxAct, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: <?= $jsChartLabels ?>,
|
||||
datasets: [
|
||||
{ label: 'Erstellt', data: <?= $jsChartCreated ?>, borderColor: primary, backgroundColor: primary + '22', fill: true, tension: .35, pointRadius: 2 },
|
||||
{ label: 'Geschlossen', data: <?= $jsChartClosed ?>, borderColor: '#22c55e', backgroundColor: '#22c55e22', fill: true, tension: .35, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: true, position: 'top', labels: { color: muted, boxWidth: 12, font: { size: 11 } } } },
|
||||
scales: scaleOpts
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Status-Balken
|
||||
var ctxStatus = document.getElementById('chart-status');
|
||||
if (ctxStatus) {
|
||||
new Chart(ctxStatus, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: <?= $jsStatusLabels ?>,
|
||||
datasets: [{ data: <?= $jsStatusData ?>, backgroundColor: ['#3b82f6','#f59e0b','#8b5cf6','#6b7280','#22c55e','#94a3b8'], borderRadius: 4 }]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: scaleOpts }
|
||||
});
|
||||
}
|
||||
|
||||
// Prioritäts-Doughnut
|
||||
var ctxPrio = document.getElementById('chart-prio');
|
||||
if (ctxPrio) {
|
||||
var prioHex = <?= $jsPrioColors ?>;
|
||||
var prioLabels = <?= $jsPrioLabels ?>;
|
||||
var pColors = prioLabels.map(function(l) { return prioHex[l] || '#64748b'; });
|
||||
new Chart(ctxPrio, {
|
||||
type: 'doughnut',
|
||||
data: { labels: prioLabels, datasets: [{ data: <?= $jsPrioData ?>, backgroundColor: pColors, borderWidth: 2 }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: muted, boxWidth: 12, font: { size: 11 } } } } }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tabs { display:flex; gap:4px; margin-bottom:14px; border-bottom:1px solid #e2e8f0; }
|
||||
.tab-btn { background:none; border:none; padding:8px 16px; cursor:pointer; font-size:.88rem; color:#64748b; border-bottom:2px solid transparent; }
|
||||
.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; }
|
||||
</style>
|
||||
<script>
|
||||
function showTab(name) {
|
||||
['dept','topic','agent'].forEach(t => {
|
||||
document.getElementById('tab-' + t).style.display = (t === name) ? 'block' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
$token = $_GET['token'] ?? $_POST['token'] ?? '';
|
||||
$email = $token ? verify_password_reset('staff', $token) : null;
|
||||
$errors = [];
|
||||
$done = false;
|
||||
|
||||
if (!$token || !$email) {
|
||||
$errors[] = 'Dieser Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.';
|
||||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$pass1 = $_POST['password'] ?? '';
|
||||
$pass2 = $_POST['password2'] ?? '';
|
||||
|
||||
$pwError = validate_password_strength($pass1);
|
||||
if ($pwError) {
|
||||
$errors[] = $pwError;
|
||||
} elseif ($pass1 !== $pass2) {
|
||||
$errors[] = 'Die Passwörter stimmen nicht überein.';
|
||||
} else {
|
||||
$pdo = get_db();
|
||||
$pdo->prepare('UPDATE staff SET password_hash = ? WHERE email = ?')
|
||||
->execute([password_hash($pass1, PASSWORD_DEFAULT), $email]);
|
||||
consume_password_reset('staff', $token);
|
||||
$staffRow = $pdo->prepare('SELECT id FROM staff WHERE email = ?');
|
||||
$staffRow->execute([$email]);
|
||||
audit_log((int) $staffRow->fetchColumn(), 'Passwort per Reset-Link zurückgesetzt');
|
||||
$done = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Passwort zurücksetzen';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Passwort zurücksetzen – Mitarbeiterbereich</title>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<?php require __DIR__ . '/../includes/brand_block.php'; ?>
|
||||
<h1>Neues Passwort festlegen</h1>
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($done): ?>
|
||||
<div class="flash success">Ihr Passwort wurde geändert. Sie können sich jetzt anmelden.</div>
|
||||
<p style="margin-top:18px;font-size:.85rem"><a href="/staff/login.php">Zur Anmeldung →</a></p>
|
||||
<?php elseif ($email): ?>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="token" value="<?= e($token) ?>">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input type="password" id="password" name="password" required autofocus>
|
||||
<label for="password2">Neues Passwort wiederholen</label>
|
||||
<input type="password" id="password2" name="password2" required>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" style="width:100%">Passwort speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p style="margin-top:18px;font-size:.85rem"><a href="/staff/forgot_password.php">Neuen Link anfordern →</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin_or_permission('manage_roles');
|
||||
$pdo = get_db();
|
||||
|
||||
$ticketPermLabels = [
|
||||
'reply_ticket' => 'Tickets beantworten',
|
||||
'close_ticket' => 'Tickets schließen/lösen',
|
||||
'delete_ticket' => 'Tickets löschen',
|
||||
'transfer_ticket' => 'Tickets übertragen (Abteilung ändern)',
|
||||
'post_note' => 'Interne Notizen schreiben',
|
||||
'assign_ticket' => 'Tickets zuweisen (Mitarbeiter/Team)',
|
||||
'manage_canned' => 'Textbausteine verwalten',
|
||||
'view_reports' => 'Berichte einsehen',
|
||||
'manage_kb' => 'Wissensdatenbank verwalten',
|
||||
'create_ticket' => 'Tickets im Namen von Kunden erstellen',
|
||||
];
|
||||
// Feinkörnige Adminportal-Rechte ("Co-Admin") – siehe admin_section_permissions() in functions.php.
|
||||
// manage_kb steckt bewusst nur oben bei den Ticket-Rechten, nicht doppelt hier.
|
||||
$adminPermLabels = admin_section_permissions();
|
||||
unset($adminPermLabels['manage_kb']);
|
||||
$permLabels = array_merge($ticketPermLabels, $adminPermLabels);
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$isActive = ($_POST['status'] ?? 'active') === 'active' ? 1 : 0;
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?');
|
||||
$dup->execute([$name, $id]);
|
||||
if ($dup->fetchColumn() > 0) $errors[] = 'Eine Rolle mit diesem Namen existiert bereits.';
|
||||
if (!$errors) {
|
||||
$perms = [];
|
||||
foreach (array_keys($permLabels) as $p) {
|
||||
$perms[$p] = !empty($_POST['perm_' . $p]);
|
||||
}
|
||||
$json = json_encode($perms);
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$name, $json, $isActive, $notes ?: null, db_now(), db_now()]);
|
||||
audit_log($staff['id'], "Rolle erstellt: {$name}");
|
||||
flash('success', 'Rolle erstellt.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE roles SET name=?, permissions=?, is_active=?, notes=?, updated_at=? WHERE id=?')
|
||||
->execute([$name, $json, $isActive, $notes ?: null, db_now(), $id]);
|
||||
audit_log($staff['id'], "Rolle aktualisiert: {$name}");
|
||||
flash('success', 'Änderungen gespeichert.');
|
||||
}
|
||||
redirect('/staff/roles.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
// Mitarbeiter und Abteilungszugriffe auf die Hauptrolle zurücksetzen
|
||||
$pdo->prepare('UPDATE staff SET role_id = NULL WHERE role_id = ?')->execute([$id]);
|
||||
$pdo->prepare('UPDATE staff_department SET role_id = NULL WHERE role_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM roles WHERE id = ?')->execute([$id]);
|
||||
audit_log($staff['id'], "Rolle gelöscht: #{$id}");
|
||||
flash('success', 'Rolle gelöscht.');
|
||||
redirect('/staff/roles.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$roles = $pdo->query(
|
||||
'SELECT r.*, (SELECT COUNT(*) FROM staff s WHERE s.role_id = r.id) + (SELECT COUNT(*) FROM staff_department sd WHERE sd.role_id = r.id) AS staff_count
|
||||
FROM roles r ORDER BY r.name'
|
||||
)->fetchAll();
|
||||
|
||||
$editRole = null;
|
||||
$editPerms = array_fill_keys(array_keys($permLabels), false);
|
||||
if ($editId) {
|
||||
$es = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); $es->execute([$editId]);
|
||||
$editRole = $es->fetch() ?: null;
|
||||
if ($editRole) $editPerms = array_merge($editPerms, json_decode($editRole['permissions'] ?? '{}', true) ?? []);
|
||||
}
|
||||
if ($_POST && $errors) foreach (array_keys($permLabels) as $p) $editPerms[$p] = !empty($_POST['perm_' . $p]);
|
||||
|
||||
$pageTitle = 'Rollen'; $activePage = 'roles';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editRole[$col] ?? $default);
|
||||
$curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editRole['is_active'] ?? 1);
|
||||
?>
|
||||
<h1>
|
||||
<?= $editRole ? 'Rolle aktualisieren' : 'Neue Rolle' ?>
|
||||
<?php if ($editRole): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editRole['name']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editRole ? 'update' : 'create' ?>">
|
||||
<?php if ($editRole): ?><input type="hidden" name="id" value="<?= (int) $editRole['id'] ?>"><?php endif; ?>
|
||||
|
||||
<?php ui_tab_bar(['role' => 'Rolle', 'perms' => 'Berechtigungen']); ?>
|
||||
|
||||
<div class="tab-pane" data-pane="role" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Rolleninformationen</h3>
|
||||
<label for="name">Name *</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" placeholder="z. B. Nur-Antwort" required>
|
||||
<?php ui_status_radios($curActive); ?>
|
||||
<p class="muted" style="font-size:.78rem;margin-top:-6px">Eine deaktivierte Rolle wird ignoriert; betroffene Mitarbeiter haben dann die Standard-Mitarbeiterrechte.</p>
|
||||
<h3>Administratornotizen <span class="muted" style="font-weight:400;font-size:.85rem">– Interne Notizen, sichtbar für alle Administratoren</span></h3>
|
||||
<textarea name="notes" rows="6"><?= e($v('notes', 'notes')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="perms" style="padding:16px 20px;display:none">
|
||||
<p class="muted" style="margin-top:0">Administratoren haben immer alle Rechte. Die Rechte einer Rolle gelten für Mitarbeiter mit dieser Rolle – je Abteilung kann ein Mitarbeiter eine andere Rolle haben (Mitarbeiter → Zugang).</p>
|
||||
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px">Tickets</h4>
|
||||
<div style="columns:2;column-gap:24px">
|
||||
<?php foreach ($ticketPermLabels as $key => $label): ?>
|
||||
<label style="display:flex;align-items:center;gap:8px;padding:4px 0;font-weight:400;cursor:pointer;break-inside:avoid">
|
||||
<input type="checkbox" name="perm_<?= $key ?>" style="width:auto" <?= ($editPerms[$key] ?? false) ? 'checked' : '' ?>> <?= e($label) ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px;margin-top:20px">Adminportal-Bereiche („Co-Admin“)</h4>
|
||||
<div style="columns:2;column-gap:24px">
|
||||
<?php foreach ($adminPermLabels as $key => $label): ?>
|
||||
<label style="display:flex;align-items:center;gap:8px;padding:4px 0;font-weight:400;cursor:pointer;break-inside:avoid">
|
||||
<input type="checkbox" name="perm_<?= $key ?>" style="width:auto" <?= ($editPerms[$key] ?? false) ? 'checked' : '' ?>> <?= e($label) ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editRole ? 'Änderungen speichern' : 'Rolle erstellen' ?></button>
|
||||
<a href="/staff/roles.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php ui_tab_assets('role'); ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">Rollen</h1>
|
||||
<a href="/staff/roles.php?new=1" class="btn">Neue Rolle hinzufügen</a>
|
||||
</div>
|
||||
<p class="muted">Benutzerdefinierte Berechtigungssets für Mitarbeiter. Ohne Rolle gelten die Standard-Mitarbeiterrechte.</p>
|
||||
|
||||
<div class="card">
|
||||
<?php $defaults = default_permissions('agent'); ?>
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-weight:600">Standard-Mitarbeiterrechte (ohne eigene Rolle)</summary>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-top:10px">
|
||||
<?php foreach ($permLabels as $key => $label): ?>
|
||||
<div style="font-size:.82rem;color:<?= ($defaults[$key] ?? false) ? 'var(--success)' : 'var(--muted)' ?>"><?= ($defaults[$key] ?? false) ? '✓' : '✕' ?> <?= e($label) ?></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Rollenname</th><th>Status</th><th style="text-align:center">Zugewiesen</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($roles as $r): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/roles.php?edit=<?= (int) $r['id'] ?>"><?= e($r['name']) ?></a></td>
|
||||
<td><strong><?= ($r['is_active'] ?? 1) ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
|
||||
<td style="text-align:center"><?= (int) $r['staff_count'] ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($r['created_at'] ?? null)) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($r['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/roles.php?edit=<?= (int) $r['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Rolle löschen? Zugeordnete Mitarbeiter erhalten wieder ihre Hauptrolle bzw. die Standardrechte.')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $r['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$roles): ?><tr><td colspan="6" class="muted">Noch keine benutzerdefinierten Rollen angelegt.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
$results = security_check_all($pdo, true);
|
||||
$counts = ['error' => 0, 'warn' => 0, 'info' => 0, 'ok' => 0];
|
||||
foreach ($results as $r) $counts[$r['level']]++;
|
||||
|
||||
$pageTitle = 'Sicherheitsprüfung';
|
||||
$activePage = 'security_check';
|
||||
require __DIR__ . '/_header.php';
|
||||
|
||||
$style = [
|
||||
'error' => ['#dc2626', '❌', 'Handeln'],
|
||||
'warn' => ['#d97706', '⚠️', 'Prüfen'],
|
||||
'info' => ['#2563eb', 'ℹ️', 'Hinweis'],
|
||||
'ok' => ['#15803d', '✅', 'In Ordnung'],
|
||||
];
|
||||
?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<h1>Sicherheitsprüfung</h1>
|
||||
<p class="muted">Prüft die Einrichtung dieser Installation und ruft interne Adressen selbst ab, um zu sehen, ob sie von außen erreichbar wären.</p>
|
||||
</div>
|
||||
<a href="/staff/security_check.php" class="btn btn-outline" style="flex-shrink:0">🔄 Erneut prüfen</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<strong><?= $counts['error'] ?></strong> Handlungsbedarf · <strong><?= $counts['warn'] ?></strong> Prüfen · <strong><?= $counts['info'] ?></strong> Hinweise · <strong><?= $counts['ok'] ?></strong> in Ordnung
|
||||
</div>
|
||||
|
||||
<?php foreach ($results as $r): [$color, $icon, $label] = $style[$r['level']]; ?>
|
||||
<div class="card" style="margin-bottom:10px;border-left:4px solid <?= $color ?>">
|
||||
<div style="font-weight:600"><?= $icon ?> <?= e($r['title']) ?> <span class="muted" style="font-weight:400;font-size:.78rem">· <?= e($label) ?></span></div>
|
||||
<?php if ($r['detail'] !== ''): ?><div class="muted" style="font-size:.88rem;margin-top:4px"><?= e($r['detail']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* staff/session_ping.php
|
||||
*
|
||||
* Wird vom Frontend per fetch() aufgerufen wenn der Nutzer aktiv ist.
|
||||
* Aktualisiert staff_last_activity und gibt die verbleibende Zeit zurück.
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Nur POST erlauben
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['error' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Muss eingeloggt sein
|
||||
if (empty($_SESSION['staff_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Nicht eingeloggt', 'remaining' => 0]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$timeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
|
||||
$last = $_SESSION['staff_last_activity'] ?? 0;
|
||||
$elapsed = time() - $last;
|
||||
|
||||
// Abgelaufen? Dann nicht mehr verlängern
|
||||
if ($last && $elapsed > ($timeout * 60)) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Session abgelaufen', 'remaining' => 0]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Aktivität erneuern
|
||||
$_SESSION['staff_last_activity'] = time();
|
||||
$remaining = $timeout * 60;
|
||||
|
||||
echo json_encode(['ok' => true, 'remaining' => $remaining]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin();
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$slug = preg_replace('/[^a-z0-9\-]/', '', strtolower(str_replace(' ', '-', trim($_POST['slug'] ?? ''))));
|
||||
$content = sanitize_staff_html(trim($_POST['content'] ?? ''));
|
||||
$showNav = !empty($_POST['show_in_nav']) ? 1 : 0;
|
||||
$isActive = ($_POST['status'] ?? 'active') === 'active' ? 1 : 0;
|
||||
$sortOrder = (int) ($_POST['sort_order'] ?? 0);
|
||||
|
||||
if ($title === '') $errors[] = 'Bitte einen Titel angeben.';
|
||||
if ($slug === '') $errors[] = 'Bitte einen URL-Slug angeben (nur a-z, 0-9, -)';
|
||||
|
||||
// Duplikat
|
||||
if (!$errors) {
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM site_pages WHERE slug = ? AND id <> ?');
|
||||
$dup->execute([$slug, $id]);
|
||||
if ((int) $dup->fetchColumn()) $errors[] = 'Dieser Slug wird bereits verwendet.';
|
||||
}
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO site_pages (slug,title,content,show_in_nav,is_active,sort_order,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)')
|
||||
->execute([$slug, $title, $content, $showNav, $isActive, $sortOrder, db_now(), db_now()]);
|
||||
audit_log($staff['id'], "Seite erstellt: {$title}");
|
||||
flash('success', 'Seite erstellt.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE site_pages SET slug=?,title=?,content=?,show_in_nav=?,is_active=?,sort_order=?,updated_at=? WHERE id=?')
|
||||
->execute([$slug, $title, $content, $showNav, $isActive, $sortOrder, db_now(), $id]);
|
||||
audit_log($staff['id'], "Seite aktualisiert: {$title}");
|
||||
flash('success', 'Änderungen gespeichert.');
|
||||
}
|
||||
redirect('/staff/site_pages.php');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$pdo->prepare('DELETE FROM site_pages WHERE id = ?')->execute([(int) $_POST['id']]);
|
||||
audit_log($staff['id'], 'Seite gelöscht: #' . (int) $_POST['id']);
|
||||
flash('success', 'Seite gelöscht.');
|
||||
redirect('/staff/site_pages.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$pages = $pdo->query('SELECT * FROM site_pages ORDER BY sort_order, title')->fetchAll();
|
||||
$editPage = null;
|
||||
if ($editId) { $es = $pdo->prepare('SELECT * FROM site_pages WHERE id = ?'); $es->execute([$editId]); $editPage = $es->fetch() ?: null; }
|
||||
|
||||
$pageTitle = 'Seiten-Verwaltung'; $activePage = 'site_pages';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editPage[$col] ?? $default);
|
||||
$curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editPage['is_active'] ?? 1);
|
||||
$showNavChecked = $_POST ? !empty($_POST['show_in_nav']) : (bool) ($editPage['show_in_nav'] ?? 1);
|
||||
// Inhalt wird beim Absenden als bereinigtes HTML gespeichert; bei Fehlern den eingegebenen Text behalten
|
||||
$contentHtml = $_POST ? sanitize_staff_html((string) ($_POST['content'] ?? '')) : (string) ($editPage['content'] ?? '');
|
||||
?>
|
||||
<h1>
|
||||
<?= $editPage ? 'Seite aktualisieren' : 'Neue Seite' ?>
|
||||
<?php if ($editPage): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editPage['title']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0" onsubmit="document.getElementById('page-content').value=document.getElementById('page-editor').innerHTML">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editPage ? 'update' : 'create' ?>">
|
||||
<?php if ($editPage): ?><input type="hidden" name="id" value="<?= (int) $editPage['id'] ?>"><?php endif; ?>
|
||||
|
||||
<?php ui_tab_bar(['page' => 'Seite', 'content' => 'Inhalt']); ?>
|
||||
|
||||
<div class="tab-pane" data-pane="page" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Seiteninformationen</h3>
|
||||
<label for="title">Titel *</label>
|
||||
<input type="text" id="title" name="title" value="<?= e($v('title', 'title')) ?>" placeholder="z. B. Impressum" required
|
||||
oninput="if(!document.getElementById('slug').dataset.manual) document.getElementById('slug').value=this.value.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')">
|
||||
<label for="slug">URL-Slug * <span class="muted" style="font-weight:400">(/page/…)</span></label>
|
||||
<input type="text" id="slug" name="slug" value="<?= e($v('slug', 'slug')) ?>" pattern="[a-z0-9\-]+" placeholder="impressum" required oninput="this.dataset.manual='1'">
|
||||
<?php ui_status_radios($curActive, 'Deaktiviert (nicht erreichbar)'); ?>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-weight:400;cursor:pointer;margin-top:6px">
|
||||
<input type="checkbox" name="show_in_nav" value="1" style="width:auto" <?= $showNavChecked ? 'checked' : '' ?>>
|
||||
Im Navigations-Footer des Portals anzeigen
|
||||
</label>
|
||||
<label for="sort_order">Sortierung</label>
|
||||
<input type="number" id="sort_order" name="sort_order" value="<?= e((string) $v('sort_order', 'sort_order', '0')) ?>" style="max-width:140px">
|
||||
<?php if ($editPage): ?>
|
||||
<p class="muted" style="font-size:.82rem;margin-top:12px">Erreichbar unter <a href="/page.php?slug=<?= e($editPage['slug']) ?>" target="_blank"><code>/page/<?= e($editPage['slug']) ?></code></a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" data-pane="content" style="padding:16px 20px;display:none">
|
||||
<div class="richtext-wrap">
|
||||
<div class="richtext-toolbar">
|
||||
<button type="button" onclick="document.execCommand('bold')"><strong>B</strong></button>
|
||||
<button type="button" onclick="document.execCommand('italic')"><em>I</em></button>
|
||||
<button type="button" onclick="document.execCommand('underline')"><u>U</u></button>
|
||||
<button type="button" onclick="document.execCommand('insertUnorderedList')">•</button>
|
||||
<button type="button" onclick="document.execCommand('insertOrderedList')">1.</button>
|
||||
<button type="button" onclick="var u=prompt('URL:','https://');if(u)document.execCommand('createLink',false,u)">🔗</button>
|
||||
</div>
|
||||
<div class="richtext-editable" id="page-editor" contenteditable="true" style="min-height:260px"><?= $contentHtml ?></div>
|
||||
</div>
|
||||
<input type="hidden" name="content" id="page-content">
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editPage ? 'Änderungen speichern' : 'Erstellen' ?></button>
|
||||
<a href="/staff/site_pages.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php ui_tab_assets('page'); ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">Seiten-Verwaltung</h1>
|
||||
<a href="/staff/site_pages.php?new=1" class="btn">Neue Seite hinzufügen</a>
|
||||
</div>
|
||||
<p class="muted">Eigene Inhaltsseiten für das Kundenportal (z. B. Impressum, Datenschutz, Willkommen). Erreichbar unter <code>/page/{slug}</code>.</p>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>URL</th><th>Navigation</th><th>Status</th><th>Erstellt</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($pages as $p): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/site_pages.php?edit=<?= (int) $p['id'] ?>"><?= e($p['title']) ?></a></td>
|
||||
<td><a href="/page.php?slug=<?= e($p['slug']) ?>" target="_blank" style="font-family:var(--font-mono);font-size:.8rem">/page/<?= e($p['slug']) ?></a></td>
|
||||
<td><?= $p['show_in_nav'] ? '✓' : '–' ?></td>
|
||||
<td><strong><?= $p['is_active'] ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($p['created_at'] ?? null)) ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($p['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/site_pages.php?edit=<?= (int) $p['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Seite wirklich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $p['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$pages): ?><tr><td colspan="7" class="muted">Noch keine Seiten angelegt.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin_or_permission('manage_priorities');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
|
||||
try {
|
||||
sla_plan_save($pdo, $id, [
|
||||
'name' => $_POST['name'] ?? '', 'response_hours' => $_POST['response_hours'] ?? 0, 'resolution_hours' => $_POST['resolution_hours'] ?? 0,
|
||||
'is_active' => ($_POST['status'] ?? 'active') === 'active', 'notes' => $_POST['notes'] ?? '',
|
||||
]);
|
||||
audit_log($staff['id'], ($action === 'create' ? 'SLA-Plan erstellt: ' : 'SLA-Plan aktualisiert: ') . trim($_POST['name'] ?? ''));
|
||||
flash('success', $action === 'create' ? 'SLA-Plan erstellt.' : 'Änderungen gespeichert. Sie gelten für neue Tickets und bei Prioritätsänderung.');
|
||||
redirect('/staff/sla_plans.php');
|
||||
} catch (AdminModelException $e) {
|
||||
$errors[] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$deleted = sla_plan_delete($pdo, (int) $_POST['id']);
|
||||
audit_log($staff['id'], "SLA-Plan gelöscht: {$deleted}");
|
||||
flash('success', 'SLA-Plan gelöscht. Betroffene Abteilungen und Themen nutzen wieder die Zeiten der Priorität.');
|
||||
redirect('/staff/sla_plans.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$plans = $pdo->query(
|
||||
'SELECT p.*,
|
||||
(SELECT COUNT(*) FROM departments d WHERE d.sla_plan_id = p.id) AS dept_count,
|
||||
(SELECT COUNT(*) FROM help_topics h WHERE h.sla_plan_id = p.id) AS topic_count
|
||||
FROM sla_plans p ORDER BY p.name'
|
||||
)->fetchAll();
|
||||
$editPlan = null;
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM sla_plans WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editPlan = $stmt->fetch() ?: null;
|
||||
}
|
||||
$hours = fn(int $h) => $h >= 24 && $h % 24 === 0 ? ($h / 24) . ' Tag' . ($h / 24 === 1 ? '' : 'e') : $h . ' Std';
|
||||
|
||||
$pageTitle = 'SLA-Pläne';
|
||||
$activePage = 'sla_plans';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editPlan[$col] ?? $default);
|
||||
$curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editPlan['is_active'] ?? 1);
|
||||
?>
|
||||
<h1>
|
||||
<?= $editPlan ? 'SLA-Plan aktualisieren' : 'Neuer SLA-Plan' ?>
|
||||
<?php if ($editPlan): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editPlan['name']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editPlan ? 'update' : 'create' ?>">
|
||||
<?php if ($editPlan): ?><input type="hidden" name="id" value="<?= (int) $editPlan['id'] ?>"><?php endif; ?>
|
||||
|
||||
<?php ui_tab_bar(['plan' => 'SLA-Plan']); ?>
|
||||
<div class="tab-pane" data-pane="plan" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Planinformationen</h3>
|
||||
<label for="name">Name *</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" placeholder="z. B. Premium-Support" required>
|
||||
<?php ui_status_radios($curActive); ?>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:520px">
|
||||
<div><label for="response_hours">Reaktionszeit (Std) *</label>
|
||||
<input type="number" min="1" id="response_hours" name="response_hours" value="<?= e((string) $v('response_hours', 'response_hours', '4')) ?>" required></div>
|
||||
<div><label for="resolution_hours">Lösungszeit (Std) *</label>
|
||||
<input type="number" min="1" id="resolution_hours" name="resolution_hours" value="<?= e((string) $v('resolution_hours', 'resolution_hours', '24')) ?>" required></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:.82rem">Der Plan ersetzt die Zeiten der Priorität für Tickets, deren <strong>Hilfethema</strong> oder <strong>Abteilung</strong> ihn zugeordnet hat (Hilfethema vor Abteilung). Mit aktivierten <a href="/staff/business_hours.php">Geschäftszeiten</a> zählen nur Öffnungszeiten.</p>
|
||||
<h3>Administratornotizen <span class="muted" style="font-weight:400;font-size:.85rem">– Interne Notizen</span></h3>
|
||||
<textarea name="notes" rows="5"><?= e($v('notes', 'notes')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editPlan ? 'Änderungen speichern' : 'Erstellen' ?></button>
|
||||
<a href="/staff/sla_plans.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php ui_tab_assets('plan'); ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">SLA-Pläne</h1>
|
||||
<a href="/staff/sla_plans.php?new=1" class="btn">Neuen SLA-Plan hinzufügen</a>
|
||||
</div>
|
||||
<p class="muted">Benannte Reaktions- und Lösungszeiten, die du Abteilungen und Hilfethemen zuordnest. Ohne Zuordnung gelten die Zeiten der <a href="/staff/priorities.php">Priorität</a>.</p>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Reaktion</th><th>Lösung</th><th style="text-align:center">Abteilungen</th><th style="text-align:center">Hilfethemen</th><th>Zuletzt aktualisiert</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($plans as $p): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/sla_plans.php?edit=<?= (int) $p['id'] ?>"><?= e($p['name']) ?></a></td>
|
||||
<td><strong><?= $p['is_active'] ? 'Aktiv' : 'Deaktiviert' ?></strong></td>
|
||||
<td><?= e($hours((int) $p['response_hours'])) ?></td>
|
||||
<td><?= e($hours((int) $p['resolution_hours'])) ?></td>
|
||||
<td style="text-align:center"><?= (int) $p['dept_count'] ?></td>
|
||||
<td style="text-align:center"><?= (int) $p['topic_count'] ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e(ui_date($p['updated_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/sla_plans.php?edit=<?= (int) $p['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('SLA-Plan wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $p['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$plans): ?><tr><td colspan="8" class="muted">Noch keine SLA-Pläne angelegt.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$me = require_admin_or_permission('manage_staff');
|
||||
$isFullAdmin = $me['role'] === 'admin';
|
||||
$canManageTeams = has_permission($me, 'manage_teams');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
$departments = $pdo->query('SELECT * FROM departments ORDER BY name')->fetchAll();
|
||||
$customRoles = $pdo->query('SELECT * FROM roles ORDER BY name')->fetchAll();
|
||||
$teams = $pdo->query('SELECT id, name, is_active FROM teams ORDER BY name')->fetchAll();
|
||||
|
||||
$permLabels = [
|
||||
'reply_ticket' => 'Tickets beantworten',
|
||||
'close_ticket' => 'Tickets schließen/lösen',
|
||||
'delete_ticket' => 'Tickets löschen',
|
||||
'transfer_ticket' => 'Tickets übertragen',
|
||||
'post_note' => 'Interne Notizen schreiben',
|
||||
'assign_ticket' => 'Tickets zuweisen',
|
||||
'manage_canned' => 'Textbausteine verwalten',
|
||||
'view_reports' => 'Berichte einsehen',
|
||||
'manage_kb' => 'Wissensdatenbank verwalten',
|
||||
'create_ticket' => 'Tickets im Namen von Kunden erstellen',
|
||||
] + array_diff_key(admin_section_permissions(), ['manage_kb' => 1]);
|
||||
|
||||
/** Setzt die zusätzlichen Abteilungen (Erweiterter Zugriff) neu. */
|
||||
function save_staff_access(PDO $pdo, int $id, ?int $primaryDeptId, array $post): void
|
||||
{
|
||||
$selected = array_unique(array_filter(array_map('intval', $post['extra_departments'] ?? [])));
|
||||
$pdo->prepare('DELETE FROM staff_department WHERE staff_id = ?')->execute([$id]);
|
||||
$ins = $pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,?,?)');
|
||||
$validRoles = array_map('intval', $pdo->query('SELECT id FROM roles')->fetchAll(PDO::FETCH_COLUMN));
|
||||
foreach ($selected as $dId) {
|
||||
if ($dId === (int) $primaryDeptId) continue; // Hauptabteilung wird nicht doppelt gespeichert
|
||||
$rid = (int) ($post['role_' . $dId] ?? 0);
|
||||
$ins->execute([$id, $dId, !empty($post['notify_' . $dId]) ? 1 : 0, in_array($rid, $validRoles, true) ? $rid : null]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Setzt die Team-Mitgliedschaften eines Mitarbeiters neu. */
|
||||
function save_staff_teams(PDO $pdo, int $id, array $teamIds): void
|
||||
{
|
||||
$pdo->prepare('DELETE FROM team_members WHERE staff_id = ?')->execute([$id]);
|
||||
$ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)');
|
||||
foreach (array_unique(array_filter(array_map('intval', $teamIds))) as $tid) $ins->execute([$tid, $id]);
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'create' || $action === 'update') {
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
// Nur echte Administratoren dürfen die Administrator-Rolle vergeben – sonst könnte sich ein
|
||||
// per Co-Admin-Recht (nur manage_staff) berechtigter Agent selbst (oder andere) zum
|
||||
// Vollzugriff-Admin machen. Beim Bearbeiten durch einen Co-Admin bleibt die vorhandene
|
||||
// Rolle des Ziel-Kontos unverändert, statt sie stillschweigend auf "Mitarbeiter" zu setzen.
|
||||
if ($isFullAdmin) {
|
||||
$role = ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'agent';
|
||||
} elseif ($action === 'update') {
|
||||
$currentRoleStmt = $pdo->prepare('SELECT role FROM staff WHERE id = ?');
|
||||
$currentRoleStmt->execute([$id]);
|
||||
$role = $currentRoleStmt->fetchColumn() === 'admin' ? 'admin' : 'agent';
|
||||
} else {
|
||||
$role = 'agent';
|
||||
}
|
||||
$roleId = ($_POST['role_id'] ?? '') !== '' ? (int) $_POST['role_id'] : null;
|
||||
$deptId = ($_POST['department_id'] ?? '') !== '' ? (int) $_POST['department_id'] : null;
|
||||
$isActive = ($_POST['status'] ?? 'active') === 'active' ? 1 : 0;
|
||||
$password = $_POST['password'] ?? '';
|
||||
$signature = sanitize_staff_html(trim($_POST['signature'] ?? ''));
|
||||
|
||||
if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige E-Mail-Adresse angeben.';
|
||||
if ($action === 'create' || $password !== '') {
|
||||
$pwErr = validate_password_strength($password);
|
||||
if ($pwErr) $errors[] = $pwErr;
|
||||
}
|
||||
if ($action === 'update' && $id === (int) $me['id'] && !$isActive) $errors[] = 'Sie können Ihr eigenes Konto nicht deaktivieren.';
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id <> ?');
|
||||
$dup->execute([$email, $id]);
|
||||
if ($dup->fetchColumn() > 0) $errors[] = 'Ein Mitarbeiter mit dieser E-Mail-Adresse existiert bereits.';
|
||||
|
||||
if (!$errors) {
|
||||
if ($action === 'create') {
|
||||
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, role_id, department_id, signature, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$name, $email, password_hash($password, PASSWORD_DEFAULT), $role, $roleId, $deptId, $signature ?: null, $isActive, db_now()]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
audit_log($me['id'], "Mitarbeiter angelegt: {$name} ({$email})");
|
||||
flash('success', 'Mitarbeiter angelegt.');
|
||||
} else {
|
||||
$sql = 'UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=?';
|
||||
$params = [$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive];
|
||||
if ($password !== '') { $sql .= ', password_hash=?'; $params[] = password_hash($password, PASSWORD_DEFAULT); }
|
||||
$params[] = $id;
|
||||
$pdo->prepare($sql . ' WHERE id=?')->execute($params);
|
||||
audit_log($me['id'], "Mitarbeiter aktualisiert: {$name} ({$email})");
|
||||
flash('success', 'Änderungen gespeichert.');
|
||||
}
|
||||
save_staff_access($pdo, $id, $deptId, $_POST);
|
||||
if ($canManageTeams) save_staff_teams($pdo, $id, (array) ($_POST['teams'] ?? []));
|
||||
redirect('/staff/staff_manage.php');
|
||||
}
|
||||
}
|
||||
|
||||
// Eigenständiger Zugriffs-Speicher-Aufruf (Rückwärtskompatibilität)
|
||||
if ($action === 'save_access') {
|
||||
$id = (int) $_POST['id'];
|
||||
$stmt = $pdo->prepare('SELECT department_id FROM staff WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
save_staff_access($pdo, $id, (int) $stmt->fetchColumn(), $_POST);
|
||||
audit_log($me['id'], "Abteilungszugriff geändert für Mitarbeiter #{$id}");
|
||||
flash('success', 'Abteilungszugriff gespeichert.');
|
||||
redirect('/staff/staff_manage.php?edit=' . $id . '&tab=access');
|
||||
}
|
||||
|
||||
if ($action === 'delete') {
|
||||
$id = (int) $_POST['id'];
|
||||
if ($id === (int) $me['id']) {
|
||||
flash('error', 'Sie können Ihr eigenes Konto nicht löschen.');
|
||||
} else {
|
||||
$pdo->prepare('UPDATE tickets SET assigned_staff_id = NULL WHERE assigned_staff_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM staff WHERE id = ?')->execute([$id]);
|
||||
audit_log($me['id'], "Mitarbeiter gelöscht: #{$id}");
|
||||
flash('success', 'Mitarbeiter gelöscht.');
|
||||
}
|
||||
redirect('/staff/staff_manage.php');
|
||||
}
|
||||
}
|
||||
|
||||
$isNew = isset($_GET['new']);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
|
||||
|
||||
$editStaff = null;
|
||||
$extraAccess = []; // department_id => ['notify' => 0/1, 'role_id' => ?int]
|
||||
$memberTeams = [];
|
||||
if ($editId) {
|
||||
$stmt = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$editStaff = $stmt->fetch() ?: null;
|
||||
if ($editStaff) {
|
||||
$stmt = $pdo->prepare('SELECT department_id, notify, role_id FROM staff_department WHERE staff_id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
foreach ($stmt->fetchAll() as $row) $extraAccess[(int) $row['department_id']] = ['notify' => (int) $row['notify'], 'role_id' => $row['role_id'] !== null ? (int) $row['role_id'] : null];
|
||||
$stmt = $pdo->prepare('SELECT team_id FROM team_members WHERE staff_id = ?');
|
||||
$stmt->execute([$editId]);
|
||||
$memberTeams = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
}
|
||||
if ($_POST && $errors) { // eingegebene Auswahl bei Fehlern erhalten
|
||||
$extraAccess = [];
|
||||
foreach (array_filter(array_map('intval', $_POST['extra_departments'] ?? [])) as $dId) {
|
||||
$extraAccess[$dId] = ['notify' => !empty($_POST['notify_' . $dId]) ? 1 : 0, 'role_id' => ($_POST['role_' . $dId] ?? '') !== '' ? (int) $_POST['role_' . $dId] : null];
|
||||
}
|
||||
$memberTeams = array_map('intval', (array) ($_POST['teams'] ?? []));
|
||||
}
|
||||
|
||||
$allStaff = $pdo->query(
|
||||
'SELECT s.*, d.name AS department_name,
|
||||
(SELECT COUNT(*) FROM team_members tm WHERE tm.staff_id = s.id) AS team_count
|
||||
FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name'
|
||||
)->fetchAll();
|
||||
$fmt = fn(?string $dt) => $dt ? date('d.m.Y', strtotime($dt)) : '–';
|
||||
|
||||
$pageTitle = 'Mitarbeiter';
|
||||
$activePage = 'staff';
|
||||
require __DIR__ . '/_header.php';
|
||||
?>
|
||||
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if ($showForm): ?>
|
||||
<?php
|
||||
$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
|
||||
$v = fn(string $k, string $col, $default = '') => $f($k, $editStaff[$col] ?? $default);
|
||||
$curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editStaff['is_active'] ?? 1) ? 'active' : 'locked');
|
||||
$curRole = (string) $v('role', 'role', 'agent');
|
||||
$curRoleId = (string) $v('role_id', 'role_id');
|
||||
$curDept = (string) $v('department_id', 'department_id');
|
||||
$startTab = in_array($_GET['tab'] ?? '', ['account', 'access', 'perms', 'teams'], true) ? $_GET['tab'] : 'account';
|
||||
$rolePerms = [];
|
||||
foreach ($customRoles as $r) $rolePerms[$r['id']] = json_decode($r['permissions'], true) ?: [];
|
||||
$agentDefaults = default_permissions('agent');
|
||||
$adminDefaults = default_permissions('admin');
|
||||
?>
|
||||
<h1>
|
||||
<?= $editStaff ? 'Agenten verwalten' : 'Neuer Agent' ?>
|
||||
<?php if ($editStaff): ?><span class="muted" style="font-size:1rem;font-weight:400">— <?= e($editStaff['name']) ?></span><?php endif; ?>
|
||||
</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:0" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="<?= $editStaff ? 'update' : 'create' ?>">
|
||||
<?php if ($editStaff): ?><input type="hidden" name="id" value="<?= (int) $editStaff['id'] ?>"><?php endif; ?>
|
||||
|
||||
<div style="display:flex;gap:4px;padding:10px 12px 0;border-bottom:1px solid var(--border)">
|
||||
<button type="button" class="tab-btn" data-tab="account">Konto</button>
|
||||
<button type="button" class="tab-btn" data-tab="access">Zugang</button>
|
||||
<button type="button" class="tab-btn" data-tab="perms">Berechtigungen</button>
|
||||
<button type="button" class="tab-btn" data-tab="teams">Teams</button>
|
||||
</div>
|
||||
|
||||
<!-- Konto -->
|
||||
<div class="tab-pane" data-pane="account" style="padding:16px 20px">
|
||||
<h3 style="margin-top:0">Kontoinformationen</h3>
|
||||
<label for="name">Name *</label>
|
||||
<input type="text" id="name" name="name" value="<?= e($v('name', 'name')) ?>" required>
|
||||
<label for="email">E-Mail-Adresse *</label>
|
||||
<input type="email" id="email" name="email" value="<?= e($v('email', 'email')) ?>" required>
|
||||
<label>Status *</label>
|
||||
<div style="display:flex;gap:18px;margin:4px 0 10px">
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="active" style="width:auto" <?= $curStatus === 'active' ? 'checked' : '' ?>> <strong>Aktiv</strong></label>
|
||||
<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="radio" name="status" value="locked" style="width:auto" <?= $curStatus !== 'active' ? 'checked' : '' ?>> Gesperrt</label>
|
||||
</div>
|
||||
<label for="password"><?= $editStaff ? 'Neues Passwort (leer = unverändert)' : 'Passwort *' ?></label>
|
||||
<input type="password" id="password" name="password" autocomplete="new-password" <?= $editStaff ? '' : 'required' ?>>
|
||||
<label for="signature">Signatur (E-Mail-Fuß für eigene Antworten)</label>
|
||||
<textarea id="signature" name="signature" data-richtext data-image-upload-url="/staff/upload_signature_image.php" style="min-height:90px" placeholder="z. B. Mit freundlichen Grüßen Max Mustermann"><?= e($v('signature', 'signature')) ?></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Zugang -->
|
||||
<div class="tab-pane" data-pane="access" style="padding:16px 20px;display:none">
|
||||
<h3 style="margin-top:0">Zugang</h3>
|
||||
<p class="muted" style="margin-top:0">Wähle die Abteilungen, zu denen der Agent Zugang haben soll.</p>
|
||||
|
||||
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px">Hauptabteilung <span style="color:#dc2626">*</span></h4>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<select name="department_id" id="department_id" style="max-width:320px">
|
||||
<option value="">–</option>
|
||||
<?php foreach ($departments as $d): ?>
|
||||
<option value="<?= $d['id'] ?>" <?= $curDept === (string) $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<span class="muted" style="font-size:.85rem">Rolle: <strong id="primary-role-label">–</strong> <span style="font-size:.78rem">(Reiter „Berechtigungen“)</span></span>
|
||||
</div>
|
||||
|
||||
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px;margin-top:22px">Erweiterter Zugriff</h4>
|
||||
<div id="extra-rows"></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:10px">
|
||||
<select id="extra-add" style="max-width:260px"><option value="">— Abteilung auswählen —</option></select>
|
||||
<button type="button" class="btn secondary" id="extra-add-btn">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Berechtigungen -->
|
||||
<div class="tab-pane" data-pane="perms" style="padding:16px 20px;display:none">
|
||||
<h3 style="margin-top:0">Berechtigungen</h3>
|
||||
<label for="role">Kontotyp</label>
|
||||
<select name="role" id="role" style="max-width:320px" <?= $isFullAdmin ? '' : 'disabled' ?>>
|
||||
<option value="agent" <?= $curRole === 'agent' ? 'selected' : '' ?>>Mitarbeiter</option>
|
||||
<option value="admin" <?= $curRole === 'admin' ? 'selected' : '' ?>>Administrator (alle Rechte)</option>
|
||||
</select>
|
||||
<?php if (!$isFullAdmin): ?>
|
||||
<input type="hidden" name="role" value="<?= e($curRole) ?>">
|
||||
<p class="muted" style="font-size:.78rem;margin-top:2px">Nur Administratoren können die Administrator-Rolle vergeben.</p>
|
||||
<?php endif; ?>
|
||||
<div id="role-id-box">
|
||||
<label for="role_id">Rolle (für Mitarbeiter)</label>
|
||||
<select name="role_id" id="role_id" style="max-width:320px">
|
||||
<option value="">– Standard-Mitarbeiterrechte –</option>
|
||||
<?php foreach ($customRoles as $cr): ?>
|
||||
<option value="<?= $cr['id'] ?>" <?= $curRoleId === (string) $cr['id'] ? 'selected' : '' ?>><?= e($cr['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="muted" style="font-size:.78rem">Rollen legst du unter <a href="/staff/roles.php">Rollen</a> an.</p>
|
||||
</div>
|
||||
<h4 style="border-bottom:1px dotted var(--border);padding-bottom:4px">Wirksame Rechte</h4>
|
||||
<div id="perm-list" style="columns:2;column-gap:24px;font-size:.88rem"></div>
|
||||
</div>
|
||||
|
||||
<!-- Teams -->
|
||||
<div class="tab-pane" data-pane="teams" style="padding:16px 20px;display:none">
|
||||
<h3 style="margin-top:0">Teams</h3>
|
||||
<?php if (!$teams): ?>
|
||||
<p class="muted">Es sind noch keine Teams angelegt. Teams legst du unter <a href="/staff/teams.php">Teams</a> an.</p>
|
||||
<?php else: ?>
|
||||
<p class="muted" style="margin-top:0">Der Agent ist Mitglied in folgenden Teams.<?= $canManageTeams ? '' : ' (Zum Ändern fehlt das Recht „Teams verwalten“.)' ?></p>
|
||||
<?php foreach ($teams as $t): ?>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin:6px 0">
|
||||
<input type="checkbox" name="teams[]" value="<?= $t['id'] ?>" style="width:auto" <?= in_array((int) $t['id'], $memberTeams, true) ? 'checked' : '' ?> <?= $canManageTeams ? '' : 'disabled' ?>>
|
||||
<?= e($t['name']) ?><?= $t['is_active'] ? '' : ' <span class="muted">(deaktiviert)</span>' ?>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="padding:0 20px 18px">
|
||||
<button type="submit" class="btn"><?= $editStaff ? 'Änderungen speichern' : 'Agent anlegen' ?></button>
|
||||
<a href="/staff/staff_manage.php" class="btn secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.tab-btn{background:none;border:1px solid transparent;border-bottom:none;padding:8px 16px;cursor:pointer;border-radius:6px 6px 0 0;color:var(--muted)}
|
||||
.tab-btn.active{background:var(--card-bg,#fff);border-color:var(--border);color:inherit;font-weight:600;position:relative;top:1px}
|
||||
.extra-row{display:flex;align-items:center;gap:12px;padding:6px 0;border-bottom:1px dotted var(--border)}
|
||||
.extra-row .nm{flex:1}
|
||||
.extra-row button{background:none;border:none;color:#dc2626;cursor:pointer;font-size:1rem}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var btns = document.querySelectorAll('.tab-btn'), panes = document.querySelectorAll('.tab-pane');
|
||||
function show(t) {
|
||||
btns.forEach(function (b) { b.classList.toggle('active', b.dataset.tab === t); });
|
||||
panes.forEach(function (p) { p.style.display = p.dataset.pane === t ? '' : 'none'; });
|
||||
}
|
||||
btns.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
|
||||
show(<?= json_encode($startTab) ?>);
|
||||
|
||||
// Erweiterter Zugriff
|
||||
var depts = <?= json_encode(array_map(fn($d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments), JSON_UNESCAPED_UNICODE) ?>;
|
||||
var extra = <?= json_encode((object) $extraAccess) ?>; // id -> {notify, role_id}
|
||||
var roles = <?= json_encode(array_map(fn($r) => ['id' => (int) $r['id'], 'name' => $r['name']], $customRoles), JSON_UNESCAPED_UNICODE) ?>;
|
||||
var rows = document.getElementById('extra-rows'), addSel = document.getElementById('extra-add'), primary = document.getElementById('department_id');
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
function render() {
|
||||
var p = primary.value;
|
||||
delete extra[p]; // Hauptabteilung nicht doppelt
|
||||
rows.innerHTML = '';
|
||||
depts.forEach(function (d) {
|
||||
if (!(d.id in extra)) return;
|
||||
var div = document.createElement('div'); div.className = 'extra-row';
|
||||
var opts = '<option value="">Hauptrolle</option>' + roles.map(function (r) { return '<option value="' + r.id + '"' + (String(extra[d.id].role_id) === String(r.id) ? ' selected' : '') + '>' + esc(r.name) + '</option>'; }).join('');
|
||||
div.innerHTML = '<span class="nm">' + esc(d.name) + '<input type="hidden" name="extra_departments[]" value="' + d.id + '"></span>' +
|
||||
'<select name="role_' + d.id + '" style="max-width:200px" title="Rolle in dieser Abteilung">' + opts + '</select>' +
|
||||
'<label style="display:flex;align-items:center;gap:6px;margin:0"><input type="checkbox" name="notify_' + d.id + '" style="width:auto"' + (extra[d.id].notify ? ' checked' : '') + '> Benachrichtigungen</label>' +
|
||||
'<button type="button" title="Entfernen" aria-label="Entfernen">🗑</button>';
|
||||
div.querySelector('input[type=checkbox]').addEventListener('change', function (e) { extra[d.id].notify = e.target.checked ? 1 : 0; });
|
||||
div.querySelector('select').addEventListener('change', function (e) { extra[d.id].role_id = e.target.value === '' ? null : parseInt(e.target.value, 10); });
|
||||
div.querySelector('button').addEventListener('click', function () { delete extra[d.id]; render(); });
|
||||
rows.appendChild(div);
|
||||
});
|
||||
addSel.innerHTML = '<option value="">— Abteilung auswählen —</option>';
|
||||
depts.forEach(function (d) { if (!(d.id in extra) && String(d.id) !== p) addSel.insertAdjacentHTML('beforeend', '<option value="' + d.id + '">' + esc(d.name) + '</option>'); });
|
||||
}
|
||||
document.getElementById('extra-add-btn').addEventListener('click', function () { if (addSel.value) { extra[addSel.value] = { notify: 1, role_id: null }; render(); } });
|
||||
primary.addEventListener('change', render);
|
||||
render();
|
||||
|
||||
// Wirksame Rechte
|
||||
var labels = <?= json_encode($permLabels, JSON_UNESCAPED_UNICODE) ?>;
|
||||
var roleDefs = <?= json_encode((object) $rolePerms) ?>, agentDef = <?= json_encode($agentDefaults) ?>, adminDef = <?= json_encode($adminDefaults) ?>;
|
||||
var roleSel = document.getElementById('role'), roleIdSel = document.getElementById('role_id'), list = document.getElementById('perm-list');
|
||||
function perms() {
|
||||
if (roleSel.value === 'admin') return adminDef;
|
||||
return roleIdSel.value && roleDefs[roleIdSel.value] ? roleDefs[roleIdSel.value] : agentDef;
|
||||
}
|
||||
function primaryRoleLabel() {
|
||||
if (roleSel.value === 'admin') return 'Administrator';
|
||||
var r = roles.filter(function (x) { return String(x.id) === roleIdSel.value; })[0];
|
||||
return r ? r.name : 'Standard-Mitarbeiterrechte';
|
||||
}
|
||||
function renderPerms() {
|
||||
document.getElementById('primary-role-label').textContent = primaryRoleLabel();
|
||||
document.getElementById('role-id-box').style.display = roleSel.value === 'admin' ? 'none' : '';
|
||||
var p = perms(); list.innerHTML = '';
|
||||
Object.keys(labels).forEach(function (k) {
|
||||
list.insertAdjacentHTML('beforeend', '<div style="padding:2px 0">' + (p[k] ? '✓' : '<span class="muted">✗</span>') + ' ' + esc(labels[k]) + '</div>');
|
||||
});
|
||||
}
|
||||
roleSel.addEventListener('change', renderPerms); roleIdSel.addEventListener('change', renderPerms); renderPerms();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php else: ?>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h1 style="margin:0">Mitarbeiter</h1>
|
||||
<a href="/staff/staff_manage.php?new=1" class="btn">Neuen Agenten hinzufügen</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:14px">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>E-Mail</th><th>Typ</th><th>Status</th><th>Hauptabteilung</th><th style="text-align:center">Teams</th><th>Erstellt</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($allStaff as $s): ?>
|
||||
<tr>
|
||||
<td><a href="/staff/staff_manage.php?edit=<?= (int) $s['id'] ?>"><?= e($s['name']) ?></a></td>
|
||||
<td><?= e($s['email']) ?></td>
|
||||
<td><?= $s['role'] === 'admin' ? 'Administrator' : 'Mitarbeiter' ?></td>
|
||||
<td><strong><?= $s['is_active'] ? 'Aktiv' : 'Gesperrt' ?></strong></td>
|
||||
<td><?= e($s['department_name'] ?? '–') ?></td>
|
||||
<td style="text-align:center"><?= (int) $s['team_count'] ?></td>
|
||||
<td class="muted" style="font-size:.82rem"><?= e($fmt($s['created_at'] ?? null)) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/staff/staff_manage.php?edit=<?= (int) $s['id'] ?>">Bearbeiten</a> ·
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Mitarbeiter wirklich löschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="id" value="<?= (int) $s['id'] ?>">
|
||||
<button type="submit" style="background:none;border:none;color:#dc2626;cursor:pointer;padding:0;font-size:.9rem">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/_footer.php'; ?>
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* DeskHub Service Worker – Mitarbeiterbereich (scope: /staff/)
|
||||
*/
|
||||
const CACHE = 'deskhub-staff-v2';
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
const PRECACHE = ['/offline.html', '/assets/css/style.css'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
if (e.request.method !== 'GET') return;
|
||||
|
||||
if (e.request.url.match(/\.(css|js|woff2?|png|svg|ico|webp)(\?.*)?$/)) {
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(cached =>
|
||||
cached || fetch(e.request).then(resp => {
|
||||
caches.open(CACHE).then(c => c.put(e.request, resp.clone()));
|
||||
return resp;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
e.respondWith(
|
||||
fetch(e.request).catch(() => caches.match(OFFLINE_URL))
|
||||
);
|
||||
});
|
||||
|
||||
// ── Push Notifications ────────────────────────────────────────────────────────
|
||||
self.addEventListener('push', e => {
|
||||
let data = { title: 'DeskHub', body: 'Neue Benachrichtigung', url: '/staff/', icon: '/assets/icons/icon-192.png' };
|
||||
try { data = Object.assign(data, e.data.json()); } catch {}
|
||||
e.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: data.icon,
|
||||
badge: data.badge || '/assets/icons/icon-192.png',
|
||||
data: { url: data.url },
|
||||
vibrate: [150, 50, 150],
|
||||
tag: 'deskhub-' + (data.url || ''),
|
||||
renotify: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', e => {
|
||||
e.notification.close();
|
||||
const url = e.notification.data?.url || '/staff/';
|
||||
e.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
|
||||
for (const c of list) {
|
||||
if (c.url.includes('/staff/') && 'focus' in c) {
|
||||
c.navigate(url);
|
||||
return c.focus();
|
||||
}
|
||||
}
|
||||
return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user