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

760 lines
36 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';
$staff = require_staff_login();
$pdo = get_db();
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
/** Einfaches Balkendiagramm als SVG */
function svg_bar_chart(array $data, int $height = 200): string
{
if (!$data) return '<p class="muted">Keine Daten vorhanden.</p>';
$palette = ['#0f6f6a', '#2563eb', '#d97706', '#dc2626', '#a855f7', '#16a34a', '#6b7280'];
$max = max(max($data), 1);
$count = count($data);
$barWidth = 38;
$gap = 16;
$width = $count * ($barWidth + $gap) + $gap;
$chartHeight = $height - 46;
$svg = "<svg viewBox=\"0 0 {$width} {$height}\" xmlns=\"http://www.w3.org/2000/svg\" style=\"width:100%; height:auto; max-width:{$width}px; display:block\">";
$x = $gap; $i = 0;
foreach ($data as $label => $value) {
$barH = (int) round(($value / $max) * $chartHeight);
$y = $chartHeight - $barH + 18;
$color = $palette[$i % count($palette)];
$svg .= "<rect x=\"{$x}\" y=\"{$y}\" width=\"{$barWidth}\" height=\"{$barH}\" rx=\"3\" fill=\"{$color}\" />";
$svg .= "<text x=\"" . ($x + $barWidth / 2) . "\" y=\"" . ($y - 6) . "\" text-anchor=\"middle\" font-size=\"12\" fill=\"#16202a\" font-family=\"sans-serif\">" . (int) $value . "</text>";
$svg .= "<text x=\"" . ($x + $barWidth / 2) . "\" y=\"" . ($height - 6) . "\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"#64748b\" font-family=\"sans-serif\">" . htmlspecialchars((string) $label) . "</text>";
$x += $barWidth + $gap;
$i++;
}
$svg .= '</svg>';
return $svg;
}
/** Mehrlinien-Diagramm als SVG. $buckets: [label => [serieKey => wert]]. $series: [serieKey => ['label'=>..,'color'=>..]] */
function svg_multiline_chart(array $buckets, array $series): string
{
if (!$buckets) return '<p class="muted">Keine Daten im gewählten Zeitraum.</p>';
$labels = array_keys($buckets);
$n = count($labels);
$width = max(640, $n * 48);
$height = 260;
$padLeft = 30; $padBottom = 36; $padTop = 16; $padRight = 16;
$chartW = $width - $padLeft - $padRight;
$chartH = $height - $padTop - $padBottom;
$maxVal = 1;
foreach ($buckets as $b) {
foreach (array_keys($series) as $key) {
$maxVal = max($maxVal, (int) ($b[$key] ?? 0));
}
}
$svg = "<svg viewBox=\"0 0 {$width} {$height}\" xmlns=\"http://www.w3.org/2000/svg\" style=\"width:100%;height:auto;max-width:{$width}px;display:block\">";
$svg .= "<line x1=\"{$padLeft}\" y1=\"" . ($padTop + $chartH) . "\" x2=\"" . ($padLeft + $chartW) . "\" y2=\"" . ($padTop + $chartH) . "\" stroke=\"#cbd5e1\" stroke-width=\"1\" />";
$stepX = $n > 1 ? $chartW / ($n - 1) : 0;
foreach ($series as $key => $info) {
$points = [];
foreach ($labels as $i => $label) {
$val = (int) ($buckets[$label][$key] ?? 0);
$x = round($padLeft + $i * $stepX, 1);
$y = round($padTop + $chartH - ($val / $maxVal) * $chartH, 1);
$points[] = "{$x},{$y}";
}
$svg .= '<polyline fill="none" stroke="' . $info['color'] . '" stroke-width="2" points="' . implode(' ', $points) . '" />';
foreach ($labels as $i => $label) {
$val = (int) ($buckets[$label][$key] ?? 0);
$x = round($padLeft + $i * $stepX, 1);
$y = round($padTop + $chartH - ($val / $maxVal) * $chartH, 1);
$svg .= "<circle cx=\"{$x}\" cy=\"{$y}\" r=\"2.6\" fill=\"{$info['color']}\" />";
}
}
$labelEvery = max(1, (int) ceil($n / 12));
foreach ($labels as $i => $label) {
if ($i % $labelEvery !== 0 && $i !== $n - 1) continue;
$x = round($padLeft + $i * $stepX, 1);
$svg .= "<text x=\"{$x}\" y=\"" . ($height - 10) . "\" font-size=\"10\" fill=\"#64748b\" text-anchor=\"middle\" font-family=\"sans-serif\">" . htmlspecialchars((string) $label) . "</text>";
}
$svg .= '</svg>';
return $svg;
}
// ---------------------------------------------------------------
// Zeitraum bestimmen
// ---------------------------------------------------------------
$preset = $_GET['range'] ?? '30d';
$today = new DateTime('today');
switch ($preset) {
case '7d': $from = (clone $today)->modify('-6 days'); $to = clone $today; break;
case 'thismonth': $from = new DateTime(date('Y-m-01')); $to = clone $today; break;
case 'thisyear': $from = new DateTime(date('Y-01-01')); $to = clone $today; break;
case 'custom':
$from = !empty($_GET['from']) ? new DateTime($_GET['from']) : (clone $today)->modify('-29 days');
$to = !empty($_GET['to']) ? new DateTime($_GET['to']) : clone $today;
break;
case '30d':
default:
$preset = '30d';
$from = (clone $today)->modify('-29 days'); $to = clone $today;
}
if ($from > $to) { [$from, $to] = [$to, $from]; }
$rangeFrom = $from->format('Y-m-d 00:00:00');
$rangeTo = $to->format('Y-m-d 23:59:59');
$rangeDays = (int) $from->diff($to)->days + 1;
$useWeekly = $rangeDays > 45;
// ---------------------------------------------------------------
// Kennzahlen (gesamt, nicht zeitraumgefiltert – schneller Überblick)
// ---------------------------------------------------------------
[$deptFilterT, $deptParamsT] = dept_filter_sql($myDeptIds, 'department_id');
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE 1=1{$deptFilterT}");
$stmt->execute($deptParamsT);
$totalTickets = (int) $stmt->fetchColumn();
$monthStart = date('Y-m-01 00:00:00');
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolved_at >= ?{$deptFilterT}");
$stmt->execute([$monthStart, ...$deptParamsT]);
$resolvedThisMonth = (int) $stmt->fetchColumn();
$respTimes = [];
$stmt = $pdo->prepare("SELECT created_at, first_responded_at FROM tickets WHERE first_responded_at IS NOT NULL{$deptFilterT}");
$stmt->execute($deptParamsT);
foreach ($stmt->fetchAll() as $r) {
$respTimes[] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600;
}
$avgResponse = $respTimes ? array_sum($respTimes) / count($respTimes) : null;
$resolTimes = [];
$stmt = $pdo->prepare("SELECT created_at, resolved_at FROM tickets WHERE resolved_at IS NOT NULL{$deptFilterT}");
$stmt->execute($deptParamsT);
foreach ($stmt->fetchAll() as $r) {
$resolTimes[] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600;
}
$avgResolution = $resolTimes ? array_sum($resolTimes) / count($resolTimes) : null;
// ---------------------------------------------------------------
// Mehrlinien-Aktivitätsdiagramm für den gewählten Zeitraum
// ---------------------------------------------------------------
function bucket_key_for(string $datetime, bool $weekly): string
{
$d = new DateTime($datetime);
return $weekly ? 'KW' . $d->format('W') : $d->format('d.m');
}
$buckets = [];
$cursor = clone $from;
while ($cursor <= $to) {
$key = $useWeekly ? 'KW' . $cursor->format('W') : $cursor->format('d.m');
if (!isset($buckets[$key])) {
$buckets[$key] = ['erstellt' => 0, 'zugewiesen' => 0, 'geschlossen' => 0, 'geloescht' => 0, 'ueberfaellig' => 0];
}
$cursor->modify('+1 day');
}
$stmt = $pdo->prepare("SELECT created_at FROM tickets WHERE created_at BETWEEN ? AND ?{$deptFilterT}");
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
foreach ($stmt->fetchAll() as $r) {
$k = bucket_key_for($r['created_at'], $useWeekly);
if (isset($buckets[$k])) $buckets[$k]['erstellt']++;
}
$stmt = $pdo->prepare(
"SELECT al.created_at FROM activity_log al JOIN tickets t ON t.id = al.ticket_id
WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT}"
);
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
foreach ($stmt->fetchAll() as $r) {
$k = bucket_key_for($r['created_at'], $useWeekly);
if (isset($buckets[$k])) $buckets[$k]['zugewiesen']++;
}
$stmt = $pdo->prepare("SELECT closed_at FROM tickets WHERE closed_at BETWEEN ? AND ?{$deptFilterT}");
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
foreach ($stmt->fetchAll() as $r) {
$k = bucket_key_for($r['closed_at'], $useWeekly);
if (isset($buckets[$k])) $buckets[$k]['geschlossen']++;
}
[$deptFilterDel, $deptParamsDel] = dept_filter_sql($myDeptIds, 'department_id');
$stmt = $pdo->prepare("SELECT deleted_at FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterDel}");
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsDel));
foreach ($stmt->fetchAll() as $r) {
$k = bucket_key_for($r['deleted_at'], $useWeekly);
if (isset($buckets[$k])) $buckets[$k]['geloescht']++;
}
$stmt = $pdo->prepare(
"SELECT al.created_at FROM activity_log al JOIN tickets t ON t.id = al.ticket_id
WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT}"
);
$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
foreach ($stmt->fetchAll() as $r) {
$k = bucket_key_for($r['created_at'], $useWeekly);
if (isset($buckets[$k])) $buckets[$k]['ueberfaellig']++;
}
$chartSeries = [
'erstellt' => ['label' => 'Erstellt', 'color' => '#16a34a'],
'zugewiesen' => ['label' => 'Zugewiesen', 'color' => '#2563eb'],
'geschlossen' => ['label' => 'Geschlossen', 'color' => '#0f6f6a'],
'geloescht' => ['label' => 'Gelöscht', 'color' => '#a855f7'],
'ueberfaellig' => ['label' => 'Überfällig', 'color' => '#dc2626'],
];
// ---------------------------------------------------------------
// Nach Status / Priorität (Gesamtüberblick, unabhängig vom Zeitraum)
// ---------------------------------------------------------------
$byStatus = [];
foreach (['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'] as $s) {
[$label] = status_label($s);
$byStatus[$label] = 0;
}
$stmt = $pdo->prepare("SELECT status, COUNT(*) c FROM tickets WHERE 1=1{$deptFilterT} GROUP BY status");
$stmt->execute($deptParamsT);
foreach ($stmt->fetchAll() as $r) {
[$label] = status_label($r['status']);
$byStatus[$label] = (int) $r['c'];
}
$byPrio = [];
$stmt = $pdo->prepare(
"SELECT p.name, p.sort_order, COUNT(t.id) c FROM priorities p
LEFT JOIN tickets t ON t.priority_id = p.id{$deptFilterT}
GROUP BY p.id, p.name, p.sort_order ORDER BY p.sort_order"
);
$stmt->execute($deptParamsT);
foreach ($stmt->fetchAll() as $r) {
$byPrio[$r['name']] = (int) $r['c'];
}
// ---------------------------------------------------------------
// SLA-Einhaltung im Zeitraum
// ---------------------------------------------------------------
$slaStmt = $pdo->prepare(
"SELECT COUNT(*) AS total,
SUM(CASE WHEN resolved_at <= resolution_due_at THEN 1 ELSE 0 END) AS on_time
FROM tickets
WHERE status IN ('resolved','closed')
AND resolved_at IS NOT NULL AND resolution_due_at IS NOT NULL
AND resolved_at BETWEEN ? AND ?{$deptFilterT}"
);
$slaStmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
$slaRow = $slaStmt->fetch();
$slaTotal = (int)($slaRow['total'] ?? 0);
$slaOk = (int)($slaRow['on_time'] ?? 0);
$slaRate = $slaTotal > 0 ? round($slaOk / $slaTotal * 100) : null;
// Mitarbeiter-Performance im Zeitraum
$agentPerf = [];
if ($staff['role'] === 'admin') {
$avgResponseHours = report_avg_response_hours_expression(
$pdo->getAttribute(PDO::ATTR_DRIVER_NAME)
);
$apStmt = $pdo->prepare(
"SELECT s.name,
COUNT(*) AS total,
SUM(CASE WHEN t.status IN ('resolved','closed') THEN 1 ELSE 0 END) AS resolved,
AVG(CASE WHEN t.first_responded_at IS NOT NULL
THEN {$avgResponseHours} END) AS avg_resp
FROM tickets t
JOIN staff s ON s.id = t.assigned_staff_id
WHERE t.created_at BETWEEN ? AND ?{$deptFilterT}
GROUP BY t.assigned_staff_id, s.name
ORDER BY resolved DESC LIMIT 10"
);
$apStmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
$agentPerf = $apStmt->fetchAll();
}
// Chart.js-Daten für Zeitreihe
$chartLabels = array_keys($buckets);
$chartCreated = array_column(array_values($buckets), 'erstellt');
$chartClosed = array_column(array_values($buckets), 'geschlossen');
// ---------------------------------------------------------------
// Statistik-Tabellen je Abteilung / Thema / Agent
// ---------------------------------------------------------------
$deptRows = build_report_table($pdo, 'department_id', $rangeFrom, $rangeTo, $myDeptIds);
$topicRows = build_report_table($pdo, 'help_topic_id', $rangeFrom, $rangeTo, $myDeptIds);
$departments = $pdo->query('SELECT id, name FROM departments')->fetchAll(PDO::FETCH_KEY_PAIR);
$agentNames = $pdo->query('SELECT id, name FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR);
$helpTopics = $pdo->query('SELECT id, name FROM help_topics')->fetchAll(PDO::FETCH_KEY_PAIR);
function render_report_table(array $rows, array $labelMap, string $noneLabel): string
{
if (!$rows) return '<p class="muted">Keine Daten im gewählten Zeitraum.</p>';
ob_start();
?>
<table>
<thead><tr>
<th>Bezeichnung</th><th>Geöffnet</th><th>Zugewiesen</th><th>Überfällig</th><th>Geschlossen</th>
<th>Wieder geöffnet</th><th>Gelöscht</th><th>Servicezeit (Std)</th><th>Reaktionszeit (Std)</th>
</tr></thead>
<tbody>
<?php foreach ($rows as $key => $r): ?>
<tr>
<td><?= $key === 'none' ? e($noneLabel) : e($labelMap[(int) $key] ?? "#{$key}") ?></td>
<td><?= (int)$r['geoeffnet'] ?></td>
<td><?= (int)$r['zugewiesen'] ?></td>
<td><?= (int)$r['ueberfaellig'] ?></td>
<td><?= (int)$r['geschlossen'] ?></td>
<td><?= (int)$r['wieder_geoeffnet'] ?></td>
<td><?= (int)$r['geloescht'] ?></td>
<td><?= $r['servicezeit'] !== null ? number_format((float)$r['servicezeit'], 1) : '–' ?></td>
<td><?= $r['reaktionszeit'] !== null ? number_format((float)$r['reaktionszeit'], 1) : '–' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
return ob_get_clean();
}
// ---------------------------------------------------------------
// Übersicht: "Mein Status" + Team + Handlungsbedarf-Kennzahlen. Bis vor kurzem eine eigene
// Seite (staff/dashboard.php) – zusammengelegt hierher, weil der Login ohnehin schon immer
// auf Berichte landete und die frühere "Priorisiert"-Ticketliste dort nur eine Untermenge
// dessen war, was der Tickets-Reiter längst zeigt. dashboard.php bleibt als Weiterleitung
// hierher bestehen, damit alte Links/Lesezeichen weiter funktionieren.
// ---------------------------------------------------------------
$now = db_now();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed'){$deptFilterT}");
$stmt->execute($deptParamsT);
$dashOpenTickets = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed'){$deptFilterT}");
$stmt->execute(array_merge([$staff['id']], $deptParamsT));
$dashMine = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed'){$deptFilterT}");
$stmt->execute($deptParamsT);
$dashUnassigned = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND resolution_due_at IS NOT NULL AND resolution_due_at < ?{$deptFilterT}");
$stmt->execute(array_merge([$now], $deptParamsT));
$dashOverdue = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'{$deptFilterT}");
$stmt->execute($deptParamsT);
$dashUnanswered = (int) $stmt->fetchColumn();
$mentionStmt = $pdo->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
$mentionStmt->execute([$staff['id']]);
$unreadMentions = (int) $mentionStmt->fetchColumn();
$tsColors = ['available'=>'#22c55e','busy'=>'#f59e0b','break'=>'#6366f1','offline'=>'#94a3b8'];
$tsLabels = ['available'=>'Verfügbar','busy'=>'Beschäftigt','break'=>'Pause','offline'=>'Offline'];
$teamStatus = [];
$myAvailStatus = 'available';
try {
staff_run_auto_offline($pdo);
$tsStmt = $pdo->query(
"SELECT s.id, s.name, s.is_on_vacation, sa.status AS avail_status, sa.note AS avail_note
FROM staff s
LEFT JOIN staff_availability sa ON sa.staff_id = s.id
WHERE s.is_active = 1 ORDER BY s.name"
);
foreach ($tsStmt->fetchAll() as $row) {
if ($row['id'] === $staff['id']) {
$myAvailStatus = $row['avail_status'] ?? 'available';
} else {
$teamStatus[] = $row;
}
}
} catch (Throwable $e) {}
$pageTitle = 'Übersicht';
$activePage = 'reports';
require __DIR__ . '/_header.php';
?>
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:4px">
<h1>Willkommen, <?= e($staff['name']) ?></h1>
<?php if ($staff['role'] === 'admin'): ?>
<div style="display:flex; align-items:center; gap:10px">
<span style="font-size:.78rem; color:var(--muted)">v<?= DESKHUB_VERSION ?></span>
<a href="/staff/update.php" class="btn secondary" style="font-size:.8rem; padding:6px 12px">🔄 Updates</a>
</div>
<?php endif; ?>
</div>
<?php if ($staff['role'] === 'admin'):
// Update-Check: neuestes Release von Gitea holen
$__latestVersion = null;
if (function_exists('curl_init')) {
$__ch = curl_init('https://git.viper.ipv64.net/api/v1/repos/M_Viper/deskhub/releases?limit=1');
curl_setopt_array($__ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>4, CURLOPT_SSL_VERIFYPEER=>true, CURLOPT_PROTOCOLS=>CURLPROTO_HTTPS, CURLOPT_HTTPHEADER=>update_http_headers()]);
$__resp = curl_exec($__ch); curl_close($__ch);
$__rel = @json_decode($__resp, true);
if (!empty($__rel[0]['tag_name'])) $__latestVersion = $__rel[0]['tag_name'];
}
$__hasUpdate = $__latestVersion && version_compare($__latestVersion, DESKHUB_VERSION, '>');
?>
<?php if ($__hasUpdate): ?>
<div style="background:linear-gradient(135deg,#0f6f6a,#0b5a56); color:#fff; border-radius:10px; padding:16px 20px; margin-bottom:20px; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap">
<div style="display:flex; align-items:center; gap:14px">
<span style="font-size:1.8rem">🆕</span>
<div>
<div style="font-weight:700; font-size:.95rem">Update verfügbar: Version <?= e($__latestVersion) ?></div>
<div style="font-size:.82rem; opacity:.85; margin-top:2px">Aktuell installiert: v<?= DESKHUB_VERSION ?></div>
</div>
</div>
<a href="/staff/update.php" style="background:rgba(255,255,255,.2); color:#fff; border:1px solid rgba(255,255,255,.3); border-radius:8px; padding:8px 16px; font-size:.85rem; font-weight:600; text-decoration:none; white-space:nowrap; flex-shrink:0">
Zum Update-Bereich →
</a>
</div>
<?php endif; ?>
<?php endif; ?>
<div class="stat-grid">
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php'"><div class="num"><?= $dashOpenTickets ?></div><div class="lbl">Offene Tickets</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=me'"><div class="num"><?= $dashMine ?></div><div class="lbl">Mir zugewiesen</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?assigned=unassigned'"><div class="num"><?= $dashUnassigned ?></div><div class="lbl">Nicht zugewiesen</div></div>
<div class="stat-card overdue" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashOverdue ?></div><div class="lbl">Überfällig</div></div>
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php?status=open'"><div class="num"><?= $dashUnanswered ?></div><div class="lbl">Unbeantwortet</div></div>
<?php if ($unreadMentions > 0): ?>
<div class="stat-card" style="cursor:pointer; border-color:#f59e0b" onclick="location.href='/staff/mentions.php'"><div class="num" style="color:#f59e0b"><?= $unreadMentions ?></div><div class="lbl">🔔 Neue Erwähnungen</div></div>
<?php endif; ?>
</div>
<!-- ── Mein Status + Team ─────────────────────────────────────────────────── -->
<form id="csrf-dash" style="display:none"><?= csrf_field() ?></form>
<div class="card" style="margin-bottom:20px">
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap">
<!-- Mein Status -->
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<span style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">Mein Status:</span>
<?php foreach ($tsColors as $sk => $sc):
$active = $myAvailStatus === $sk;
?>
<button type="button" onclick="setDashStatus('<?= $sk ?>')"
data-status="<?= $sk ?>"
class="dash-status-btn"
style="display:flex;align-items:center;gap:5px;padding:5px 12px;border:2px solid <?= $active ? $sc : 'var(--border)' ?>;border-radius:20px;background:<?= $active ? $sc.'18' : 'transparent' ?>;cursor:pointer;font-size:.8rem;font-weight:<?= $active ? '600' : '400' ?>;color:<?= $active ? $sc : 'var(--muted)' ?>;transition:all .15s">
<span style="width:7px;height:7px;border-radius:50%;background:<?= $sc ?>"></span>
<?= $tsLabels[$sk] ?>
</button>
<?php endforeach; ?>
</div>
<!-- Trenner -->
<?php if ($teamStatus): ?>
<div style="width:1px;height:28px;background:var(--border);flex-shrink:0"></div>
<!-- Team -->
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1">
<span style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0">Team:</span>
<?php foreach ($teamStatus as $tm):
$ts = $tm['is_on_vacation'] ? 'offline' : ($tm['avail_status'] ?? 'offline');
$tsc = $tsColors[$ts] ?? '#94a3b8';
$tsl = $tm['is_on_vacation'] ? 'Urlaub' : ($tsLabels[$ts] ?? 'Offline');
?>
<div id="team-member-<?= $tm['id'] ?>"
title="<?= e($tm['name']) ?>: <?= e($tsl) ?><?= $tm['avail_note'] ? ' – '.e($tm['avail_note']) : '' ?>"
style="display:flex;align-items:center;gap:5px;padding:4px 10px;border:1px solid var(--border);border-radius:20px;font-size:.78rem;background:var(--surface)">
<span class="team-dot" style="width:7px;height:7px;border-radius:50%;background:<?= $tsc ?>;flex-shrink:0"></span>
<span style="font-weight:500"><?= e($tm['name']) ?></span>
<span class="team-label" style="color:var(--muted);font-size:.72rem"><?= e($tsl) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<script>
var dashStatusColors = <?= json_encode($tsColors) ?>;
var dashStatusLabels = <?= json_encode($tsLabels) ?>;
function setDashStatus(s) {
// Sofort visuell aktualisieren
document.querySelectorAll('.dash-status-btn').forEach(function(btn) {
var active = btn.dataset.status === s;
var c = dashStatusColors[btn.dataset.status] || '#94a3b8';
btn.style.borderColor = active ? c : 'var(--border)';
btn.style.background = active ? c + '18' : 'transparent';
btn.style.color = active ? c : 'var(--muted)';
btn.style.fontWeight = active ? '600' : '400';
});
// An Server senden
var fd = new FormData();
fd.append('status', s);
fd.append('csrf_token', document.querySelector('#csrf-dash input[name=csrf_token]')?.value || '');
fetch('/staff/availability.php', { method:'POST', body:fd, credentials:'same-origin' })
.then(function(r){ return r.json(); })
.then(function(d){ if (d.error) console.error('Status:', d.error); })
.catch(function(e){ console.error('Status-Update:', e); });
}
// Team-Status alle 20 Sek. aktualisieren
function refreshDashTeam() {
fetch('/staff/availability_status.php', { credentials:'same-origin' })
.then(function(r){ return r.json(); })
.then(function(data) {
data.forEach(function(ag) {
var el = document.getElementById('team-member-' + ag.id);
if (!el) return;
var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline');
var col = dashStatusColors[s] || '#94a3b8';
var lbl = ag.is_on_vacation ? 'Urlaub' : (dashStatusLabels[s] || 'Offline');
el.querySelector('.team-dot').style.background = col;
el.querySelector('.team-label').textContent = lbl;
el.title = ag.name + ': ' + lbl + (ag.note ? ' – ' + ag.note : '');
});
}).catch(function(){});
}
setInterval(refreshDashTeam, 20000);
</script>
<h2 style="margin-top:8px">Berichte</h2>
<div class="stat-grid">
<div class="stat-card"><div class="num"><?= $totalTickets ?></div><div class="lbl">Tickets gesamt</div></div>
<div class="stat-card"><div class="num"><?= $resolvedThisMonth ?></div><div class="lbl">Gelöst diesen Monat</div></div>
<div class="stat-card"><div class="num"><?= $avgResponse !== null ? round($avgResponse, 1) . ' Std' : '–' ?></div><div class="lbl">Ø Erstreaktion</div></div>
<div class="stat-card"><div class="num"><?= $avgResolution !== null ? round($avgResolution, 1) . ' Std' : '–' ?></div><div class="lbl">Ø Lösungszeit</div></div>
</div>
<form method="get" class="filters card">
<div>
<label>Berichtszeitraum</label>
<select name="range" onchange="document.getElementById('custom-range').style.display = this.value==='custom' ? 'flex' : 'none'">
<option value="7d" <?= $preset==='7d'?'selected':'' ?>>Letzte 7 Tage</option>
<option value="30d" <?= $preset==='30d'?'selected':'' ?>>Letzte 30 Tage</option>
<option value="thismonth" <?= $preset==='thismonth'?'selected':'' ?>>Dieser Monat</option>
<option value="thisyear" <?= $preset==='thisyear'?'selected':'' ?>>Dieses Jahr</option>
<option value="custom" <?= $preset==='custom'?'selected':'' ?>>Benutzerdefiniert</option>
</select>
</div>
<div id="custom-range" style="display:<?= $preset==='custom'?'flex':'none' ?>; gap:14px">
<div>
<label>Von</label>
<input type="date" name="from" value="<?= e($from->format('Y-m-d')) ?>">
</div>
<div>
<label>Bis</label>
<input type="date" name="to" value="<?= e($to->format('Y-m-d')) ?>">
</div>
</div>
<div><button type="submit" class="btn secondary">Aktualisieren</button></div>
</form>
<div class="card">
<h3>Ticketaktivität <span class="muted" style="font-weight:400; font-size:.8rem">(<?= e($from->format('d.m.Y')) ?> – <?= e($to->format('d.m.Y')) ?>)</span></h3>
<canvas id="chart-activity" style="max-height:100px"></canvas>
<div style="display:flex; gap:16px; flex-wrap:wrap; margin-top:10px">
<?php foreach ($chartSeries as $info): ?>
<span style="font-size:.78rem; display:flex; align-items:center; gap:6px">
<span style="width:10px;height:10px;border-radius:50%;background:<?= e($info['color']) ?>;display:inline-block"></span><?= e($info['label']) ?>
</span>
<?php endforeach; ?>
</div>
</div>
<!-- ── SLA-Kennzahl ────────────────────────────────────────────────────── -->
<?php if ($slaTotal > 0): ?>
<div class="card" style="padding:20px;margin-bottom:20px">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px">
<div>
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">SLA-Einhaltung im Zeitraum</div>
<div style="font-size:2.2rem;font-weight:900;line-height:1.1;margin-top:4px;color:<?= $slaRate >= 90 ? '#22c55e' : ($slaRate >= 70 ? '#f59e0b' : '#ef4444') ?>"><?= $slaRate ?>%</div>
<div style="font-size:.8rem;color:var(--muted);margin-top:2px"><?= $slaOk ?> von <?= $slaTotal ?> Tickets innerhalb der Frist gelöst</div>
</div>
<div style="flex:1;max-width:400px">
<div style="display:flex;justify-content:space-between;font-size:.75rem;color:var(--muted);margin-bottom:4px">
<span>Im Zeitlimit</span><span>Überfällig</span>
</div>
<div style="background:var(--border);border-radius:20px;height:12px;overflow:hidden">
<div style="height:100%;width:<?= $slaRate ?>%;background:<?= $slaRate >= 90 ? '#22c55e' : ($slaRate >= 70 ? '#f59e0b' : '#ef4444') ?>;border-radius:20px;transition:width .6s"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:.75rem;margin-top:4px">
<span style="color:#22c55e;font-weight:600"><?= $slaOk ?> ✓</span>
<span style="color:#ef4444;font-weight:600"><?= $slaTotal - $slaOk ?> ✗</span>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- ── Status & Priorität als Chart.js ─────────────────────────────────── -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px">
<div class="card">
<h3 style="margin:0 0 14px">Nach Status</h3>
<canvas id="chart-status" style="max-height:140px"></canvas>
</div>
<div class="card">
<h3 style="margin:0 0 14px">Nach Priorität</h3>
<canvas id="chart-prio" style="max-height:140px"></canvas>
</div>
</div>
<div class="card">
<h3>Statistiken <span class="muted" style="font-weight:400; font-size:.8rem">(<?= e($from->format('d.m.Y')) ?> – <?= e($to->format('d.m.Y')) ?>)</span></h3>
<div class="tabs">
<button type="button" class="tab-btn active" onclick="showTab('dept')">Abteilung</button>
<button type="button" class="tab-btn" onclick="showTab('topic')">Themen</button>
<?php if ($staff['role'] === 'admin'): ?>
<button type="button" class="tab-btn" onclick="showTab('agent')">Mitarbeiter</button>
<?php endif; ?>
</div>
<?php
$exportParams = ['range' => $preset, 'from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d')];
?>
<div id="tab-dept" class="tab-pane" style="overflow-x:auto">
<?= render_report_table($deptRows, $departments, '–') ?>
<p style="margin-top:8px"><a href="/staff/export.php?type=report&dim=dept&<?= http_build_query($exportParams) ?>" class="btn secondary" style="font-size:.8rem; padding:6px 12px">Exportieren (CSV)</a></p>
</div>
<div id="tab-topic" class="tab-pane" style="display:none; overflow-x:auto">
<?= render_report_table($topicRows, $helpTopics, 'Kein Hilfethema') ?>
<p style="margin-top:8px"><a href="/staff/export.php?type=report&dim=topic&<?= http_build_query($exportParams) ?>" class="btn secondary" style="font-size:.8rem; padding:6px 12px">Exportieren (CSV)</a></p>
</div>
<?php if ($staff['role'] === 'admin' && $agentPerf): ?>
<div id="tab-agent" class="tab-pane" style="display:none;overflow-x:auto">
<table>
<thead><tr>
<th>Mitarbeiter</th>
<th style="text-align:right">Tickets</th>
<th style="text-align:right">Gelöst</th>
<th style="text-align:right">Lösungsrate</th>
<th style="text-align:right">Ø Erstantwort</th>
<th>Leistung</th>
</tr></thead>
<tbody>
<?php
$maxResolved = max(array_column($agentPerf, 'resolved') ?: [1]);
foreach ($agentPerf as $ap):
$rate = $ap['total'] > 0 ? round($ap['resolved'] / $ap['total'] * 100) : 0;
$barPct = $maxResolved > 0 ? round($ap['resolved'] / $maxResolved * 100) : 0;
$avgR = $ap['avg_resp'] !== null ? round((float)$ap['avg_resp'], 1) . ' Std' : '–';
?>
<tr>
<td style="font-weight:500"><?= e($ap['name']) ?></td>
<td style="text-align:right"><?= (int)$ap['total'] ?></td>
<td style="text-align:right;color:var(--primary);font-weight:600"><?= (int)$ap['resolved'] ?></td>
<td style="text-align:right">
<span style="color:<?= $rate >= 80 ? '#22c55e' : ($rate >= 50 ? '#f59e0b' : '#ef4444') ?>;font-weight:600"><?= $rate ?>%</span>
</td>
<td style="text-align:right;color:var(--muted)"><?= $avgR ?></td>
<td style="min-width:120px">
<div style="background:var(--border);border-radius:20px;height:6px;overflow:hidden">
<div style="height:100%;width:<?= $barPct ?>%;background:var(--primary);border-radius:20px"></div>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p style="margin-top:8px"><a href="/staff/export.php?type=report&dim=agent&<?= http_build_query($exportParams) ?>" class="btn secondary" style="font-size:.8rem;padding:6px 12px">Exportieren (CSV)</a></p>
</div>
<?php endif; ?>
</div>
<?php
$prioColorMap = [];
foreach ($pdo->query('SELECT name, color FROM priorities ORDER BY sort_order')->fetchAll() as $pc) {
$prioColorMap[$pc['name']] = $pc['color'];
}
$jsChartLabels = json_encode($chartLabels);
$jsChartCreated = json_encode($chartCreated);
$jsChartClosed = json_encode($chartClosed);
$jsStatusLabels = json_encode(array_keys($byStatus));
$jsStatusData = json_encode(array_values($byStatus));
$jsPrioLabels = json_encode(array_keys($byPrio));
$jsPrioData = json_encode(array_values($byPrio));
$jsPrioColors = json_encode($prioColorMap);
?>
<script src="/assets/js/chart.min.js"></script>
<script>
(function initCharts() {
if (typeof Chart === 'undefined') {
setTimeout(initCharts, 50);
return;
}
var primary = '#0f6f6a';
var muted = '#64748b';
var border = '#e2e8f0';
Chart.defaults.maintainAspectRatio = true;
var scaleOpts = {
x: { ticks: { color: muted, font: { size: 11 } }, grid: { color: border } },
y: { beginAtZero: true, ticks: { color: muted, precision: 0, font: { size: 11 } }, grid: { color: border } }
};
// Aktivitätsdiagramm
var ctxAct = document.getElementById('chart-activity');
if (ctxAct) {
new Chart(ctxAct, {
type: 'line',
data: {
labels: <?= $jsChartLabels ?>,
datasets: [
{ label: 'Erstellt', data: <?= $jsChartCreated ?>, borderColor: primary, backgroundColor: primary + '22', fill: true, tension: .35, pointRadius: 2 },
{ label: 'Geschlossen', data: <?= $jsChartClosed ?>, borderColor: '#22c55e', backgroundColor: '#22c55e22', fill: true, tension: .35, pointRadius: 2 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: true, position: 'top', labels: { color: muted, boxWidth: 12, font: { size: 11 } } } },
scales: scaleOpts
}
});
}
// Status-Balken
var ctxStatus = document.getElementById('chart-status');
if (ctxStatus) {
new Chart(ctxStatus, {
type: 'bar',
data: {
labels: <?= $jsStatusLabels ?>,
datasets: [{ data: <?= $jsStatusData ?>, backgroundColor: ['#3b82f6','#f59e0b','#8b5cf6','#6b7280','#22c55e','#94a3b8'], borderRadius: 4 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: scaleOpts }
});
}
// Prioritäts-Doughnut
var ctxPrio = document.getElementById('chart-prio');
if (ctxPrio) {
var prioHex = <?= $jsPrioColors ?>;
var prioLabels = <?= $jsPrioLabels ?>;
var pColors = prioLabels.map(function(l) { return prioHex[l] || '#64748b'; });
new Chart(ctxPrio, {
type: 'doughnut',
data: { labels: prioLabels, datasets: [{ data: <?= $jsPrioData ?>, backgroundColor: pColors, borderWidth: 2 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: muted, boxWidth: 12, font: { size: 11 } } } } }
});
}
})();
</script>
<style>
.tabs { display:flex; gap:4px; margin-bottom:14px; border-bottom:1px solid #e2e8f0; }
.tab-btn { background:none; border:none; padding:8px 16px; cursor:pointer; font-size:.88rem; color:#64748b; border-bottom:2px solid transparent; }
.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; }
</style>
<script>
function showTab(name) {
['dept','topic','agent'].forEach(t => {
document.getElementById('tab-' + t).style.display = (t === name) ? 'block' : 'none';
});
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
}
</script>
<?php require __DIR__ . '/_footer.php'; ?>