Files
deskhub/DeskHub-Webseite/staff/staff_manage.php
T
2026-11-05 21:21:52 +00:00

673 lines
43 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/webauthn.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 = permission_labels();
/** 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 === 'unlock_login') {
$id = (int) $_POST['id'];
$exists = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ?');
$exists->execute([$id]);
if (!(int) $exists->fetchColumn()) {
flash('error', 'Mitarbeiter nicht gefunden.');
} else {
login_guard_unlock($pdo, $id, (int) $me['id']);
flash('success', 'Die Anmeldesperre ist aufgehoben. Der Mitarbeiter kann sich sofort wieder anmelden.');
}
redirect('/staff/staff_manage.php');
}
if ($action === 'sign_out_all') {
$id = (int) $_POST['id'];
$exists = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ?');
$exists->execute([$id]);
if (!(int) $exists->fetchColumn()) {
flash('error', 'Mitarbeiter nicht gefunden.');
} else {
$r = staff_sign_out_everywhere($pdo, $id, (int) $me['id'], $id === (int) $me['id']);
flash('success', 'Mitarbeiter von allen Geräten abgemeldet. Beendet: App-Anmeldungen ' . $r['apps'] . ', Browser-Push ' . $r['browsers'] . ', App-Geräte ' . $r['devices'] . '.');
}
redirect('/staff/staff_manage.php');
}
if ($action === 'remove_passkey') {
$id = (int) $_POST['id'];
$q = $pdo->prepare('SELECT id, name, email FROM staff WHERE id = ?');
$q->execute([$id]);
$target = $q->fetch();
$del = $pdo->prepare('DELETE FROM staff_passkeys WHERE id = ? AND staff_id = ?');
if ($target) $del->execute([(int) ($_POST['passkey_id'] ?? 0), $id]);
if ($target && $del->rowCount() > 0) {
audit_log((int) $me['id'], "Passkey von Mitarbeiter #{$id} entfernt");
login_guard_record($pdo, 'passkey_removed', $id, (string) $target['email'], 'web', '1 Passkey durch Administrator #' . (int) $me['id'] . ' entfernt');
if ($id !== (int) $me['id'] && trim((string) $target['email']) !== '') {
send_mail((string) $target['email'], 'Ein Passkey wurde entfernt',
email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Passkey entfernt</h2><p style="margin:0;color:#334155">Ein Administrator hat einen Ihrer Passkeys in DeskHub entfernt. Sie können sich weiter mit Passwort (und 2FA) anmelden. Haben Sie das nicht erwartet, wenden Sie sich an Ihre Administration.</p>'));
}
flash('success', 'Passkey entfernt.');
} else {
flash('error', 'Passkey nicht gefunden.');
}
redirect('/staff/staff_manage.php?edit=' . $id);
}
if ($action === 'remove_passkeys') {
$id = (int) $_POST['id'];
$q = $pdo->prepare('SELECT id, name, email FROM staff WHERE id = ?');
$q->execute([$id]);
$target = $q->fetch();
if (!$target) {
flash('error', 'Mitarbeiter nicht gefunden.');
} else {
$n = webauthn_remove_all($pdo, $id);
if ($n > 0) {
audit_log((int) $me['id'], "Passkeys von Mitarbeiter #{$id} entfernt ({$n})");
login_guard_record($pdo, 'passkey_removed', $id, (string) $target['email'], 'web', $n . ' Passkey(s) durch Administrator #' . (int) $me['id'] . ' entfernt');
if ($id !== (int) $me['id'] && trim((string) $target['email']) !== '') {
send_mail((string) $target['email'], 'Ihre Passkeys wurden entfernt',
email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Passkeys entfernt</h2><p style="margin:0;color:#334155">Ein Administrator hat Ihre Passkeys in DeskHub entfernt. Sie können sich weiter mit Passwort (und 2FA) anmelden und im Profil unter „Sicherheit“ neue Passkeys einrichten. Haben Sie das nicht erwartet, wenden Sie sich an Ihre Administration.</p>'));
}
flash('success', $n . ' Passkey' . ($n === 1 ? '' : 's') . ' von ' . $target['name'] . ' entfernt. Die Anmeldung mit Passwort bleibt möglich.');
} else {
flash('info', 'Dieser Mitarbeiter hat keine Passkeys.');
}
}
redirect(($_POST['back'] ?? '') === 'edit' ? '/staff/staff_manage.php?edit=' . $id : '/staff/staff_manage.php');
}
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]);
webauthn_remove_all($pdo, $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 r.name FROM roles r WHERE r.id = s.role_id) AS role_name,
(SELECT COUNT(*) FROM team_members tm WHERE tm.staff_id = s.id) AS team_count,
(SELECT COUNT(*) FROM tickets t WHERE t.assigned_staff_id = s.id AND t.is_spam = 0 AND t.status NOT IN ('resolved','closed')) AS open_tickets
FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name"
)->fetchAll();
// Weitere Abteilungen je Mitarbeiter (zusätzlich zur Hauptabteilung)
$extraDepts = [];
foreach ($pdo->query('SELECT sd.staff_id, d.name FROM staff_department sd JOIN departments d ON d.id = sd.department_id ORDER BY d.name')->fetchAll() as $r) {
$extraDepts[(int) $r['staff_id']][] = (string) $r['name'];
}
$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&#10;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>
<?php if ($editStaff): ?>
<?php $editPasskeys = webauthn_staff_passkeys($pdo, (int) $editStaff['id']); ?>
<div class="card" style="max-width:960px;margin-top:18px">
<h3 style="margin-top:0">🔑 Passkeys von <?= e($editStaff['name']) ?></h3>
<?php if (!webauthn_enabled()): ?><div class="flash info">Passkeys sind zurzeit ausgeschaltet (Adminportal → Anmelde-Wächter). Vorhandene Passkeys gelten nicht, bleiben aber gespeichert.</div><?php endif; ?>
<?php if (!$editPasskeys): ?>
<p class="muted" style="margin:0">Dieser Mitarbeiter hat keinen Passkey. Einrichten kann er ihn selbst unter Mein Profil → Sicherheit.</p>
<?php else: ?>
<table style="width:100%;font-size:.88rem">
<thead><tr style="text-align:left"><th>Name</th><th>Hinzugefügt</th><th>Zuletzt benutzt</th><th></th></tr></thead>
<tbody>
<?php foreach ($editPasskeys as $pk): ?>
<tr>
<td><?= e($pk['name']) ?></td>
<td><?= e(date('d.m.Y H:i', strtotime((string) $pk['created_at']))) ?></td>
<td><?= $pk['last_used_at'] ? e(date('d.m.Y H:i', strtotime((string) $pk['last_used_at']))) : 'noch nie' ?></td>
<td style="text-align:right"><form method="post" onsubmit="return confirm('Diesen Passkey entfernen?')"><?= csrf_field() ?><input type="hidden" name="action" value="remove_passkey"><input type="hidden" name="id" value="<?= (int) $editStaff['id'] ?>"><input type="hidden" name="passkey_id" value="<?= (int) $pk['id'] ?>"><button class="btn secondary" type="submit" style="padding:3px 10px;font-size:.8rem">Entfernen</button></form></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<form method="post" style="margin-top:12px" onsubmit="return confirm('Alle <?= count($editPasskeys) ?> Passkey(s) dieses Mitarbeiters entfernen? Er kann sich danach nur noch mit Passwort (und 2FA) anmelden.')"><?= csrf_field() ?><input type="hidden" name="action" value="remove_passkeys"><input type="hidden" name="id" value="<?= (int) $editStaff['id'] ?>"><input type="hidden" name="back" value="edit"><button class="btn secondary" type="submit" style="border-color:var(--danger);color:var(--danger)">Alle Passkeys entfernen</button></form>
<p class="muted" style="font-size:.8rem;margin-bottom:0">Der Mitarbeiter bekommt eine Mail. Anmelden mit Passwort (und 2FA) bleibt möglich.</p>
<?php endif; ?>
</div>
<?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}
.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: ?>
<?php
$ago = function (?string $dt): string {
if (!$dt) return 'noch nie';
$d = time() - strtotime($dt);
if ($d < 300) return 'online';
if ($d < 3600) return 'vor ' . floor($d / 60) . ' Min';
if ($d < 86400) return 'vor ' . floor($d / 3600) . ' Std';
if ($d < 86400 * 60) return 'vor ' . floor($d / 86400) . ' Tagen';
return date('d.m.Y', strtotime($dt));
};
$staffRows = [];
$nAdmin = $nAgent = $nOff = $nLocked = 0;
foreach ($allStaff as $s) {
$locked = $s['is_active'] && login_guard_is_locked($pdo, (string) $s['email']);
if (!$s['is_active']) $nOff++;
elseif ($s['role'] === 'admin') $nAdmin++;
else $nAgent++;
if ($locked) $nLocked++;
$staffRows[] = $s + ['_locked' => $locked, '_passkeys' => webauthn_count_passkeys($pdo, (int) $s['id'])];
}
?>
<style>
.sm-bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin:16px 0 12px; }
.sm-chip { border:1px solid var(--border,#e2e8f0); background:var(--surface,#fff); color:var(--muted,#64748b); border-radius:999px; padding:5px 14px; font:inherit; font-size:.82rem; font-weight:600; cursor:pointer; }
.sm-chip.on { background:var(--primary,#0f6f6a); border-color:var(--primary,#0f6f6a); color:#fff; }
.sm-chip small { opacity:.8; margin-left:4px; font-weight:700; }
.sm-search { margin-left:auto; max-width:260px; flex:1 1 180px; }
.sm-table { width:100%; border-collapse:collapse; }
.sm-table th { text-align:left; font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted,#64748b); padding:10px 12px; border-bottom:1px solid var(--border,#e2e8f0); white-space:nowrap; }
.sm-table td { padding:12px; border-bottom:1px solid var(--border-soft,#eef2f6); vertical-align:middle; }
.sm-table tr:last-child td { border-bottom:0; }
.sm-table tbody tr:hover { background:var(--bg-soft,#f8fafc); }
.sm-person { display:flex; align-items:center; gap:12px; min-width:220px; }
.sm-avatar { width:40px; height:40px; border-radius:50%; flex-shrink:0; object-fit:cover; display:flex; align-items:center; justify-content:center; background:var(--primary,#0f6f6a); color:#fff; font-weight:700; font-size:.95rem; position:relative; }
.sm-person a.sm-name { font-weight:700; color:var(--ink,#0f172a); text-decoration:none; }
.sm-person a.sm-name:hover { color:var(--primary,#0f6f6a); }
.sm-mail { font-size:.8rem; color:var(--muted,#64748b); word-break:break-all; }
.sm-badge { display:inline-block; font-size:.72rem; font-weight:700; border-radius:999px; padding:2px 10px; white-space:nowrap; }
.sm-b-admin { background:#ede9fe; color:#6d28d9; }
.sm-b-role { background:#e0f2fe; color:#0369a1; }
.sm-b-agent { background:#f1f5f9; color:#475569; }
.sm-b-ok { background:#dcfce7; color:#15803d; }
.sm-b-off { background:#fee2e2; color:#b91c1c; }
.sm-b-warn { background:#fef3c7; color:#b45309; }
.sm-b-vac { background:#e0e7ff; color:#4338ca; }
.sm-dept { font-size:.86rem; }
.sm-dept span { display:inline-block; font-size:.74rem; color:var(--muted,#64748b); background:var(--bg-soft,#f1f5f9); border-radius:6px; padding:1px 7px; margin:2px 3px 0 0; }
.sm-num { font-weight:700; font-size:1rem; }
.sm-dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#94a3b8; margin-right:6px; }
.sm-dot.on { background:#22c55e; }
.sm-actions { display:flex; align-items:center; gap:8px; justify-content:flex-end; white-space:nowrap; }
.sm-menu { position:relative; }
.sm-menu > summary { list-style:none; cursor:pointer; border:1px solid var(--border,#e2e8f0); border-radius:8px; width:34px; height:34px; display:flex; align-items:center; justify-content:center; font-size:1.1rem; color:var(--muted,#64748b); background:var(--surface,#fff); }
.sm-menu > summary::-webkit-details-marker { display:none; }
.sm-menu[open] > summary { background:var(--bg-soft,#f1f5f9); }
/* fixed statt absolute: Der Rahmen der Tabelle (overflow) würde das Menü sonst abschneiden; die Lage setzt das Skript unten */
.sm-menu-list { position:fixed; left:auto; top:0; right:0; z-index:1000; min-width:210px; background:var(--surface,#fff); border:1px solid var(--border,#e2e8f0); border-radius:10px; box-shadow:0 10px 30px rgba(15,23,42,.16); padding:6px; }
.sm-menu-list form { margin:0; }
.sm-menu-list button { display:block; width:100%; text-align:left; background:none; border:0; border-radius:6px; padding:8px 10px; font:inherit; font-size:.88rem; cursor:pointer; color:var(--ink,#0f172a); }
.sm-menu-list button:hover { background:var(--bg-soft,#f1f5f9); }
.sm-menu-list button.danger { color:#dc2626; }
.sm-empty { padding:26px; text-align:center; color:var(--muted,#64748b); display:none; }
@media (max-width:1100px) { .sm-hide-m { display:none; } }
</style>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<div>
<h1 style="margin:0">Mitarbeiter</h1>
<div class="muted" style="font-size:.86rem;margin-top:2px"><?= count($staffRows) ?> Konten<?= $nLocked ? ' · ' . $nLocked . ' mit zeitweise gesperrter Anmeldung' : '' ?></div>
</div>
<a href="/staff/staff_manage.php?new=1" class="btn">Neuen Agenten hinzufügen</a>
</div>
<div class="sm-bar" id="sm-bar">
<button type="button" class="sm-chip on" data-f="all">Alle<small><?= count($staffRows) ?></small></button>
<button type="button" class="sm-chip" data-f="admin">Administratoren<small><?= $nAdmin ?></small></button>
<button type="button" class="sm-chip" data-f="agent">Mitarbeiter<small><?= $nAgent ?></small></button>
<?php if ($nOff): ?><button type="button" class="sm-chip" data-f="off">Deaktiviert<small><?= $nOff ?></small></button><?php endif; ?>
<?php if ($nLocked): ?><button type="button" class="sm-chip" data-f="locked">Gesperrt<small><?= $nLocked ?></small></button><?php endif; ?>
<input type="search" id="sm-search" class="sm-search" placeholder="Mitarbeiter suchen …" aria-label="Mitarbeiter suchen">
</div>
<div class="card" style="padding:0;overflow:visible">
<div style="overflow-x:auto;overflow-y:visible">
<table class="sm-table" id="sm-table">
<thead><tr>
<th>Mitarbeiter</th><th>Rolle</th><th>Abteilung</th><th class="sm-hide-m" style="text-align:center">Offene Tickets</th><th class="sm-hide-m">Zuletzt aktiv</th><th>Status</th><th></th>
</tr></thead>
<tbody>
<?php foreach ($staffRows as $s):
$sid = (int) $s['id'];
$isMe = $sid === (int) $me['id'];
$isAdmin = $s['role'] === 'admin';
$initials = mb_strtoupper(mb_substr(trim((string) $s['name']), 0, 1)) . (preg_match('/\s(\S)/u', trim((string) $s['name']), $mm) ? mb_strtoupper($mm[1]) : '');
$online = !empty($s['last_active_at']) && (time() - strtotime((string) $s['last_active_at'])) < 300;
$group = !$s['is_active'] ? 'off' : ($isAdmin ? 'admin' : 'agent');
$depts = $extraDepts[$sid] ?? [];
$depts = array_values(array_filter($depts, fn($n) => $n !== (string) ($s['department_name'] ?? '')));
$haystack = mb_strtolower($s['name'] . ' ' . $s['email'] . ' ' . ($s['department_name'] ?? '') . ' ' . implode(' ', $depts) . ' ' . ($s['role_name'] ?? ''));
?>
<tr data-group="<?= $group ?>" data-locked="<?= $s['_locked'] ? '1' : '0' ?>" data-text="<?= e($haystack) ?>">
<td>
<div class="sm-person">
<?php if (!empty($s['avatar_path'])): ?><img class="sm-avatar" src="<?= e($s['avatar_path']) ?>" alt="">
<?php else: ?><span class="sm-avatar" aria-hidden="true"><?= e($initials ?: '?') ?></span><?php endif; ?>
<div>
<a class="sm-name" href="/staff/staff_manage.php?edit=<?= $sid ?>"><?= e($s['name']) ?></a><?php if ($isMe): ?> <span class="muted" style="font-size:.74rem">(Sie)</span><?php endif; ?>
<div class="sm-mail"><?= e($s['email']) ?></div>
</div>
</div>
</td>
<td>
<?php if ($isAdmin): ?><span class="sm-badge sm-b-admin">Administrator</span>
<?php elseif (!empty($s['role_name'])): ?><span class="sm-badge sm-b-role"><?= e($s['role_name']) ?></span>
<?php else: ?><span class="sm-badge sm-b-agent">Mitarbeiter</span><?php endif; ?>
<?php if (!empty($s['totp_enabled'])): ?><div class="muted" style="font-size:.72rem;margin-top:4px" title="Zwei-Faktor-Anmeldung ist eingeschaltet">🔒 2FA an</div><?php endif; ?>
<?php if (!empty($s['_passkeys'])): ?><div class="muted" style="font-size:.72rem;margin-top:2px" title="Der Mitarbeiter kann sich mit Passkey anmelden">🔑 <?= (int) $s['_passkeys'] ?> Passkey<?= (int) $s['_passkeys'] === 1 ? '' : 's' ?></div><?php endif; ?>
</td>
<td class="sm-dept">
<?= e($s['department_name'] ?? '–') ?>
<?php if ($depts || (int) $s['team_count']): ?>
<div>
<?php foreach ($depts as $dn): ?><span><?= e($dn) ?></span><?php endforeach; ?>
<?php if ((int) $s['team_count']): ?><span><?= (int) $s['team_count'] ?> Team<?= (int) $s['team_count'] === 1 ? '' : 's' ?></span><?php endif; ?>
</div>
<?php endif; ?>
</td>
<td class="sm-hide-m" style="text-align:center"><span class="sm-num"><?= (int) $s['open_tickets'] ?></span></td>
<td class="sm-hide-m" style="font-size:.86rem;white-space:nowrap"><span class="sm-dot <?= $online ? 'on' : '' ?>"></span><?= e($ago($s['last_active_at'] ?? null)) ?></td>
<td>
<?php if (!$s['is_active']): ?><span class="sm-badge sm-b-off">Deaktiviert</span>
<?php else: ?><span class="sm-badge sm-b-ok">Aktiv</span><?php endif; ?>
<?php if ($s['is_active'] && !empty($s['is_on_vacation'])): ?><span class="sm-badge sm-b-vac">Im Urlaub</span><?php endif; ?>
<?php if ($s['_locked']): ?><div style="margin-top:4px"><span class="sm-badge sm-b-warn" title="Zu viele falsche Anmeldeversuche">Anmeldung zeitweise gesperrt</span></div><?php endif; ?>
</td>
<td>
<div class="sm-actions">
<a class="btn secondary" style="padding:6px 14px;font-size:.84rem" href="/staff/staff_manage.php?edit=<?= $sid ?>">Bearbeiten</a>
<details class="sm-menu">
<summary title="Weitere Aktionen" aria-label="Weitere Aktionen für <?= e($s['name']) ?>">⋯</summary>
<div class="sm-menu-list">
<?php if ($s['_locked']): ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="unlock_login"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit">Sperre aufheben</button></form>
<?php endif; ?>
<?php if (!empty($s['_passkeys'])): ?>
<form method="post" onsubmit="return confirm('Alle <?= (int) $s['_passkeys'] ?> Passkey(s) dieses Mitarbeiters entfernen? Er kann sich danach nur noch mit Passwort (und 2FA) anmelden.');"><?= csrf_field() ?><input type="hidden" name="action" value="remove_passkeys"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit">Passkeys entfernen (<?= (int) $s['_passkeys'] ?>)</button></form>
<?php endif; ?>
<form method="post" onsubmit="return confirm('Diesen Mitarbeiter von allen Geräten abmelden? Er muss sich danach überall neu anmelden.');"><?= csrf_field() ?><input type="hidden" name="action" value="sign_out_all"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit">Überall abmelden</button></form>
<?php if (!$isMe): ?>
<form method="post" onsubmit="return confirm('Mitarbeiter wirklich löschen?');"><?= csrf_field() ?><input type="hidden" name="action" value="delete"><input type="hidden" name="id" value="<?= $sid ?>"><button type="submit" class="danger">Löschen</button></form>
<?php endif; ?>
</div>
</details>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="sm-empty" id="sm-empty">Keine Mitarbeiter gefunden.</div>
</div>
<script>
(function () {
var rows = Array.prototype.slice.call(document.querySelectorAll('#sm-table tbody tr'));
var chips = Array.prototype.slice.call(document.querySelectorAll('#sm-bar .sm-chip'));
var search = document.getElementById('sm-search');
var empty = document.getElementById('sm-empty');
var filter = 'all';
function apply() {
var q = (search.value || '').trim().toLowerCase();
var shown = 0;
rows.forEach(function (tr) {
var ok = filter === 'all' || (filter === 'locked' ? tr.dataset.locked === '1' : tr.dataset.group === filter);
if (ok && q && tr.dataset.text.indexOf(q) === -1) ok = false;
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
empty.style.display = shown ? 'none' : 'block';
}
chips.forEach(function (c) { c.addEventListener('click', function () { filter = c.dataset.f; chips.forEach(function (x) { x.classList.toggle('on', x === c); }); apply(); }); });
search.addEventListener('input', apply);
// Aktionsmenüs: nur eines offen, Klick daneben oder Esc schließt
var menus = Array.prototype.slice.call(document.querySelectorAll('.sm-menu'));
// Menü direkt unter dem Knopf anzeigen, bei zu wenig Platz darüber; rechtsbündig mit dem Knopf
function place(m) {
var list = m.querySelector('.sm-menu-list');
var r = m.querySelector('summary').getBoundingClientRect();
list.style.visibility = 'hidden';
var h = list.offsetHeight;
var top = r.bottom + 6;
if (top + h > window.innerHeight - 8) top = Math.max(8, r.top - h - 6);
list.style.top = top + 'px';
list.style.right = Math.max(8, window.innerWidth - r.right) + 'px';
list.style.visibility = 'visible';
}
menus.forEach(function (m) { m.addEventListener('toggle', function () { if (m.open) { menus.forEach(function (o) { if (o !== m) o.open = false; }); place(m); } }); });
function closeAll() { menus.forEach(function (m) { m.open = false; }); }
window.addEventListener('resize', closeAll);
window.addEventListener('scroll', closeAll, true);
document.addEventListener('click', function (e) { menus.forEach(function (m) { if (m.open && !m.contains(e.target)) m.open = false; }); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') menus.forEach(function (m) { m.open = false; }); });
})();
</script>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>