Files
deskhub/DeskHub-Webseite/staff/availability.php
T
2026-10-21 16:44:46 +00:00

391 lines
19 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
/**
* staff/availability.php
* Verfügbarkeitsstatus setzen + Schichtplan verwalten (Admin)
*/
require_once __DIR__ . '/../includes/bootstrap.php';
$staff = require_staff_login();
$pdo = get_db();
// ── AJAX: Status setzen ───────────────────────────────────────────────────────
// Status-AJAX: POST mit 'status' Feld aber ohne 'action' Feld
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status']) && !isset($_POST['action'])) {
header('Content-Type: application/json');
// CSRF prüfen
$csrfOk = hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'] ?? '');
if (!$csrfOk) { http_response_code(403); echo json_encode(['error'=>'CSRF-Fehler', 'got'=>$_POST['csrf_token']??'', 'expected'=>$_SESSION['csrf_token']??'']); exit; }
$newStatus = $_POST['status'] ?? '';
$note = trim($_POST['note'] ?? '');
$allowed = ['available', 'busy', 'break', 'offline'];
if (!in_array($newStatus, $allowed, true)) {
http_response_code(400);
echo json_encode(['error' => 'Ungültiger Status']);
exit;
}
try {
staff_set_status($pdo, (int) $staff['id'], $newStatus, $note);
staff_touch_activity($pdo, (int) $staff['id']);
echo json_encode(['ok' => true, 'status' => $newStatus]);
} catch (Throwable $e) {
error_log('staff_availability: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Status konnte nicht gespeichert werden.']);
}
exit;
}
// ── Schichtplan (Admin-Seite) ─────────────────────────────────────────────────
$admin = $staff['role'] === 'admin' ? $staff : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
// Admin: Status eines Mitarbeiters ändern
if ($action === 'set_staff_status' && $admin) {
$targetId = (int) ($_POST['staff_id'] ?? 0);
$newStatus = (string) ($_POST['status'] ?? '');
$exists = $pdo->prepare('SELECT name FROM staff WHERE id = ? AND is_active = 1');
$exists->execute([$targetId]);
$targetName = $exists->fetchColumn();
if (!$targetName || !in_array($newStatus, STAFF_AVAILABILITY_STATUSES, true)) {
flash('error', 'Ungültiger Mitarbeiter oder Status.');
} else {
staff_set_status($pdo, $targetId, $newStatus, (string) ($_POST['note'] ?? ''));
audit_log($admin['id'], "Status von Mitarbeiter #$targetId auf $newStatus gesetzt");
flash('success', 'Status von ' . $targetName . ' geändert.');
}
redirect('/staff/availability.php');
}
// Admin: Zeit bis zum automatischen Offline einstellen
if ($action === 'save_auto_offline' && $admin) {
$m = max(0, min(1440, (int) ($_POST['auto_offline_minutes'] ?? 30)));
set_setting('staff_auto_offline_minutes', (string) $m);
audit_log($admin['id'], 'Automatisch offline nach ' . ($m > 0 ? $m . ' Minuten' : 'aus') . ' gesetzt');
flash('success', $m > 0 ? "Mitarbeiter werden nach $m Minuten ohne Aktivität automatisch auf Offline gesetzt." : 'Automatisch offline ist ausgeschaltet.');
redirect('/staff/availability.php');
}
if ($action === 'save_shifts' && $admin) {
$targetId = (int)($_POST['staff_id'] ?? 0);
$__nameStmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?');
$__nameStmt->execute([$targetId]);
$editAgentName = $__nameStmt->fetchColumn() ?: "#$targetId";
if (!$targetId) {
flash('error', 'Ungültiger Mitarbeiter.');
redirect('/staff/availability.php');
}
// Alte Schichten löschen
$pdo->prepare('DELETE FROM staff_shifts WHERE staff_id = ?')->execute([$targetId]);
// Neue speichern
$days = $_POST['days'] ?? [];
foreach ($days as $day => $times) {
if (empty($times['active'])) continue;
$start = $times['start'] ?? '08:00';
$end = $times['end'] ?? '17:00';
// Keine Zeitvalidierung – Nutzer ist verantwortlich
$pdo->prepare(
'INSERT INTO staff_shifts (staff_id, weekday, start_time, end_time, is_active) VALUES (?,?,?,?,1)'
)->execute([$targetId, (int)$day, $start, $end]);
}
audit_log($admin['id'], "Schichtplan für Mitarbeiter #$targetId gespeichert");
flash('success', 'Schichtplan für ' . e($editAgentName ?? "#$targetId") . ' gespeichert.');
redirect('/staff/availability.php?edit=' . $targetId);
}
}
// ── Daten laden ───────────────────────────────────────────────────────────────
staff_run_auto_offline($pdo);
$agents = $pdo->query(
"SELECT s.*, sa.status AS avail_status, sa.auto_offline AS avail_auto, sa.note AS avail_note, sa.updated_at AS avail_updated
FROM staff s
LEFT JOIN staff_availability sa ON sa.staff_id = s.id
WHERE s.is_active = 1
ORDER BY s.name"
)->fetchAll();
// Schichten für alle Mitarbeiter
$allShifts = $pdo->query(
'SELECT * FROM staff_shifts WHERE is_active = 1 ORDER BY staff_id, weekday, start_time'
)->fetchAll();
$shiftsByStaff = [];
foreach ($allShifts as $sh) {
$shiftsByStaff[$sh['staff_id']][$sh['weekday']][] = $sh;
}
// Für den Edit-Modus
$editStaffId = (int)($_GET['edit'] ?? 0);
$editShifts = $editStaffId ? ($shiftsByStaff[$editStaffId] ?? []) : [];
$pageTitle = 'Verfügbarkeit & Schichten';
$activePage = 'availability';
require __DIR__ . '/_header.php';
$statusLabels = [
'available' => ['Verfügbar', '#22c55e', '🟢'],
'busy' => ['Beschäftigt', '#f59e0b', '🟡'],
'break' => ['Pause', '#6366f1', '🟣'],
'offline' => ['Offline', '#94a3b8', '⚫'],
];
$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
?>
<div class="topbar">
<h1>Verfügbarkeit & Schichten</h1>
</div>
<!-- Hidden CSRF form für AJAX -->
<form id="csrf-form" style="display:none"><?= csrf_field() ?></form>
<!-- ── Eigener Status ─────────────────────────────────────────────────────────── -->
<?php
$myAvail = null;
foreach ($agents as $a) { if ($a['id'] === $staff['id']) { $myAvail = $a; break; } }
$myStatus = $myAvail['avail_status'] ?? 'available';
?>
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Mein Status</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<?php foreach ($statusLabels as $sk => [$sl, $sc, $se]): ?>
<button type="button" onclick="setMyStatus('<?= $sk ?>')"
class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>"
data-status="<?= $sk ?>"
style="display:flex;align-items:center;gap:7px;padding:8px 16px;border:2px solid <?= $myStatus === $sk ? $sc : 'var(--border)' ?>;border-radius:8px;background:<?= $myStatus === $sk ? $sc.'18' : 'transparent' ?>;cursor:pointer;font-size:.85rem;font-weight:<?= $myStatus === $sk ? '600' : '400' ?>;color:<?= $myStatus === $sk ? $sc : 'var(--ink-soft)' ?>;transition:all .15s">
<span><?= $se ?></span> <?= $sl ?>
</button>
<?php endforeach; ?>
<div style="flex:1;min-width:160px">
<input type="text" id="avail-note" value="<?= e($myAvail['avail_note'] ?? '') ?>"
placeholder="Notiz (optional, z.B. &laquo;In Besprechung bis 14:00&raquo;)"
style="width:100%;font-size:.83rem"
oninput="scheduleStatusUpdate()">
</div>
</div>
</div>
<!-- ── Team-Übersicht ─────────────────────────────────────────────────────────── -->
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:14px">Team-Übersicht</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">
<?php foreach ($agents as $ag):
$as = $ag['avail_status'] ?? 'offline';
[$asl, $asc, $ase] = $statusLabels[$as] ?? ['Unbekannt','#94a3b8','⚫'];
$hasShift = isset($shiftsByStaff[$ag['id']]);
?>
<div id="agent-status-<?= $ag['id'] ?>" style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">
<div style="width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:.9rem;font-weight:700;flex-shrink:0;position:relative">
<?= mb_strtoupper(mb_substr($ag['name'], 0, 1)) ?>
<span class="status-dot" style="position:absolute;bottom:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:<?= $asc ?>;border:2px solid var(--surface)"></span>
</div>
<div style="min-width:0;flex:1">
<div style="font-size:.85rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<?= e($ag['name']) ?>
<?php if ($ag['is_on_vacation']): ?><span style="font-size:.7rem">🌴</span><?php endif; ?>
</div>
<div style="font-size:.75rem;font-weight:500"><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
<div class="status-note" style="font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= e($ag['avail_note'] ?? '') ?></div>
</div>
<?php if ($admin): ?>
<a href="?edit=<?= $ag['id'] ?>" title="Schichtplan bearbeiten"
style="color:var(--muted);font-size:.78rem;text-decoration:none;flex-shrink:0">⚙️</a>
<?php endif; ?>
<?php $__la = staff_last_active_label($ag['last_active_at'] ?? null); ?>
<?php if ($__la !== '' || !empty($ag['avail_auto'])): ?><div style="font-size:.68rem;color:var(--muted);flex-shrink:0;text-align:right"><?= !empty($ag['avail_auto']) ? 'automatisch<br>offline' : '' ?><?= $__la !== '' && empty($ag['avail_auto']) ? 'aktiv ' . e($__la) : '' ?></div><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if ($admin): ?>
<!-- ── Admin: Status der Mitarbeiter ändern + Auto-Offline ───────────────────── -->
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px">Status der Mitarbeiter ändern (Admin)</div>
<p class="muted" style="font-size:.8rem;margin-bottom:12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
<table style="width:100%;border-collapse:collapse;font-size:.85rem">
<?php foreach ($agents as $ag): ?>
<tr style="border-top:1px solid var(--border)">
<td style="padding:6px 8px"><?= e($ag['name']) ?></td>
<td style="padding:6px 8px">
<form method="post" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<?= csrf_field() ?>
<input type="hidden" name="action" value="set_staff_status">
<input type="hidden" name="staff_id" value="<?= (int) $ag['id'] ?>">
<select name="status" style="width:auto">
<?php foreach ($statusLabels as $sk => [$sl]): ?><option value="<?= $sk ?>" <?= ($ag['avail_status'] ?? 'offline') === $sk ? 'selected' : '' ?>><?= $sl ?></option><?php endforeach; ?>
</select>
<input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:180px">
<button type="submit" class="btn secondary">Setzen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<form method="post" style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_auto_offline">
<label style="margin:0;font-weight:500">Automatisch offline nach</label>
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>" style="width:90px"> Minuten ohne Aktivität
<button type="submit" class="btn secondary">Speichern</button>
<span class="muted" style="font-size:.78rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span>
</form>
</div>
<?php endif; ?>
<!-- ── Schichtplan-Editor (Admin) ─────────────────────────────────────────────── -->
<?php if ($admin && $editStaffId): ?>
<?php
$editAgent = null;
foreach ($agents as $a) { if ($a['id'] === $editStaffId) { $editAgent = $a; break; } }
?>
<?php if ($editAgent): ?>
<div class="card">
<h3>Schichtplan: <?= e($editAgent['name']) ?></h3>
<p class="muted" style="font-size:.83rem;margin-bottom:16px">
Definiere an welchen Tagen und zu welchen Zeiten dieser Mitarbeiter verfügbar ist.
Tickets werden außerhalb der Schicht nicht automatisch zugewiesen.
</p>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_shifts">
<input type="hidden" name="staff_id" value="<?= $editStaffId ?>">
<table style="width:100%;border-collapse:collapse">
<thead>
<tr>
<th style="text-align:left;padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Tag</th>
<th style="padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Aktiv</th>
<th style="padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Von</th>
<th style="padding:8px 12px;font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Bis</th>
</tr>
</thead>
<tbody>
<?php for ($d = 1; $d <= 7; $d++):
$dayShift = $editShifts[$d][0] ?? null;
$active = $dayShift !== null;
$start = $active ? substr($dayShift['start_time'], 0, 5) : '08:00';
$end = $active ? substr($dayShift['end_time'], 0, 5) : '17:00';
?>
<tr style="border-bottom:1px solid var(--border-soft)">
<td style="padding:10px 12px;font-size:.88rem;font-weight:500"><?= $weekdays[$d-1] ?></td>
<td style="padding:10px 12px;text-align:center">
<input type="checkbox" name="days[<?= $d ?>][active]" value="1" style="width:auto"
<?= $active ? 'checked' : '' ?>
onchange="toggleDay(<?= $d ?>, this.checked)">
</td>
<td style="padding:10px 12px">
<input type="time" name="days[<?= $d ?>][start]" value="<?= e($start) ?>"
id="day-start-<?= $d ?>" style="font-size:.83rem;padding:5px 8px;width:130px"
<?= !$active ? 'disabled style="opacity:.4"' : '' ?> required>
</td>
<td style="padding:10px 12px">
<input type="time" name="days[<?= $d ?>][end]" value="<?= e($end) ?>"
id="day-end-<?= $d ?>" style="font-size:.83rem;padding:5px 8px;width:130px"
<?= !$active ? 'disabled style="opacity:.4"' : '' ?> required>
</td>
</tr>
<?php endfor; ?>
</tbody>
</table>
<div class="btn-row" style="margin-top:16px">
<button type="submit" class="btn">Speichern</button>
<a href="/staff/availability.php" class="btn secondary">Abbrechen</a>
</div>
</form>
</div>
<?php endif; ?>
<?php endif; ?>
<script>
function getCsrf() {
return document.querySelector('#csrf-form input[name=csrf_token]')?.value
|| document.querySelector('input[name=csrf_token]')?.value || '';
}
var currentStatus = '<?= e($myStatus) ?>';
var statusUpdateTimer;
function setMyStatus(s) {
currentStatus = s;
document.querySelectorAll('.status-btn').forEach(function(btn) {
var active = btn.dataset.status === s;
var colors = {
available: ['#22c55e','#22c55e18'],
busy: ['#f59e0b','#f59e0b18'],
break: ['#6366f1','#6366f118'],
offline: ['#94a3b8','#94a3b818'],
};
var c = colors[btn.dataset.status] || ['var(--border)','transparent'];
btn.style.borderColor = active ? c[0] : 'var(--border)';
btn.style.background = active ? c[1] : 'transparent';
btn.style.color = active ? c[0] : 'var(--ink-soft)';
btn.style.fontWeight = active ? '600' : '400';
});
sendStatusUpdate();
}
function scheduleStatusUpdate() {
clearTimeout(statusUpdateTimer);
statusUpdateTimer = setTimeout(sendStatusUpdate, 800);
}
function sendStatusUpdate() {
var note = document.getElementById('avail-note')?.value || '';
var fd = new FormData();
fd.append('status', currentStatus);
fd.append('note', note);
fd.append('csrf_token', getCsrf());
fetch('/staff/availability.php', {
method: 'POST',
body: fd,
credentials: 'same-origin'
}).then(function(r) {
return r.json();
}).then(function(data) {
if (data.error) console.error('Status-Update Fehler:', data.error);
}).catch(function(err) {
console.error('Status-Update fehlgeschlagen:', err);
});
}
// ── Team-Status Live-Update ──────────────────────────────────────────────
function refreshTeamStatus() {
fetch('/staff/availability_status.php', { credentials: 'same-origin' })
.then(function(r){ return r.json(); })
.then(function(data) {
data.forEach(function(ag) {
var el = document.getElementById('agent-status-' + ag.id);
if (!el) return;
var colors = { available:'#22c55e', busy:'#f59e0b', break:'#6366f1', offline:'#94a3b8' };
var labels = { available:'Verfügbar', busy:'Beschäftigt', break:'Pause', offline:'Offline' };
var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline');
var label = ag.is_on_vacation ? '🌴 Urlaub' : labels[s];
var color = colors[s] || '#94a3b8';
el.querySelector('.status-dot').style.background = color;
el.querySelector('.status-label').textContent = label;
el.querySelector('.status-label').style.color = color;
var noteEl = el.querySelector('.status-note');
if (noteEl) noteEl.textContent = ag.note || '';
});
}).catch(function(){});
}
// Alle 15 Sekunden Team-Status aktualisieren
setInterval(refreshTeamStatus, 15000);
// Nach eigenem Status-Update sofort refreshen
var _origSendStatus = sendStatusUpdate;
sendStatusUpdate = function() {
_origSendStatus();
setTimeout(refreshTeamStatus, 500);
};
function toggleDay(d, active) {
document.getElementById('day-start-' + d).disabled = !active;
document.getElementById('day-end-' + d).disabled = !active;
}
</script>
<?php require __DIR__ . '/_footer.php'; ?>