804 lines
39 KiB
PHP
804 lines
39 KiB
PHP
<?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 (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';
|
||
?>
|
||
|
||
<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" 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>
|
||
<?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>
|
||
|
||
<?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; }
|
||
if ($csat && (csat_enabled() || $csat['sent'] > 0)):
|
||
?>
|
||
<div class="card" style="margin-bottom:16px">
|
||
<h3>Kundenzufriedenheit <span class="muted" style="font-weight:400; font-size:.8rem">(Bewertungen im gewählten Zeitraum)</span></h3>
|
||
<?php if (!$csat['sent']): ?>
|
||
<p class="muted">Im Zeitraum wurden noch keine Abfragen verschickt.</p>
|
||
<?php else: ?>
|
||
<div class="stat-grid" style="margin-bottom:12px">
|
||
<div class="stat-card"><div class="num"><?= $csat['avg'] !== null ? number_format($csat['avg'], 1, ',', '') . ' ★' : '–' ?></div><div class="lbl">Ø Bewertung</div></div>
|
||
<div class="stat-card"><div class="num"><?= $csat['answered'] ?></div><div class="lbl">Antworten von <?= $csat['sent'] ?> Abfragen</div></div>
|
||
<div class="stat-card"><div class="num"><?= $csat['rate'] !== null ? number_format($csat['rate'], 0, ',', '') . ' %' : '–' ?></div><div class="lbl">Rücklaufquote</div></div>
|
||
</div>
|
||
<?php $distMax = max(1, max($csat['dist'])); ?>
|
||
<table style="width:100%;max-width:520px">
|
||
<?php for ($i = 5; $i >= 1; $i--): ?>
|
||
<tr><td style="white-space:nowrap;width:1%;color:#f5b301"><?= e(csat_stars($i)) ?></td>
|
||
<td><div style="background:#e2e8f0;border-radius:6px;height:12px"><div style="background:#f5b301;height:12px;border-radius:6px;width:<?= (int) round($csat['dist'][$i] * 100 / $distMax) ?>%"></div></div></td>
|
||
<td style="width:1%;text-align:right"><?= $csat['dist'][$i] ?></td></tr>
|
||
<?php endfor; ?>
|
||
</table>
|
||
<?php if ($csat['byStaff']): ?>
|
||
<h4 style="margin:14px 0 6px">Nach Mitarbeiter</h4>
|
||
<table style="width:100%;max-width:520px"><?php foreach ($csat['byStaff'] as $row): ?>
|
||
<tr><td><?= e($row['name']) ?></td><td style="text-align:right"><?= number_format($row['avg'], 1, ',', '') ?> ★</td><td class="muted" style="text-align:right;width:1%;white-space:nowrap"><?= $row['count'] ?> Bewertung<?= $row['count'] === 1 ? '' : 'en' ?></td></tr>
|
||
<?php endforeach; ?></table>
|
||
<?php endif; ?>
|
||
<?php if ($csat['recent']): ?>
|
||
<h4 style="margin:14px 0 6px">Letzte Bewertungen</h4>
|
||
<?php foreach ($csat['recent'] as $row): ?>
|
||
<div style="padding:6px 0;border-top:1px solid var(--border,#e3e9ee);font-size:.88rem">
|
||
<span style="color:#f5b301"><?= e(csat_stars($row['rating'])) ?></span>
|
||
<a href="/staff/ticket_view.php?n=<?= e(rawurlencode($row['number'])) ?>">#<?= e($row['number']) ?></a> <?= e($row['subject']) ?>
|
||
<?php if ($row['comment'] !== ''): ?><div class="muted" style="margin-top:2px">„<?= e($row['comment']) ?>“</div><?php endif; ?>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<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'; ?>
|