Files
deskhub/DeskHub-Webseite/staff/reports.php
T
2026-11-01 11:15:52 +00:00

1079 lines
62 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':
// Ungültige Eingaben (kein Datum) fallen auf den Standardzeitraum zurück, statt eine Fehlerseite zu erzeugen
$parseDay = function (string $key, DateTime $fallback): DateTime {
$raw = (string) ($_GET[$key] ?? '');
$d = $raw !== '' ? DateTime::createFromFormat('!Y-m-d', $raw) : false;
return $d instanceof DateTime ? $d : $fallback;
};
$from = $parseDay('from', (clone $today)->modify('-29 days'));
$to = $parseDay('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 (is_overdue = 1 OR (sla_paused_at IS NULL 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';
?>
<?php
// Zufriedenheit (CSAT): nur zeigen, wenn die Abfrage eingeschaltet ist oder es schon Antworten gibt
$csat = null;
try { $csat = csat_stats($pdo, $rangeFrom, $rangeTo, $myDeptIds); } catch (Throwable $e) { $csat = null; }
$showCsat = $csat && (csat_enabled() || $csat['sent'] > 0);
$isAdmin = $staff['role'] === 'admin';
$pageTabs = ['overview' => 'Überblick', 'activity' => 'Verlauf', 'analysis' => 'Auswertung'];
if ($showCsat) $pageTabs['csat'] = 'Zufriedenheit';
$tab = (string) ($_GET['tab'] ?? 'overview');
if (!isset($pageTabs[$tab])) $tab = 'overview';
$__latestVersion = null;
$__hasUpdate = false;
if ($isAdmin && $tab === 'overview') { // der Hinweis steht nur im Überblick
// Update-Check: neuestes Release von Gitea holen. Das Ergebnis wird eine Stunde gemerkt (bei Fehlern 15 Minuten), damit nicht jeder Aufruf der Seite wartet.
$__cache = json_decode((string) get_setting('update_check_cache', ''), true);
if (is_array($__cache) && isset($__cache['at']) && time() - (int) $__cache['at'] < (empty($__cache['tag']) ? 900 : 3600)) {
$__latestVersion = $__cache['tag'] ?: null;
} else {
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'];
}
set_setting('update_check_cache', json_encode(['at' => time(), 'tag' => $__latestVersion]));
}
$__hasUpdate = $__latestVersion && version_compare($__latestVersion, DESKHUB_VERSION, '>');
}
$rangeLabel = $from->format('d.m.Y') . ' – ' . $to->format('d.m.Y');
$exportParams = ['range' => $preset, 'from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d')];
?>
<div class="ov-head">
<div>
<h1 style="margin:0">Willkommen, <?= e($staff['name']) ?></h1>
<p class="muted" style="margin:4px 0 0"><?= e(date('d.m.Y')) ?><?= $isAdmin ? ' · Version ' . e(DESKHUB_VERSION) : '' ?></p>
</div>
<?php if ($isAdmin): ?><a href="/staff/update.php" class="btn secondary" style="font-size:.8rem; padding:6px 12px">🔄 Updates</a><?php endif; ?>
</div>
<nav class="page-tabs" role="tablist" aria-label="Übersicht">
<?php foreach ($pageTabs as $key => $label): ?>
<button type="button" role="tab" id="ptab-<?= $key ?>" data-tab="<?= $key ?>" aria-controls="pane-<?= $key ?>" aria-selected="<?= $tab === $key ? 'true' : 'false' ?>" class="page-tab<?= $tab === $key ? ' active' : '' ?>">
<?= e($label) ?>
<?php if ($key === 'overview' && $dashOverdue > 0): ?><span class="page-tab-badge" title="Überfällige Tickets"><?= $dashOverdue ?></span><?php endif; ?>
</button>
<?php endforeach; ?>
</nav>
<!-- Berichtszeitraum: gilt für Verlauf, Auswertung und Zufriedenheit -->
<form method="get" class="filters card" id="range-bar" style="<?= $tab === 'overview' ? 'display:none' : '' ?>">
<input type="hidden" name="tab" id="range-tab" value="<?= e($tab) ?>">
<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>
<div class="muted" style="margin-left:auto; font-size:.85rem"><?= e($rangeLabel) ?></div>
</form>
<?php
// ── Daten für den Überblick ───────────────────────────────────────────────
[$ovDept, $ovDeptParams] = dept_filter_sql($myDeptIds, 't.department_id');
$ovOpen = "t.status NOT IN ('resolved','closed')";
$ovVisible = ticket_not_snoozed_sql('t');
$ovBase = "SELECT t.id, t.ticket_number, t.subject, t.requester_name, t.status, t.resolution_due_at, t.sla_paused_at, t.is_overdue, t.created_at, t.updated_at,
p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id";
$ovMine = $ovUnassigned = $ovTasks = $ovDepts = $ovFeed = [];
$ovTodayNew = $ovTodayDone = $ovAtRisk = 0;
$ovWeek = [];
try {
$q = $pdo->prepare("$ovBase WHERE t.assigned_staff_id = ? AND $ovOpen AND $ovVisible{$ovDept} ORDER BY t.resolution_due_at IS NULL, t.resolution_due_at ASC, t.updated_at DESC LIMIT 6");
$q->execute([$staff['id'], ...$ovDeptParams]);
$ovMine = $q->fetchAll();
$q = $pdo->prepare("$ovBase WHERE t.assigned_staff_id IS NULL AND $ovOpen AND $ovVisible{$ovDept} ORDER BY t.created_at DESC LIMIT 6");
$q->execute($ovDeptParams);
$ovUnassigned = $q->fetchAll();
$todayStart = date('Y-m-d 00:00:00');
$q = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE t.created_at >= ?{$ovDept}");
$q->execute([$todayStart, ...$ovDeptParams]);
$ovTodayNew = (int) $q->fetchColumn();
$q = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE t.resolved_at >= ?{$ovDept}");
$q->execute([$todayStart, ...$ovDeptParams]);
$ovTodayDone = (int) $q->fetchColumn();
$soon = date('Y-m-d H:i:s', time() + 4 * 3600);
$q = $pdo->prepare("SELECT COUNT(*) FROM tickets t WHERE $ovOpen AND t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ?{$ovDept}");
$q->execute([db_now(), $soon, ...$ovDeptParams]);
$ovAtRisk = (int) $q->fetchColumn();
// Letzte 7 Tage: neue und gelöste Tickets je Tag
$weekStart = date('Y-m-d 00:00:00', strtotime('-6 days'));
for ($i = 6; $i >= 0; $i--) { $day = date('Y-m-d', strtotime("-$i days")); $ovWeek[$day] = ['new' => 0, 'done' => 0]; }
$q = $pdo->prepare("SELECT t.created_at FROM tickets t WHERE t.created_at >= ?{$ovDept}");
$q->execute([$weekStart, ...$ovDeptParams]);
foreach ($q->fetchAll(PDO::FETCH_COLUMN) as $c) { $k = substr((string) $c, 0, 10); if (isset($ovWeek[$k])) $ovWeek[$k]['new']++; }
$q = $pdo->prepare("SELECT t.resolved_at FROM tickets t WHERE t.resolved_at >= ?{$ovDept}");
$q->execute([$weekStart, ...$ovDeptParams]);
foreach ($q->fetchAll(PDO::FETCH_COLUMN) as $c) { $k = substr((string) $c, 0, 10); if (isset($ovWeek[$k])) $ovWeek[$k]['done']++; }
$q = $pdo->prepare("SELECT ta.id, ta.title, ta.due_at, ta.status, t.ticket_number FROM tasks ta LEFT JOIN tickets t ON t.id = ta.ticket_id
WHERE ta.assigned_to = ? AND ta.status != 'done' ORDER BY ta.due_at IS NULL, ta.due_at ASC, ta.created_at DESC LIMIT 5");
$q->execute([$staff['id']]);
$ovTasks = $q->fetchAll();
$q = $pdo->prepare("SELECT d.name, COUNT(*) AS n FROM tickets t JOIN departments d ON d.id = t.department_id WHERE $ovOpen{$ovDept} GROUP BY d.id, d.name ORDER BY n DESC LIMIT 8");
$q->execute($ovDeptParams);
$ovDepts = $q->fetchAll();
$q = $pdo->prepare("SELECT a.action, a.created_at, s.name AS staff_name, t.ticket_number, t.subject
FROM activity_log a JOIN tickets t ON t.id = a.ticket_id LEFT JOIN staff s ON s.id = a.staff_id
WHERE 1=1{$ovDept} ORDER BY a.created_at DESC, a.id DESC LIMIT 8");
$q->execute($ovDeptParams);
$ovFeed = $q->fetchAll();
} catch (Throwable $e) { error_log('Übersicht: ' . $e->getMessage()); }
/** Eine Ticketzeile für die Listen im Überblick */
function ov_ticket_row(array $t): string
{
$over = !in_array($t['status'], ['resolved', 'closed'], true) && (!empty($t['is_overdue']) || (empty($t['sla_paused_at']) && !empty($t['resolution_due_at']) && strtotime((string) $t['resolution_due_at']) < time()));
$due = !empty($t['sla_paused_at']) ? 'Pausiert' : (!empty($t['resolution_due_at']) ? time_until($t['resolution_due_at']) : '');
$color = preg_match('/^#[0-9a-fA-F]{3,8}$/', (string) $t['priority_color']) ? $t['priority_color'] : '#94a3b8';
return '<a class="ov-row" href="/staff/ticket_view.php?n=' . e(rawurlencode((string) $t['ticket_number'])) . '">'
. '<span class="ov-dot" style="background:' . e($color) . '" title="' . e($t['priority_name']) . '"></span>'
. '<span class="ov-row-main"><span class="ov-row-title">' . e($t['subject']) . '</span>'
. '<span class="ov-row-meta">#' . e($t['ticket_number']) . ' · ' . e($t['requester_name']) . ' · ' . e($t['department_name']) . '</span></span>'
. ($due !== '' ? '<span class="ov-row-due' . ($over ? ' over' : '') . '">' . e($due) . '</span>' : '')
. '</a>';
}
$ovWeekMax = 1;
foreach ($ovWeek as $__d) { $ovWeekMax = max($ovWeekMax, $__d['new'], $__d['done']); }
$ovWeekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
$ovDeptMax = 1;
foreach ($ovDepts as $__d) { $ovDeptMax = max($ovDeptMax, (int) $__d['n']); }
?>
<!-- ── Überblick ─────────────────────────────────────────────────────────── -->
<section id="pane-overview" class="page-pane" role="tabpanel" aria-labelledby="ptab-overview" <?= $tab === 'overview' ? '' : 'hidden' ?>>
<?php if ($__hasUpdate): ?>
<div class="ov-update">
<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">Zum Update-Bereich →</a>
</div>
<?php endif; ?>
<h2 class="ov-sec">Jetzt zu tun</h2>
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit, minmax(150px, 1fr))">
<div class="stat-card" style="cursor:pointer" onclick="location.href='/staff/tickets.php'"><div class="num" data-live-count="open"><?= $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" data-live-count="mine"><?= $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" data-live-count="unassigned"><?= $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" data-live-count="overdue"><?= $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" data-live-count="unanswered"><?= $dashUnanswered ?></div><div class="lbl">Unbeantwortet</div></div>
<div class="stat-card" style="cursor:pointer<?= $ovAtRisk > 0 ? ';border-color:#f59e0b' : '' ?>" onclick="location.href='/staff/tickets.php?sla=at_risk'" title="Frist läuft in den nächsten 4 Stunden ab"><div class="num" style="<?= $ovAtRisk > 0 ? 'color:#f59e0b' : '' ?>"><?= $ovAtRisk ?></div><div class="lbl">Frist in &lt; 4 Std</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>
<div class="ov-grid-2">
<div class="card ov-card">
<div class="ov-card-head"><h3>Meine fälligsten Tickets</h3><a href="/staff/tickets.php?assigned=me">Alle anzeigen →</a></div>
<?php if (!$ovMine): ?><div class="ov-empty">🎉 Nichts offen, was dir zugewiesen ist.</div>
<?php else: foreach ($ovMine as $__t) echo ov_ticket_row($__t); endif; ?>
</div>
<div class="card ov-card">
<div class="ov-card-head"><h3>Nicht zugewiesen <?php if ($dashUnassigned > 0): ?><span class="ov-count"><?= $dashUnassigned ?></span><?php endif; ?></h3><a href="/staff/tickets.php?assigned=unassigned">Alle anzeigen →</a></div>
<?php if (!$ovUnassigned): ?><div class="ov-empty">✅ Alles ist verteilt.</div>
<?php else: foreach ($ovUnassigned as $__t) echo ov_ticket_row($__t); endif; ?>
</div>
</div>
<div class="ov-grid-3">
<div class="card ov-card">
<div class="ov-card-head"><h3>Heute &amp; letzte 7 Tage</h3></div>
<div class="ov-today">
<div><div class="ov-today-num"><?= $ovTodayNew ?></div><div class="muted">neu heute</div></div>
<div><div class="ov-today-num" style="color:#16a34a"><?= $ovTodayDone ?></div><div class="muted">gelöst heute</div></div>
</div>
<div class="ov-week" aria-label="Neue und gelöste Tickets der letzten 7 Tage">
<?php foreach ($ovWeek as $__day => $__v): ?>
<div class="ov-week-col" title="<?= e(date('d.m.', strtotime($__day))) ?>: <?= $__v['new'] ?> neu, <?= $__v['done'] ?> gelöst">
<div class="ov-week-bars">
<div class="ov-week-bar new" style="height:<?= (int) round($__v['new'] * 100 / $ovWeekMax) ?>%"></div>
<div class="ov-week-bar done" style="height:<?= (int) round($__v['done'] * 100 / $ovWeekMax) ?>%"></div>
</div>
<div class="ov-week-lbl"><?= $ovWeekdays[(int) date('w', strtotime($__day))] ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="ov-legend"><span><i style="background:var(--primary)"></i>Neu</span><span><i style="background:#22c55e"></i>Gelöst</span></div>
</div>
<div class="card ov-card">
<div class="ov-card-head"><h3>Meine Aufgaben</h3><a href="/staff/tasks.php">Alle anzeigen →</a></div>
<?php if (!$ovTasks): ?><div class="ov-empty">Keine offenen Aufgaben.</div>
<?php else: foreach ($ovTasks as $__k):
$__late = !empty($__k['due_at']) && strtotime((string) $__k['due_at']) < time();
?>
<div class="ov-task">
<span class="ov-task-title"><?= e($__k['title']) ?></span>
<span class="ov-task-meta<?= $__late ? ' over' : '' ?>"><?= !empty($__k['due_at']) ? ($__late ? 'überfällig seit ' : 'fällig ') . e(format_dt($__k['due_at'])) : 'ohne Termin' ?><?php if (!empty($__k['ticket_number'])): ?> · <a href="/staff/ticket_view.php?n=<?= e(rawurlencode((string) $__k['ticket_number'])) ?>">#<?= e($__k['ticket_number']) ?></a><?php endif; ?></span>
</div>
<?php endforeach; endif; ?>
</div>
<div class="card ov-card">
<div class="ov-card-head"><h3>Offen nach Abteilung</h3></div>
<?php if (!$ovDepts): ?><div class="ov-empty">Keine offenen Tickets.</div>
<?php else: foreach ($ovDepts as $__d): ?>
<div class="ov-dept">
<span class="ov-dept-name"><?= e($__d['name']) ?></span>
<div class="ov-dept-bar"><div style="width:<?= (int) round($__d['n'] * 100 / $ovDeptMax) ?>%"></div></div>
<span class="ov-dept-n"><?= (int) $__d['n'] ?></span>
</div>
<?php endforeach; endif; ?>
</div>
</div>
<div class="card ov-card" style="margin-bottom:20px">
<div class="ov-card-head"><h3>Letzte Aktivität</h3></div>
<?php if (!$ovFeed): ?><div class="ov-empty">Noch keine Aktivität.</div>
<?php else: foreach ($ovFeed as $__f): ?>
<div class="ov-feed">
<span class="ov-feed-time"><?= e(format_dt($__f['created_at'])) ?></span>
<span class="ov-feed-text"><strong><?= e($__f['staff_name'] ?? 'System') ?></strong> · <span><?= e($__f['action']) ?></span> ·
<a href="/staff/ticket_view.php?n=<?= e(rawurlencode((string) $__f['ticket_number'])) ?>">#<?= e($__f['ticket_number']) ?></a> <span class="muted"><?= e(mb_strimwidth((string) $__f['subject'], 0, 60, '…')) ?></span></span>
</div>
<?php endforeach; endif; ?>
</div>
<h2 class="ov-sec">Team</h2>
<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">
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap">
<span class="ov-label">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>
<?php if ($teamStatus): ?>
<div style="width:1px;height:28px;background:var(--border);flex-shrink:0"></div>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1">
<span class="ov-label" style="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>
<h2 class="ov-sec">Gesamtzahlen</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>
</section>
<!-- ── Verlauf ───────────────────────────────────────────────────────────── -->
<section id="pane-activity" class="page-pane" role="tabpanel" aria-labelledby="ptab-activity" <?= $tab === 'activity' ? '' : 'hidden' ?>>
<div class="card">
<h3 style="margin-top:0">Ticketaktivität <span class="muted" style="font-weight:400; font-size:.8rem">(<?= e($rangeLabel) ?>)</span></h3>
<div style="position:relative;height:240px"><canvas id="chart-activity"></canvas></div>
</div>
<?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 class="ov-label">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;min-width:220px;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; ?>
<div class="ov-two">
<div class="card"><h3 style="margin:0 0 14px">Nach Status</h3><div style="position:relative;height:220px"><canvas id="chart-status"></canvas></div></div>
<div class="card"><h3 style="margin:0 0 14px">Nach Priorität</h3><div style="position:relative;height:220px"><canvas id="chart-prio"></canvas></div></div>
</div>
</section>
<!-- ── Auswertung ────────────────────────────────────────────────────────── -->
<section id="pane-analysis" class="page-pane" role="tabpanel" aria-labelledby="ptab-analysis" <?= $tab === 'analysis' ? '' : 'hidden' ?>>
<div class="card">
<h3 style="margin-top:0">Statistiken <span class="muted" style="font-weight:400; font-size:.8rem">(<?= e($rangeLabel) ?>)</span></h3>
<div class="tabs">
<button type="button" class="tab-btn active" onclick="showSubTab('dept', this)">Abteilung</button>
<button type="button" class="tab-btn" onclick="showSubTab('topic', this)">Themen</button>
<?php if ($isAdmin): ?><button type="button" class="tab-btn" onclick="showSubTab('agent', this)">Mitarbeiter</button><?php endif; ?>
</div>
<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 ($isAdmin && $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>
</section>
<!-- ── Zufriedenheit ─────────────────────────────────────────────────────── -->
<?php if ($showCsat): ?>
<?php
$csatAnswered = (int) ($csat['answered'] ?? 0);
$csatHappy = $csatAnswered > 0 ? (int) round((($csat['dist'][4] ?? 0) + ($csat['dist'][5] ?? 0)) * 100 / $csatAnswered) : null;
$csatAvgColor = $csat['avg'] === null ? 'var(--muted)' : ($csat['avg'] >= 4.5 ? '#16a34a' : ($csat['avg'] >= 3.5 ? '#f59e0b' : '#ef4444'));
$csatWithComment = 0;
foreach ($csat['recent'] ?? [] as $__r) { if ($__r['comment'] !== '') $csatWithComment++; }
?>
<section id="pane-csat" class="page-pane" role="tabpanel" aria-labelledby="ptab-csat" <?= $tab === 'csat' ? '' : 'hidden' ?>>
<?php if (!$csat['sent']): ?>
<div class="card" style="text-align:center;padding:36px 20px">
<div style="font-size:2rem">⭐</div>
<h3 style="margin:8px 0 4px">Noch keine Abfragen im Zeitraum</h3>
<p class="muted" style="margin:0">Sobald Kunden nach einem geschlossenen oder gelösten Ticket um eine Bewertung gebeten wurden, erscheint die Auswertung hier.</p>
</div>
<?php else: ?>
<div class="csat-kpis">
<div class="csat-kpi">
<div class="ov-label">Ø Bewertung</div>
<div class="csat-big" style="color:<?= $csatAvgColor ?>"><?= $csat['avg'] !== null ? number_format($csat['avg'], 1, ',', '') : '–' ?><span class="csat-of"> / 5</span></div>
<div style="color:#f5b301;letter-spacing:2px"><?= $csat['avg'] !== null ? e(csat_stars((int) round($csat['avg']))) : '' ?></div>
</div>
<div class="csat-kpi">
<div class="ov-label">Zufrieden (4–5 ★)</div>
<div class="csat-big"><?= $csatHappy !== null ? $csatHappy . ' %' : '–' ?></div>
<div class="muted">aller Antworten</div>
</div>
<div class="csat-kpi">
<div class="ov-label">Antworten</div>
<div class="csat-big"><?= $csatAnswered ?></div>
<div class="muted">von <?= (int) $csat['sent'] ?> Abfragen</div>
</div>
<div class="csat-kpi">
<div class="ov-label">Rücklaufquote</div>
<div class="csat-big"><?= $csat['rate'] !== null ? number_format($csat['rate'], 0, ',', '') . ' %' : '–' ?></div>
<div class="csat-meter"><div style="width:<?= (int) min(100, round((float) ($csat['rate'] ?? 0))) ?>%"></div></div>
</div>
</div>
<div class="csat-two">
<div class="card">
<h3 style="margin:0 0 12px">Verteilung</h3>
<?php $distMax = max(1, max($csat['dist'])); ?>
<?php for ($i = 5; $i >= 1; $i--):
$cnt = (int) $csat['dist'][$i];
$pct = $csatAnswered > 0 ? (int) round($cnt * 100 / $csatAnswered) : 0;
?>
<div class="csat-row">
<span class="csat-stars"><?= e(csat_stars($i)) ?></span>
<div class="csat-bar"><div style="width:<?= (int) round($cnt * 100 / $distMax) ?>%"></div></div>
<span class="csat-num"><?= $cnt ?> <span class="muted">(<?= $pct ?> %)</span></span>
</div>
<?php endfor; ?>
</div>
<div class="card">
<h3 style="margin:0 0 12px">Nach Mitarbeiter</h3>
<?php if (!$csat['byStaff']): ?>
<p class="muted" style="margin:0">Noch keine Bewertungen zugeordnet.</p>
<?php else: foreach ($csat['byStaff'] as $row):
$sc = $row['avg'] >= 4.5 ? '#16a34a' : ($row['avg'] >= 3.5 ? '#f59e0b' : '#ef4444');
?>
<div class="csat-row">
<span class="csat-name"><?= e($row['name']) ?></span>
<div class="csat-bar"><div style="width:<?= (int) round($row['avg'] * 20) ?>%;background:<?= $sc ?>"></div></div>
<span class="csat-num"><strong><?= number_format($row['avg'], 1, ',', '') ?></strong> ★ <span class="muted">· <?= (int) $row['count'] ?></span></span>
</div>
<?php endforeach; endif; ?>
</div>
</div>
<?php if ($csat['recent']): ?>
<div class="csat-head">
<h3 style="margin:0">Letzte Bewertungen</h3>
<?php if ($csatWithComment > 0): ?>
<label class="csat-filter"><input type="checkbox" id="csat-only-comments" onchange="document.getElementById('csat-recent').classList.toggle('only-comments', this.checked)"> Nur mit Kommentar (<?= $csatWithComment ?>)</label>
<?php endif; ?>
</div>
<div class="csat-recent" id="csat-recent">
<?php foreach ($csat['recent'] as $row): ?>
<div class="csat-box<?= $row['comment'] === '' ? ' no-comment' : '' ?>">
<div style="color:#f5b301;font-size:1.05rem;letter-spacing:1px"><?= e(csat_stars($row['rating'])) ?></div>
<a href="/staff/ticket_view.php?n=<?= e(rawurlencode($row['number'])) ?>" class="csat-ticket"><span class="muted">#<?= e($row['number']) ?></span><br><?= e($row['subject']) ?></a>
<?php if ($row['comment'] !== ''): ?><div class="csat-comment">„<?= e($row['comment']) ?>“</div><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</section>
<?php endif; ?>
<?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>
var dashStatusColors = <?= json_encode($tsColors) ?>;
var dashStatusLabels = <?= json_encode($tsLabels) ?>;
function setDashStatus(s) {
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';
});
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);
// Diagramme erst zeichnen, wenn der Reiter sichtbar ist (verborgene Zeichenflächen haben keine Größe)
var chartsBuilt = false;
function buildCharts() {
if (chartsBuilt) return;
if (typeof Chart === 'undefined') { setTimeout(buildCharts, 50); return; }
chartsBuilt = true;
var primary = '#0f6f6a', muted = '#64748b', border = '#e2e8f0';
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 } }
};
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 }
});
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 }
});
var ctxPrio = document.getElementById('chart-prio');
if (ctxPrio) {
var prioHex = <?= $jsPrioColors ?>, prioLabels = <?= $jsPrioLabels ?>;
new Chart(ctxPrio, {
type: 'doughnut',
data: { labels: prioLabels, datasets: [{ data: <?= $jsPrioData ?>, backgroundColor: prioLabels.map(function(l) { return prioHex[l] || '#64748b'; }), borderWidth: 2 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: muted, boxWidth: 12, font: { size: 11 } } } } }
});
}
}
// Reiter der Seite
function showPageTab(name, fromClick) {
var found = false;
document.querySelectorAll('.page-tab').forEach(function(b) {
var on = b.dataset.tab === name;
if (on) found = true;
b.classList.toggle('active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
});
if (!found) return;
document.querySelectorAll('.page-pane').forEach(function(p) { p.hidden = (p.id !== 'pane-' + name); });
var bar = document.getElementById('range-bar');
if (bar) bar.style.display = name === 'overview' ? 'none' : '';
var hid = document.getElementById('range-tab');
if (hid) hid.value = name;
if (name === 'activity') buildCharts();
if (fromClick && window.history && history.replaceState) {
var u = new URL(location.href);
if (name === 'overview') u.searchParams.delete('tab'); else u.searchParams.set('tab', name);
history.replaceState(null, '', u.toString());
}
}
document.querySelectorAll('.page-tab').forEach(function(b, i, all) {
b.addEventListener('click', function() { showPageTab(b.dataset.tab, true); });
b.addEventListener('keydown', function(ev) {
var d = ev.key === 'ArrowRight' ? 1 : ev.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
var n = all[(i + d + all.length) % all.length];
n.focus(); showPageTab(n.dataset.tab, true); ev.preventDefault();
});
});
showPageTab(<?= json_encode($tab) ?>, false);
// Unterreiter der Auswertung
function showSubTab(name, btn) {
['dept','topic','agent'].forEach(function(t) {
var el = document.getElementById('tab-' + t);
if (el) el.style.display = (t === name) ? 'block' : 'none';
});
document.querySelectorAll('#pane-analysis .tab-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
}
</script>
<style>
.ov-head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:18px; }
.ov-sec { font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin:22px 0 10px; font-weight:700; }
.ov-label { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); }
.ov-two { display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:20px; }
@media (max-width: 800px) { .ov-two { grid-template-columns:1fr; } }
.ov-update { 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; }
.ov-update a { 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; }
.page-tabs { display:flex; gap:4px; margin:0 0 18px; border-bottom:1px solid var(--border,#e2e8f0); overflow-x:auto; overflow-y:hidden; }
.page-tab { background:none; border:0; border-bottom:3px solid transparent; padding:10px 18px; margin-bottom:-1px; cursor:pointer; font:inherit; font-weight:600; color:var(--muted,#64748b); border-radius:8px 8px 0 0; white-space:nowrap; display:inline-flex; align-items:center; gap:8px; }
.page-tab:hover { color:var(--text,#0f172a); background:var(--surface,#fff); }
.page-tab.active { color:var(--primary); border-bottom-color:var(--primary); background:var(--surface,#fff); }
.page-tab-badge { background:#ef4444; color:#fff; border-radius:10px; padding:0 7px; font-size:.72rem; line-height:18px; font-weight:700; }
.page-pane[hidden] { display:none; }
.ov-grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
.ov-grid-3 { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:16px; margin-bottom:16px; }
@media (max-width: 1100px) { .ov-grid-3 { grid-template-columns:1fr 1fr; } }
@media (max-width: 760px) { .ov-grid-2, .ov-grid-3 { grid-template-columns:1fr; } }
.ov-card { margin-bottom:0; min-width:0; }
.ov-card-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
.ov-card-head h3 { margin:0; font-size:1rem; display:flex; align-items:center; gap:8px; }
.ov-card-head a { font-size:.8rem; text-decoration:none; white-space:nowrap; }
.ov-count { background:var(--primary); color:#fff; border-radius:10px; padding:0 8px; font-size:.75rem; line-height:20px; font-weight:700; }
.ov-empty { color:var(--muted,#64748b); padding:18px 4px; text-align:center; font-size:.9rem; }
.ov-row { display:flex; align-items:center; gap:10px; padding:9px 6px; border-top:1px solid var(--border,#e3e9ee); text-decoration:none; color:inherit; border-radius:6px; }
.ov-row:first-of-type { border-top:0; }
.ov-row:hover { background:var(--surface-2, rgba(15,111,106,.06)); }
.ov-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
.ov-row-main { display:flex; flex-direction:column; min-width:0; flex:1; }
.ov-row-title { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ov-row-meta { font-size:.78rem; color:var(--muted,#64748b); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ov-row-due { font-size:.78rem; color:var(--muted,#64748b); white-space:nowrap; }
.ov-row-due.over, .ov-task-meta.over { color:#dc2626; font-weight:600; }
.ov-today { display:flex; gap:28px; margin:4px 0 14px; }
.ov-today-num { font-size:2rem; font-weight:800; line-height:1.1; }
.ov-week { display:flex; gap:8px; align-items:flex-end; height:92px; }
.ov-week-col { flex:1; display:flex; flex-direction:column; align-items:center; height:100%; }
.ov-week-bars { flex:1; width:100%; display:flex; gap:3px; align-items:flex-end; }
.ov-week-bar { flex:1; border-radius:3px 3px 0 0; min-height:2px; }
.ov-week-bar.new { background:var(--primary); }
.ov-week-bar.done { background:#22c55e; }
.ov-week-lbl { font-size:.7rem; color:var(--muted,#64748b); margin-top:4px; }
.ov-legend { display:flex; gap:14px; font-size:.75rem; color:var(--muted,#64748b); margin-top:8px; }
.ov-legend i { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:5px; }
.ov-task { padding:8px 4px; border-top:1px solid var(--border,#e3e9ee); display:flex; flex-direction:column; gap:2px; }
.ov-task:first-of-type { border-top:0; }
.ov-task-title { font-weight:600; overflow-wrap:anywhere; }
.ov-task-meta { font-size:.78rem; color:var(--muted,#64748b); }
.ov-dept { display:grid; grid-template-columns:minmax(70px, 38%) 1fr auto; gap:10px; align-items:center; padding:5px 0; }
.ov-dept-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.88rem; }
.ov-dept-bar { background:var(--border,#e2e8f0); border-radius:6px; height:10px; overflow:hidden; }
.ov-dept-bar > div { height:100%; background:var(--primary); border-radius:6px; }
.ov-dept-n { font-weight:700; font-size:.88rem; min-width:24px; text-align:right; }
.ov-feed { display:flex; gap:14px; padding:7px 2px; border-top:1px solid var(--border,#e3e9ee); font-size:.88rem; }
.ov-feed:first-of-type { border-top:0; }
.ov-feed-time { color:var(--muted,#64748b); white-space:nowrap; font-size:.8rem; min-width:112px; }
.ov-feed-text { min-width:0; overflow-wrap:anywhere; }
.csat-kpis { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:14px; margin-bottom:16px; }
.csat-kpi { background:var(--surface,#fff); border:1px solid var(--border,#e3e9ee); border-radius:12px; padding:16px 18px; display:flex; flex-direction:column; gap:4px; box-shadow:0 1px 3px rgba(15,23,42,.05); }
.csat-big { font-size:2rem; font-weight:800; line-height:1.1; }
.csat-of { font-size:.95rem; font-weight:500; color:var(--muted,#64748b); }
.csat-meter { background:var(--border,#e2e8f0); border-radius:20px; height:8px; overflow:hidden; margin-top:6px; }
.csat-meter > div { height:100%; background:var(--primary); border-radius:20px; }
.csat-two { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:22px; }
.csat-two .card { margin-bottom:0; }
.csat-row { display:grid; grid-template-columns:minmax(80px, 30%) 1fr auto; align-items:center; gap:12px; padding:6px 0; }
.csat-stars { color:#f5b301; white-space:nowrap; font-size:.95rem; }
.csat-name { font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.csat-bar { background:var(--border,#e2e8f0); border-radius:6px; height:12px; overflow:hidden; }
.csat-bar > div { height:100%; background:#f5b301; border-radius:6px; }
.csat-num { font-size:.85rem; white-space:nowrap; text-align:right; min-width:70px; }
.csat-head { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin:0 0 12px; }
.csat-filter { display:inline-flex; align-items:center; gap:6px; font-size:.85rem; color:var(--muted,#64748b); cursor:pointer; }
.csat-recent.only-comments .csat-box.no-comment { display:none; }
@media (max-width: 1000px) { .csat-kpis { grid-template-columns:repeat(2, minmax(0, 1fr)); } .csat-two { grid-template-columns:1fr; } }
@media (max-width: 480px) { .csat-kpis { grid-template-columns:1fr; } }
.csat-recent { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:14px; }
@media (max-width: 1000px) { .csat-recent { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .csat-recent { grid-template-columns:1fr; } }
.csat-box { background:var(--surface,#fff); border:1px solid var(--border,#e3e9ee); border-radius:10px; padding:14px 16px; display:flex; flex-direction:column; gap:6px; font-size:.88rem; }
.csat-ticket { font-weight:600; text-decoration:none; line-height:1.35; }
.csat-comment { color:var(--muted,#64748b); font-style:italic; border-top:1px solid var(--border,#e3e9ee); padding-top:8px; margin-top:2px; overflow-wrap:anywhere; }
.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>