Files
2026-11-05 21:21:48 +00:00

388 lines
19 KiB
PHP
Raw Permalink 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 = has_permission($staff, 'manage_availability') ? $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'];
?>
<style>
.av-dot { display:inline-block; width:10px; height:10px; border-radius:50%; background:var(--c, #94a3b8); flex:none }
.av-status { display:flex; gap:10px; flex-wrap:wrap; align-items:center }
.status-btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:.88rem; font-weight:600; padding:8px 16px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--ink-soft); cursor:pointer; transition:border-color .15s, background .15s, color .15s }
.status-btn:hover { border-color:var(--c) }
.status-btn.active { border-color:var(--c); background:color-mix(in srgb, var(--c) 12%, var(--surface)); color:var(--ink) }
.av-note { flex:1; min-width:200px; margin:0 }
.av-team { display:grid; grid-template-columns:repeat(auto-fill, minmax(250px, 1fr)); gap:12px }
.av-agent { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--border); border-radius:10px; background:var(--surface) }
.av-avatar { position:relative; width:38px; height:38px; border-radius:50%; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; flex:none }
.av-avatar .status-dot { position:absolute; right:-2px; bottom:-2px; width:12px; height:12px; border-radius:50%; border:2px solid var(--surface); background:#94a3b8 }
.av-agent .av-name { font-weight:600; overflow-wrap:anywhere }
.av-agent .status-label { font-size:.8rem; font-weight:600 }
.av-agent .status-note, .av-agent .av-seen { font-size:.76rem; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.av-edit { margin-left:auto; flex:none; padding:4px 10px; font-size:.78rem }
.av-admin-row form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:0 }
.av-admin-row select, .av-admin-row input[type=text] { margin:0; width:auto }
.av-auto { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:16px; padding-top:16px; border-top:1px solid var(--border-soft) }
.av-auto label, .av-auto input { margin:0 }
.av-auto input[type=number] { width:90px }
</style>
<h1 style="margin-top:0">Verfügbarkeit &amp; Schichten</h1>
<!-- 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">
<h3>Mein Status</h3>
<div class="av-status">
<?php foreach ($statusLabels as $sk => [$sl, $sc]): ?>
<button type="button" onclick="setMyStatus('<?= $sk ?>')" class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>" data-status="<?= $sk ?>" style="--c:<?= $sc ?>">
<span class="av-dot" style="--c:<?= $sc ?>"></span><?= $sl ?>
</button>
<?php endforeach; ?>
<input type="text" id="avail-note" class="av-note" value="<?= e($myAvail['avail_note'] ?? '') ?>" maxlength="120"
placeholder="Notiz (optional, z. B. „In Besprechung bis 14:00“)" oninput="scheduleStatusUpdate()">
</div>
</div>
<!-- ── Team-Übersicht ─────────────────────────────────────────────────────────── -->
<div class="card" style="margin-bottom:20px">
<h3>Team-Übersicht</h3>
<div class="av-team">
<?php foreach ($agents as $ag):
$as = $ag['avail_status'] ?? 'offline';
[$asl, $asc] = $statusLabels[$as] ?? ['Unbekannt', '#94a3b8'];
if (!empty($ag['is_on_vacation'])) { $asl = 'Urlaub'; $asc = '#94a3b8'; }
$__la = staff_last_active_label($ag['last_active_at'] ?? null);
?>
<div class="av-agent" id="agent-status-<?= $ag['id'] ?>">
<div class="av-avatar"><?= e(mb_strtoupper(mb_substr($ag['name'], 0, 1))) ?><span class="status-dot" style="background:<?= $asc ?>"></span></div>
<div style="min-width:0;flex:1">
<div class="av-name"><?= e($ag['name']) ?></div>
<div><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
<div class="status-note"><?= e($ag['avail_note'] ?? '') ?></div>
<?php if (!empty($ag['avail_auto'])): ?><div class="av-seen">automatisch offline</div>
<?php elseif ($__la !== ''): ?><div class="av-seen">aktiv <?= e($__la) ?></div><?php endif; ?>
</div>
<?php if ($admin): ?><a href="?edit=<?= $ag['id'] ?>" class="btn secondary av-edit" title="Schichtplan bearbeiten">Schichtplan</a><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if ($admin): ?>
<!-- ── Admin: Status der Mitarbeiter ändern + Auto-Offline ───────────────────── -->
<div class="card" style="margin-bottom:20px">
<h3>Status der Mitarbeiter ändern (Admin)</h3>
<p class="muted" style="font-size:.85rem;margin:0 0 12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
<div style="overflow-x:auto"><table>
<thead><tr><th>Mitarbeiter</th><th>Status und Notiz</th></tr></thead>
<tbody>
<?php foreach ($agents as $ag): ?>
<tr class="av-admin-row">
<td><strong><?= e($ag['name']) ?></strong></td>
<td>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="set_staff_status">
<input type="hidden" name="staff_id" value="<?= (int) $ag['id'] ?>">
<select name="status">
<?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:220px">
<button type="submit" class="btn secondary">Setzen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<form method="post" class="av-auto">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_auto_offline">
<label>Automatisch offline nach</label>
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>">
<span>Minuten ohne Aktivität</span>
<button type="submit" class="btn secondary">Speichern</button>
<span class="muted" style="font-size:.8rem">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) {
btn.classList.toggle('active', btn.dataset.status === s);
});
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'; ?>