Upload via GUI (360 Dateien)

This commit is contained in:
2026-10-30 21:50:10 +00:00
parent fa7cb047a9
commit 2ee9be11c0
2 changed files with 486 additions and 431 deletions
+429 -428
View File
@@ -1,429 +1,430 @@
<?php
/** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */
$staff = current_staff();
$activePage = $activePage ?? '';
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?></title>
<meta name="csrf" content="<?= csrf_token() ?>">
<?php
// VAPID Public Key – aus Config oder automatisch aus DB
if (file_exists(__DIR__ . '/../includes/push.php')) {
require_once __DIR__ . '/../includes/push.php';
$__vapidKeys = get_or_create_vapid_keys();
$pushKey = $__vapidKeys['public_key'] ?? '';
} else {
$pushKey = config()['push']['public_key'] ?? '';
}
?>
<?php if ($pushKey): ?>
<meta name="push-key" content="<?= e($pushKey) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<?= site_icon_links() ?>
<link rel="manifest" href="/manifest-staff.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){});
}
</script>
</head>
<body>
<script>try { if (localStorage.getItem('deskhub_sidebar') === 'closed') document.documentElement.classList.add('sidebar-closed'); } catch (e) {}</script>
<button type="button" id="sidebar-open" class="sidebar-toggle sidebar-toggle-open" aria-label="Seitenleiste einblenden" title="Seitenleiste einblenden" onclick="document.getElementById('sidebar-toggle').click()">&#9776;</button>
<div class="app-shell">
<aside class="sidebar" id="sidebar">
<button type="button" id="sidebar-toggle" class="sidebar-toggle sidebar-toggle-close" aria-label="Seitenleiste einklappen" title="Seitenleiste einklappen">&laquo;</button>
<div class="brand">
<?php $logo = get_setting('logo_path'); ?>
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>" style="max-height:34px; max-width:180px; display:block">
<?php else: ?>
Desk<span>Hub</span>
<?php endif; ?>
</div>
<nav>
<?php // Übersicht + ehemals separates "Berichte" wurden zu einer Seite zusammengelegt
// (siehe staff/reports.php) – der Login landete ohnehin schon immer dort. ?>
<a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a>
<a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a>
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
<a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a>
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
<?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?>
<?php if (get_setting('live_chat_enabled', '0') === '1' && $staff): ?>
<?php
// Badge = Anzahl Live-Chats, deren letzte Nachricht vom Kunden kam (also unbeantwortet),
// auf die eigenen Abteilungen begrenzt (wie die Ticketliste selbst).
[$__lcDeptSql, $__lcDeptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
$__lcSql = "SELECT COUNT(*) FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed'
AND (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) = 'customer'"
. $__lcDeptSql; // enthält bereits das führende " AND " (dept_filter_sql)
$__lcStmt = get_db()->prepare($__lcSql);
$__lcStmt->execute($__lcDeptParams);
$__lcPending = (int) $__lcStmt->fetchColumn();
?>
<a href="/staff/live_chat.php" class="<?= $activePage==='live_chat'?'active':'' ?>">
Live-Chat<?php if ($__lcPending): ?> <span class="nav-badge" style="background:var(--danger, #dc2626); color:#fff; border-radius:10px; min-width:18px; height:18px; font-size:.68rem; font-weight:700; display:inline-flex; align-items:center; justify-content:center; padding:0 5px; margin-left:4px; vertical-align:middle"><?= $__lcPending > 9 ? '9+' : $__lcPending ?></span><?php endif; ?>
</a>
<?php endif; ?>
<?php
// Adminportal auch für "Co-Admins" zeigen: Agenten, denen per benutzerdefinierter Rolle
// gezielt einzelne Adminportal-Rechte zugewiesen wurden (admin_section_permissions()).
// has_permission() liefert für echte Admins ohnehin immer true, daher genügt die reine
// Rechteprüfung je Link – kein zusätzliches role==='admin' nötig.
$hasAnyAdminAccess = false;
if ($staff) {
foreach (array_keys(admin_section_permissions()) as $__perm) {
if (has_permission($staff, $__perm)) { $hasAnyAdminAccess = true; break; }
}
}
?>
<?php if ($hasAnyAdminAccess): ?>
<div class="nav-section-label" id="admin-toggle" role="button" tabindex="0">
Adminportal <span class="nav-toggle-chevron">▾</span>
</div>
<div class="nav-admin-block" id="admin-block">
<span class="nav-group-label">Verwalten</span>
<?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><a href="/staff/problems.php" class="<?= $activePage==='problems'?'active':'' ?>">Problemmanagement</a><?php endif; ?>
<?php
/** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */
$staff = current_staff();
$activePage = $activePage ?? '';
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?></title>
<meta name="csrf" content="<?= csrf_token() ?>">
<?php
// VAPID Public Key – aus Config oder automatisch aus DB
if (file_exists(__DIR__ . '/../includes/push.php')) {
require_once __DIR__ . '/../includes/push.php';
$__vapidKeys = get_or_create_vapid_keys();
$pushKey = $__vapidKeys['public_key'] ?? '';
} else {
$pushKey = config()['push']['public_key'] ?? '';
}
?>
<?php if ($pushKey): ?>
<meta name="push-key" content="<?= e($pushKey) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<?= site_icon_links() ?>
<link rel="manifest" href="/manifest-staff.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){});
}
</script>
</head>
<body>
<script>try { if (localStorage.getItem('deskhub_sidebar') === 'closed') document.documentElement.classList.add('sidebar-closed'); } catch (e) {}</script>
<button type="button" id="sidebar-open" class="sidebar-toggle sidebar-toggle-open" aria-label="Seitenleiste einblenden" title="Seitenleiste einblenden" onclick="document.getElementById('sidebar-toggle').click()">&#9776;</button>
<div class="app-shell">
<aside class="sidebar" id="sidebar">
<button type="button" id="sidebar-toggle" class="sidebar-toggle sidebar-toggle-close" aria-label="Seitenleiste einklappen" title="Seitenleiste einklappen">&laquo;</button>
<div class="brand">
<?php $logo = get_setting('logo_path'); ?>
<?php if ($logo): ?>
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>" style="max-height:34px; max-width:180px; display:block">
<?php else: ?>
Desk<span>Hub</span>
<?php endif; ?>
</div>
<nav>
<?php // Übersicht + ehemals separates "Berichte" wurden zu einer Seite zusammengelegt
// (siehe staff/reports.php) – der Login landete ohnehin schon immer dort. ?>
<a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a>
<a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a>
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
<a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a>
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
<?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?>
<?php if (get_setting('live_chat_enabled', '0') === '1' && $staff): ?>
<?php
// Badge = Anzahl Live-Chats, deren letzte Nachricht vom Kunden kam (also unbeantwortet),
// auf die eigenen Abteilungen begrenzt (wie die Ticketliste selbst).
[$__lcDeptSql, $__lcDeptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
$__lcSql = "SELECT COUNT(*) FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed'
AND (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) = 'customer'"
. $__lcDeptSql; // enthält bereits das führende " AND " (dept_filter_sql)
$__lcStmt = get_db()->prepare($__lcSql);
$__lcStmt->execute($__lcDeptParams);
$__lcPending = (int) $__lcStmt->fetchColumn();
?>
<a href="/staff/live_chat.php" class="<?= $activePage==='live_chat'?'active':'' ?>">
Live-Chat<?php if ($__lcPending): ?> <span class="nav-badge" style="background:var(--danger, #dc2626); color:#fff; border-radius:10px; min-width:18px; height:18px; font-size:.68rem; font-weight:700; display:inline-flex; align-items:center; justify-content:center; padding:0 5px; margin-left:4px; vertical-align:middle"><?= $__lcPending > 9 ? '9+' : $__lcPending ?></span><?php endif; ?>
</a>
<?php endif; ?>
<?php
// Adminportal auch für "Co-Admins" zeigen: Agenten, denen per benutzerdefinierter Rolle
// gezielt einzelne Adminportal-Rechte zugewiesen wurden (admin_section_permissions()).
// has_permission() liefert für echte Admins ohnehin immer true, daher genügt die reine
// Rechteprüfung je Link – kein zusätzliches role==='admin' nötig.
$hasAnyAdminAccess = false;
if ($staff) {
foreach (array_keys(admin_section_permissions()) as $__perm) {
if (has_permission($staff, $__perm)) { $hasAnyAdminAccess = true; break; }
}
}
?>
<?php if ($hasAnyAdminAccess): ?>
<div class="nav-section-label" id="admin-toggle" role="button" tabindex="0">
Adminportal <span class="nav-toggle-chevron">▾</span>
</div>
<div class="nav-admin-block" id="admin-block">
<span class="nav-group-label">Verwalten</span>
<?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><a href="/staff/problems.php" class="<?= $activePage==='problems'?'active':'' ?>">Problemmanagement</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/mail_queue.php" class="<?= $activePage==='mail_queue'?'active':'' ?>">Mail-Warteschlange</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_assistant')): ?><a href="/staff/assistant.php" class="<?= $activePage==='assistant'?'active':'' ?>">Digitaler Assistent</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?>
<?php foreach (plugin_admin_menu_items($staff) as $__pm): ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?>
<span class="nav-group-label">Kunden</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
<span class="nav-group-label">Agenten</span>
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status &amp; Schichten</a><?php endif; ?>
<span class="nav-group-label">System</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
<?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?>
</div>
<script>
(function () {
var toggle = document.getElementById('admin-toggle');
var block = document.getElementById('admin-block');
var KEY = 'deskhub_admin_nav_collapsed';
// In diesen Adminportal-Unterseiten immer ausgeklappt zeigen, damit die aktive Seite sichtbar bleibt
var forceOpen = block.querySelector('a.active') !== null;
function apply(collapsed) {
block.style.display = collapsed ? 'none' : '';
toggle.querySelector('.nav-toggle-chevron').textContent = collapsed ? '▸' : '▾';
}
var stored = localStorage.getItem(KEY);
apply(!forceOpen && stored === '1');
toggle.addEventListener('click', function () {
var collapsed = block.style.display !== 'none';
apply(collapsed);
localStorage.setItem(KEY, collapsed ? '1' : '0');
});
toggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
});
})();
</script>
<?php endif; ?>
</nav>
<div class="sidebar-footer">
<?php if ($staff): ?>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px">
<?php if (!empty($staff['avatar_path'])): ?>
<img src="<?= e($staff['avatar_path']) ?>" alt="" style="width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; border:1px solid #2a3744">
<?php else: ?>
<div style="width:32px; height:32px; border-radius:50%; background:rgba(15,111,106,.3); display:flex; align-items:center; justify-content:center; font-size:.85rem; font-weight:700; color:#8bc4c2; flex-shrink:0">
<?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?>
</div>
<?php endif; ?>
<div style="min-width:0">
<div style="font-size:.82rem; font-weight:600; color:#fff; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><?= e($staff['name']) ?></div>
<div style="font-size:.72rem; color:#4e6278"><?= $staff['role']==='admin'?'Administrator':'Mitarbeiter' ?></div>
<?php if (!empty($staff['is_on_vacation'])): ?>
<div style="font-size:.7rem; color:var(--accent)">🌴 Im Urlaub</div>
<?php endif; ?>
</div>
</div>
<div class="lang-row" style="margin-bottom:10px"><?= i18n_switcher_html('dark') ?></div>
<div style="display:flex; gap:14px; align-items:center">
<?php
$__unreadMentions = 0;
$__unreadChat = 0;
if ($staff) {
$__ms = get_db()->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
$__ms->execute([$staff['id']]);
$__unreadMentions = (int)$__ms->fetchColumn();
$__cs = get_db()->prepare('SELECT COUNT(*) FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0');
$__cs->execute([$staff['id']]);
$__unreadChat = (int)$__cs->fetchColumn();
}
?>
<a href="/staff/chat.php" title="Interner Chat" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
💬<?php if ($__unreadChat > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadChat > 9 ? '9+' : $__unreadChat ?></span><?php endif; ?>
</a>
<a href="/staff/mentions.php" title="Erwähnungen" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
🔔<?php if ($__unreadMentions > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadMentions > 9 ? '9+' : $__unreadMentions ?></span><?php endif; ?>
</a>
<a href="/staff/profile.php" style="color:#8b97a3; font-size:.78rem">Profil</a>
<a href="/staff/logout.php" style="color:#8b97a3; font-size:.78rem">Abmelden</a>
</div>
<?php endif; ?>
</div>
</aside>
<?php
// Timeout in JS übergeben
$_jsTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
$_jsLastAct = $_SESSION['staff_last_activity'] ?? time();
if (!isset($_SESSION['staff_last_activity'])) {
$_SESSION['staff_last_activity'] = time();
$_jsLastAct = time();
}
$_jsRemaining = max(10, ($_jsLastAct + $_jsTimeout * 60) - time());
?>
<script>
window.sessionTimerRemaining = <?= (int)$_jsRemaining ?>;
window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>;
</script>
<script>
(function() {
var remaining = window.sessionTimerRemaining || 0;
var timeoutMin = window.sessionTimerTimeout || 30;
function init() {
var el = document.getElementById('session-timer');
var bar = document.getElementById('session-timer-bar');
if (!el || !bar || remaining <= 0) return;
function fmt(s) {
var m = Math.floor(s / 60);
var sec = s % 60;
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
function tick() {
if (remaining <= 0) {
window.location.href = '/staff/login.php';
return;
}
el.textContent = fmt(remaining);
// Immer sichtbar
bar.style.opacity = '1';
bar.style.pointerEvents = 'auto';
// Farbe nach Dringlichkeit
if (remaining <= 60) {
bar.style.background = 'rgba(239,68,68,.9)';
bar.style.borderColor = 'rgba(239,68,68,.5)';
el.style.color = '#fff';
} else if (remaining <= 300) {
bar.style.background = 'rgba(245,158,11,.9)';
bar.style.borderColor = 'rgba(245,158,11,.5)';
el.style.color = '#fff';
} else {
bar.style.background = 'rgba(15,23,42,.72)';
bar.style.borderColor = 'rgba(255,255,255,.1)';
el.style.color = '#cbd5e1';
}
remaining--;
setTimeout(tick, 1000);
}
tick();
// Bei jeder Nutzeraktion (Klick, Tastatur) Timer via fetch zurücksetzen
var resetDebounce;
function resetTimer() {
clearTimeout(resetDebounce);
resetDebounce = setTimeout(function() {
fetch('/staff/session_ping.php', { method:'POST',
headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content || ''},
credentials:'same-origin' })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.remaining) remaining = d.remaining; })
.catch(function(){});
}, 2000); // max. alle 2 Sek. ein Ping
}
['click','keydown','mousemove','scroll','touchstart'].forEach(function(ev) {
document.addEventListener(ev, resetTimer, { passive: true });
});
// Restzeit regelmäßig beim Server erfragen (verlängert nichts): Aktivität in einem anderen Tab verlängert die Zeit hier mit,
// und ist die Sitzung wirklich abgelaufen, geht es sofort zur Anmeldung
function syncTimer() {
fetch('/staff/session_ping.php?check=1', { credentials:'same-origin', cache:'no-store' })
.then(function(r){ if (r.status === 401) { window.location.href = '/staff/login.php'; return null; } return r.json(); })
.then(function(d){ if (d && typeof d.remaining === 'number') remaining = d.remaining; })
.catch(function(){});
}
setInterval(syncTimer, 30000);
} // end init
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
<script>
// ── Browser Push Notifications ───────────────────────────────────────────
(function() {
var pushKey = document.querySelector('meta[name=push-key]')?.content || document.getElementById('push-vapid-key')?.value;
if (!pushKey || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
var btn = document.getElementById('push-toggle-btn');
function urlB64ToUint8(b64) {
var pad = '='.repeat((4 - b64.length % 4) % 4);
var raw = atob(b64.replace(/-/g,'+').replace(/_/g,'/') + pad);
return Uint8Array.from([...raw].map(function(ch){ return ch.charCodeAt(0); }));
}
function setBtn(active) {
if (!btn) return;
btn.textContent = active ? '🔔' : '🔕';
btn.style.color = active ? '#22c55e' : '#8b97a3';
btn.title = active ? 'Push aktiv – klicken zum Deaktivieren' : 'Push-Benachrichtigungen aktivieren';
}
navigator.serviceWorker.ready.then(function(reg) {
reg.pushManager.getSubscription().then(function(s) {
setBtn(!!(s && Notification.permission === 'granted'));
});
});
window.togglePushNotifications = function() {
navigator.serviceWorker.ready.then(function(reg) {
reg.pushManager.getSubscription().then(function(sub) {
if (sub) {
sub.unsubscribe().then(function() {
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'unsubscribe', endpoint: sub.endpoint}) });
setBtn(false);
});
} else {
Notification.requestPermission().then(function(perm) {
if (perm !== 'granted') { alert('Bitte Benachrichtigungen im Browser erlauben.'); return; }
reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlB64ToUint8(pushKey) })
.then(function(ns) {
var j = ns.toJSON();
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'subscribe', endpoint:j.endpoint, keys:j.keys}) })
.then(function() { setBtn(true); });
}).catch(function(err) { alert('Push fehlgeschlagen: ' + err.message); });
});
}
});
});
};
})();
</script>
<script src="/assets/js/sidebar.js?v=<?= @filemtime(__DIR__ . "/../assets/js/sidebar.js") ?: time() ?>"></script>
<main class="main">
<!-- Session-Timer: kompakte Pille oben rechts -->
<div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span>
</div>
<?= plugin_filter('staff.page.top', '', ['staff' => $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?>
<?php foreach (get_flashes() as $f): ?>
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
<?php endforeach; ?>
<?php
if (isset($staff) && has_permission($staff, 'manage_email_addresses')) {
foreach (system_health(get_db()) as $__issue): ?>
<div class="flash <?= $__issue['level'] === 'error' ? 'error' : 'warning' ?>" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong><?= $__issue['level'] === 'error' ? 'Störung:' : 'Hinweis:' ?></strong> <?= e($__issue['message']) ?>
<a href="/staff/email_addresses.php" style="margin-left:auto">E-Mail-Adressen öffnen</a>
</div>
<?php endforeach; } ?>
<?php
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'update') {
require_once __DIR__ . '/../database/migrate.php';
$__pending = migrations_pending(get_db());
if ($__pending): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong>Datenbank veraltet:</strong> <?= count($__pending) ?> ausstehende Änderung(en) – der Code ist neuer als die Datenbank.
<a href="/staff/update.php" style="margin-left:auto">Jetzt aktualisieren</a>
</div>
<?php endif; } ?>
<?php
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'plugins' && plugin_official_configured()) {
$__upd = plugin_updates_available(get_db());
if ($__upd): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong>Plugin-Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['available'], $__upd))) ?>
<a href="/staff/plugins.php" style="margin-left:auto">Zu den Plugins</a>
</div>
<?php endif; } ?>
<?php $lockedOverride = get_locked_ticket_override(); ?>
<?php if ($lockedOverride && isset($staff) && $staff['role'] === 'admin'): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
🔓 Als Admin können Sie die Sperre von „<?= e($lockedOverride['name']) ?>“ auf Ticket #<?= e($lockedOverride['number']) ?> sofort aufheben, statt auf den automatischen Timeout zu warten.
<form method="post" action="/staff/ticket_view.php?n=<?= urlencode($lockedOverride['number']) ?>" style="margin:0">
<?= csrf_field() ?>
<input type="hidden" name="action" value="admin_force_unlock">
<input type="hidden" name="locked_by_name" value="<?= e($lockedOverride['name']) ?>">
<button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre jetzt aufheben</button>
</form>
</div>
<?php endif; ?>
<?php if (isset($staff) && $staff['role'] === 'admin' && (config()['db']['driver'] ?? 'sqlite') === 'sqlite'): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
⚠️ <strong>SQLite-Modus aktiv:</strong> Nicht für den Produktivbetrieb geeignet.
Bitte in <code>config/config.php</code> auf <code>driver&nbsp;=&nbsp;'mysql'</code> umstellen und MySQL-Zugangsdaten eintragen.
</div>
<?php endif; ?>
<?php if (isset($staff) && $staff['role'] === 'admin' && empty(config()['app_key']) && !get_setting('app_key')): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
🔑 <strong>Kein App-Key gesetzt:</strong> Passwörter werden nur schwach verschlüsselt.
Bitte in <code>config/config.php</code> den Wert <code>app_key</code> setzen.
Generieren: <code>php&nbsp;-r&nbsp;"echo&nbsp;bin2hex(random_bytes(32));"</code>
</div>
<?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/login_log.php" class="<?= $activePage==='login_log'?'active':'' ?>">Anmelde-Wächter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_assistant')): ?><a href="/staff/assistant.php" class="<?= $activePage==='assistant'?'active':'' ?>">Digitaler Assistent</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?>
<?php foreach (plugin_admin_menu_items($staff) as $__pm): ?>
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
<?php endforeach; ?>
<span class="nav-group-label">Kunden</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
<span class="nav-group-label">Agenten</span>
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status &amp; Schichten</a><?php endif; ?>
<span class="nav-group-label">System</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
<?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?>
</div>
<script>
(function () {
var toggle = document.getElementById('admin-toggle');
var block = document.getElementById('admin-block');
var KEY = 'deskhub_admin_nav_collapsed';
// In diesen Adminportal-Unterseiten immer ausgeklappt zeigen, damit die aktive Seite sichtbar bleibt
var forceOpen = block.querySelector('a.active') !== null;
function apply(collapsed) {
block.style.display = collapsed ? 'none' : '';
toggle.querySelector('.nav-toggle-chevron').textContent = collapsed ? '▸' : '▾';
}
var stored = localStorage.getItem(KEY);
apply(!forceOpen && stored === '1');
toggle.addEventListener('click', function () {
var collapsed = block.style.display !== 'none';
apply(collapsed);
localStorage.setItem(KEY, collapsed ? '1' : '0');
});
toggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
});
})();
</script>
<?php endif; ?>
</nav>
<div class="sidebar-footer">
<?php if ($staff): ?>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px">
<?php if (!empty($staff['avatar_path'])): ?>
<img src="<?= e($staff['avatar_path']) ?>" alt="" style="width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; border:1px solid #2a3744">
<?php else: ?>
<div style="width:32px; height:32px; border-radius:50%; background:rgba(15,111,106,.3); display:flex; align-items:center; justify-content:center; font-size:.85rem; font-weight:700; color:#8bc4c2; flex-shrink:0">
<?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?>
</div>
<?php endif; ?>
<div style="min-width:0">
<div style="font-size:.82rem; font-weight:600; color:#fff; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><?= e($staff['name']) ?></div>
<div style="font-size:.72rem; color:#4e6278"><?= $staff['role']==='admin'?'Administrator':'Mitarbeiter' ?></div>
<?php if (!empty($staff['is_on_vacation'])): ?>
<div style="font-size:.7rem; color:var(--accent)">🌴 Im Urlaub</div>
<?php endif; ?>
</div>
</div>
<div class="lang-row" style="margin-bottom:10px"><?= i18n_switcher_html('dark') ?></div>
<div style="display:flex; gap:14px; align-items:center">
<?php
$__unreadMentions = 0;
$__unreadChat = 0;
if ($staff) {
$__ms = get_db()->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
$__ms->execute([$staff['id']]);
$__unreadMentions = (int)$__ms->fetchColumn();
$__cs = get_db()->prepare('SELECT COUNT(*) FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0');
$__cs->execute([$staff['id']]);
$__unreadChat = (int)$__cs->fetchColumn();
}
?>
<a href="/staff/chat.php" title="Interner Chat" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
💬<?php if ($__unreadChat > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadChat > 9 ? '9+' : $__unreadChat ?></span><?php endif; ?>
</a>
<a href="/staff/mentions.php" title="Erwähnungen" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
🔔<?php if ($__unreadMentions > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadMentions > 9 ? '9+' : $__unreadMentions ?></span><?php endif; ?>
</a>
<a href="/staff/profile.php" style="color:#8b97a3; font-size:.78rem">Profil</a>
<a href="/staff/logout.php" style="color:#8b97a3; font-size:.78rem">Abmelden</a>
</div>
<?php endif; ?>
</div>
</aside>
<?php
// Timeout in JS übergeben
$_jsTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
$_jsLastAct = $_SESSION['staff_last_activity'] ?? time();
if (!isset($_SESSION['staff_last_activity'])) {
$_SESSION['staff_last_activity'] = time();
$_jsLastAct = time();
}
$_jsRemaining = max(10, ($_jsLastAct + $_jsTimeout * 60) - time());
?>
<script>
window.sessionTimerRemaining = <?= (int)$_jsRemaining ?>;
window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>;
</script>
<script>
(function() {
var remaining = window.sessionTimerRemaining || 0;
var timeoutMin = window.sessionTimerTimeout || 30;
function init() {
var el = document.getElementById('session-timer');
var bar = document.getElementById('session-timer-bar');
if (!el || !bar || remaining <= 0) return;
function fmt(s) {
var m = Math.floor(s / 60);
var sec = s % 60;
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
function tick() {
if (remaining <= 0) {
window.location.href = '/staff/login.php';
return;
}
el.textContent = fmt(remaining);
// Immer sichtbar
bar.style.opacity = '1';
bar.style.pointerEvents = 'auto';
// Farbe nach Dringlichkeit
if (remaining <= 60) {
bar.style.background = 'rgba(239,68,68,.9)';
bar.style.borderColor = 'rgba(239,68,68,.5)';
el.style.color = '#fff';
} else if (remaining <= 300) {
bar.style.background = 'rgba(245,158,11,.9)';
bar.style.borderColor = 'rgba(245,158,11,.5)';
el.style.color = '#fff';
} else {
bar.style.background = 'rgba(15,23,42,.72)';
bar.style.borderColor = 'rgba(255,255,255,.1)';
el.style.color = '#cbd5e1';
}
remaining--;
setTimeout(tick, 1000);
}
tick();
// Bei jeder Nutzeraktion (Klick, Tastatur) Timer via fetch zurücksetzen
var resetDebounce;
function resetTimer() {
clearTimeout(resetDebounce);
resetDebounce = setTimeout(function() {
fetch('/staff/session_ping.php', { method:'POST',
headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content || ''},
credentials:'same-origin' })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.remaining) remaining = d.remaining; })
.catch(function(){});
}, 2000); // max. alle 2 Sek. ein Ping
}
['click','keydown','mousemove','scroll','touchstart'].forEach(function(ev) {
document.addEventListener(ev, resetTimer, { passive: true });
});
// Restzeit regelmäßig beim Server erfragen (verlängert nichts): Aktivität in einem anderen Tab verlängert die Zeit hier mit,
// und ist die Sitzung wirklich abgelaufen, geht es sofort zur Anmeldung
function syncTimer() {
fetch('/staff/session_ping.php?check=1', { credentials:'same-origin', cache:'no-store' })
.then(function(r){ if (r.status === 401) { window.location.href = '/staff/login.php'; return null; } return r.json(); })
.then(function(d){ if (d && typeof d.remaining === 'number') remaining = d.remaining; })
.catch(function(){});
}
setInterval(syncTimer, 30000);
} // end init
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
<script>
// ── Browser Push Notifications ───────────────────────────────────────────
(function() {
var pushKey = document.querySelector('meta[name=push-key]')?.content || document.getElementById('push-vapid-key')?.value;
if (!pushKey || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
var btn = document.getElementById('push-toggle-btn');
function urlB64ToUint8(b64) {
var pad = '='.repeat((4 - b64.length % 4) % 4);
var raw = atob(b64.replace(/-/g,'+').replace(/_/g,'/') + pad);
return Uint8Array.from([...raw].map(function(ch){ return ch.charCodeAt(0); }));
}
function setBtn(active) {
if (!btn) return;
btn.textContent = active ? '🔔' : '🔕';
btn.style.color = active ? '#22c55e' : '#8b97a3';
btn.title = active ? 'Push aktiv – klicken zum Deaktivieren' : 'Push-Benachrichtigungen aktivieren';
}
navigator.serviceWorker.ready.then(function(reg) {
reg.pushManager.getSubscription().then(function(s) {
setBtn(!!(s && Notification.permission === 'granted'));
});
});
window.togglePushNotifications = function() {
navigator.serviceWorker.ready.then(function(reg) {
reg.pushManager.getSubscription().then(function(sub) {
if (sub) {
sub.unsubscribe().then(function() {
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'unsubscribe', endpoint: sub.endpoint}) });
setBtn(false);
});
} else {
Notification.requestPermission().then(function(perm) {
if (perm !== 'granted') { alert('Bitte Benachrichtigungen im Browser erlauben.'); return; }
reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlB64ToUint8(pushKey) })
.then(function(ns) {
var j = ns.toJSON();
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({action:'subscribe', endpoint:j.endpoint, keys:j.keys}) })
.then(function() { setBtn(true); });
}).catch(function(err) { alert('Push fehlgeschlagen: ' + err.message); });
});
}
});
});
};
})();
</script>
<script src="/assets/js/sidebar.js?v=<?= @filemtime(__DIR__ . "/../assets/js/sidebar.js") ?: time() ?>"></script>
<main class="main">
<!-- Session-Timer: kompakte Pille oben rechts -->
<div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span>
</div>
<?= plugin_filter('staff.page.top', '', ['staff' => $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?>
<?php foreach (get_flashes() as $f): ?>
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
<?php endforeach; ?>
<?php
if (isset($staff) && has_permission($staff, 'manage_email_addresses')) {
foreach (system_health(get_db()) as $__issue): ?>
<div class="flash <?= $__issue['level'] === 'error' ? 'error' : 'warning' ?>" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong><?= $__issue['level'] === 'error' ? 'Störung:' : 'Hinweis:' ?></strong> <?= e($__issue['message']) ?>
<a href="/staff/email_addresses.php" style="margin-left:auto">E-Mail-Adressen öffnen</a>
</div>
<?php endforeach; } ?>
<?php
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'update') {
require_once __DIR__ . '/../database/migrate.php';
$__pending = migrations_pending(get_db());
if ($__pending): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong>Datenbank veraltet:</strong> <?= count($__pending) ?> ausstehende Änderung(en) – der Code ist neuer als die Datenbank.
<a href="/staff/update.php" style="margin-left:auto">Jetzt aktualisieren</a>
</div>
<?php endif; } ?>
<?php
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'plugins' && plugin_official_configured()) {
$__upd = plugin_updates_available(get_db());
if ($__upd): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<strong>Plugin-Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['available'], $__upd))) ?>
<a href="/staff/plugins.php" style="margin-left:auto">Zu den Plugins</a>
</div>
<?php endif; } ?>
<?php $lockedOverride = get_locked_ticket_override(); ?>
<?php if ($lockedOverride && isset($staff) && $staff['role'] === 'admin'): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
🔓 Als Admin können Sie die Sperre von „<?= e($lockedOverride['name']) ?>“ auf Ticket #<?= e($lockedOverride['number']) ?> sofort aufheben, statt auf den automatischen Timeout zu warten.
<form method="post" action="/staff/ticket_view.php?n=<?= urlencode($lockedOverride['number']) ?>" style="margin:0">
<?= csrf_field() ?>
<input type="hidden" name="action" value="admin_force_unlock">
<input type="hidden" name="locked_by_name" value="<?= e($lockedOverride['name']) ?>">
<button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre jetzt aufheben</button>
</form>
</div>
<?php endif; ?>
<?php if (isset($staff) && $staff['role'] === 'admin' && (config()['db']['driver'] ?? 'sqlite') === 'sqlite'): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
⚠️ <strong>SQLite-Modus aktiv:</strong> Nicht für den Produktivbetrieb geeignet.
Bitte in <code>config/config.php</code> auf <code>driver&nbsp;=&nbsp;'mysql'</code> umstellen und MySQL-Zugangsdaten eintragen.
</div>
<?php endif; ?>
<?php if (isset($staff) && $staff['role'] === 'admin' && empty(config()['app_key']) && !get_setting('app_key')): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
🔑 <strong>Kein App-Key gesetzt:</strong> Passwörter werden nur schwach verschlüsselt.
Bitte in <code>config/config.php</code> den Wert <code>app_key</code> setzen.
Generieren: <code>php&nbsp;-r&nbsp;"echo&nbsp;bin2hex(random_bytes(32));"</code>
</div>
<?php endif; ?>
+57 -3
View File
@@ -108,10 +108,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'save_settings') {
$onVacation = !empty($_POST['is_on_vacation']) ? 1 : 0;
$substituteId = ($_POST['vacation_substitute_id'] ?? '') !== '' ? (int)$_POST['vacation_substitute_id'] : null;
$beforeRow = $pdo->prepare('SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
$beforeRow->execute([$staff['id']]);
$beforeRow = $beforeRow->fetch() ?: ['is_on_vacation' => 0, '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.');
// Offene Tickets an die Vertretung weitergeben bzw. nach dem Urlaub zurückholen
[$handed, $back] = vacation_apply_change($pdo, (int) $staff['id'], [$beforeRow['is_on_vacation'], $beforeRow['vacation_substitute_id']], [$onVacation, $substituteId], !empty($_POST['hand_over']));
flash('success', 'Einstellungen gespeichert.' . ($handed ? ' ' . $handed . ' offene Tickets wurden an die Vertretung weitergegeben.' : '') . ($back ? ' ' . $back . ' Tickets sind wieder bei Ihnen.' : ''));
redirect('/staff/profile.php?tab=settings');
}
@@ -129,6 +134,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
redirect('/staff/profile.php?tab=account');
}
// ── Gemerktes Gerät entfernen (Anmelde-Wächter) ────────────────
if ($action === 'forget_device') {
$pdo->prepare('DELETE FROM login_devices WHERE id = ? AND staff_id = ?')->execute([(int) ($_POST['device_id'] ?? 0), $staff['id']]);
flash('success', 'Gerät entfernt.');
redirect('/staff/profile.php?tab=security');
}
// ── 2FA aktivieren ─────────────────────────────────────────────
if ($action === 'enable_2fa') {
$code = trim($_POST['totp_code'] ?? '');
@@ -352,7 +364,7 @@ require __DIR__ . '/_header.php';
<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 class="muted" style="font-size:.84rem">Neue Tickets werden nicht automatisch diesem Konto zugewiesen, während der Urlaubsmodus aktiv ist.</span>
</span>
</label>
<label style="margin-top:14px">Vertretung (optional)</label>
@@ -362,7 +374,11 @@ require __DIR__ . '/_header.php';
<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>
<p class="muted" style="font-size:.8rem">Neue Tickets werden während Ihrer Abwesenheit automatisch dieser Person zugewiesen. Sie sieht dafür auch Ihre Abteilungen.</p>
<label style="display:flex; align-items:flex-start; gap:12px; cursor:pointer; font-weight:400; margin-top:10px">
<input type="checkbox" name="hand_over" value="1" style="width:auto; margin-top:3px; flex-shrink:0" checked>
<span><strong>Meine offenen Tickets an die Vertretung weitergeben</strong><br><span class="muted" style="font-size:.84rem">Beim Speichern gehen Ihre offenen Tickets an die Vertretung. Wenn Sie den Urlaubsmodus beenden, kommen die Tickets zurück, die dann noch offen bei ihr liegen. Ohne Vertretung passiert nichts.</span></span>
</label>
<?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; ?>
@@ -480,6 +496,44 @@ $setupSecret = $_SESSION['totp_setup_secret'] ?? '';
</form>
<?php endif; ?>
</div>
<?php
$myDevices = $pdo->prepare('SELECT * FROM login_devices WHERE staff_id = ? ORDER BY last_seen DESC');
$myDevices->execute([$staff['id']]);
$myDevices = $myDevices->fetchAll();
$myEvents = $pdo->prepare('SELECT * FROM login_events WHERE staff_id = ? ORDER BY id DESC LIMIT 10');
$myEvents->execute([$staff['id']]);
$myEvents = $myEvents->fetchAll();
$eventLabels = login_guard_event_labels();
?>
<div class="card" style="margin-top:20px">
<h3>🛡️ Meine Geräte und Anmeldungen</h3>
<p class="muted" style="font-size:.88rem">Von diesen Geräten haben Sie sich schon angemeldet. Meldet sich jemand von einem neuen Gerät oder Ort an, bekommen Sie eine Mail. Ein Gerät, das Sie nicht kennen, können Sie hier entfernen; ändern Sie dann auch Ihr Passwort.</p>
<?php if (!$myDevices): ?><p class="muted">Noch keine Geräte gemerkt.</p><?php else: ?>
<div style="overflow-x:auto"><table>
<thead><tr><th>Gerät</th><th>Letzter Ort</th><th>Zuletzt gesehen</th><th></th></tr></thead>
<tbody>
<?php foreach ($myDevices as $d): ?>
<tr>
<td><?= e($d['label']) ?><div class="muted" style="font-size:.76rem"><?= $d['channel'] === 'app' ? 'App' : 'Browser' ?>, seit <span><?= e(date('d.m.Y', strtotime((string) $d['first_seen']))) ?></span></div></td>
<td><?= e($d['place']) ?><div class="muted" style="font-size:.76rem"><?= e($d['last_ip']) ?></div></td>
<td><?= e(date('d.m.Y H:i', strtotime((string) $d['last_seen']))) ?></td>
<td><form method="post" onsubmit="return confirm('Dieses Gerät entfernen? Die nächste Anmeldung davon löst wieder eine Warnung aus.')"><?= csrf_field() ?><input type="hidden" name="action" value="forget_device"><input type="hidden" name="device_id" value="<?= (int) $d['id'] ?>"><button class="btn secondary" type="submit" style="padding:3px 8px;font-size:.78rem">Entfernen</button></form></td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<?php endif; ?>
<?php if ($myEvents): ?>
<h4 style="margin:18px 0 6px">Letzte Anmeldungen</h4>
<div style="overflow-x:auto"><table>
<tbody>
<?php foreach ($myEvents as $ev): ?>
<tr><td style="white-space:nowrap"><?= e(date('d.m.Y H:i', strtotime((string) $ev['created_at']))) ?></td><td><?= e($eventLabels[$ev['event']] ?? $ev['event']) ?></td><td><?= e($ev['device']) ?></td><td><?= e($ev['place']) ?> <span class="muted">(<?= e($ev['ip']) ?>)</span></td></tr>
<?php endforeach; ?>
</tbody>
</table></div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>