1079 lines
62 KiB
PHP
1079 lines
62 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':
|
||
// 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 < 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 & 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>
|