Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:19 +00:00
parent c55cc54ba2
commit 27b3e4dbaf
19 changed files with 1559 additions and 9 deletions
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* „Meine Woche“: persönliche Kennzahlen eines Mitarbeiters (gelöste Tickets, gesendete Antworten, durchschnittliche Zeit bis zur ersten
* Antwort, Zufriedenheit der Kunden, Pünktlichkeit). Sichtbar nur für den Mitarbeiter selbst (staff/my_week.php).
*/
/** @return array<string,string> */
function my_week_periods(): array
{
return ['week' => 'Diese Woche', 'lastweek' => 'Letzte Woche', 'month' => 'Dieser Monat', 'lastmonth' => 'Letzter Monat'];
}
/**
* Zeitraum und der gleich lange Zeitraum davor (zum Vergleichen).
* @return array{from:string,to:string,prev_from:string,prev_to:string,label:string}
*/
function my_week_range(string $key, ?int $now = null): array
{
$now = $now ?? time();
$labels = my_week_periods();
if (!isset($labels[$key])) $key = 'week';
$monday = strtotime('monday this week', $now);
switch ($key) {
case 'lastweek':
$from = strtotime('-7 days', $monday);
$to = $monday - 1;
break;
case 'month':
$from = strtotime(date('Y-m-01', $now));
$to = $now;
break;
case 'lastmonth':
$first = strtotime(date('Y-m-01', $now));
$from = strtotime('-1 month', $first);
$to = $first - 1;
break;
default:
$from = $monday;
$to = $now;
}
// Vergleichszeitraum nach dem Kalender (nicht in Sekunden): Sommer-/Winterzeit verschiebt sonst die Grenze um eine Stunde
if ($key === 'week' || $key === 'lastweek') {
$prevFrom = strtotime('-7 days', $from);
$prevTo = strtotime('-7 days', $to);
} elseif ($key === 'month') {
$prevFrom = strtotime('-1 month', $from);
$prevTo = min(strtotime('-1 month', $to), $from - 1); // gleich weit in den Vormonat, höchstens bis dessen Ende
} else {
$prevFrom = strtotime('-1 month', $from);
$prevTo = $from - 1;
}
$fmt = fn(int $ts) => date('Y-m-d H:i:s', $ts);
return ['from' => $fmt($from), 'to' => $fmt($to), 'prev_from' => $fmt($prevFrom), 'prev_to' => $fmt($prevTo), 'label' => $labels[$key]];
}
/**
* Kennzahlen eines Mitarbeiters in einem Zeitraum.
* @return array{resolved:int,replies:int,first_response_seconds:?int,first_response_count:int,csat_avg:?float,csat_count:int,csat_positive:?int,on_time:?int}
*/
function my_week_stats(PDO $pdo, int $staffId, string $from, string $to): array
{
$one = function (string $sql, array $params) use ($pdo) {
$s = $pdo->prepare($sql);
$s->execute($params);
return $s->fetch(PDO::FETCH_NUM) ?: [];
};
// Gelöst/geschlossen und mir zugewiesen; „pünktlich“ = vor Ablauf der Lösungsfrist (oder ohne Frist)
[$resolved, $onTime] = $one(
"SELECT COUNT(*), SUM(CASE WHEN resolution_due_at IS NULL OR COALESCE(resolved_at, closed_at) <= resolution_due_at THEN 1 ELSE 0 END)
FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?",
[$staffId, $from, $to]
) + [0, 0];
[$replies] = $one(
"SELECT COUNT(*) FROM ticket_threads WHERE staff_id = ? AND author_type = 'staff' AND is_internal_note = 0 AND created_at BETWEEN ? AND ?",
[$staffId, $from, $to]
) + [0];
// Zeit bis zur ersten Antwort: nur Tickets, deren erste öffentliche Antwort von mir stammt (Arbeitszeit, wenn Geschäftszeiten eingerichtet sind)
$st = $pdo->prepare(
"SELECT t.created_at, t.first_responded_at FROM tickets t
WHERE t.is_spam = 0 AND t.first_responded_at BETWEEN ? AND ?
AND (SELECT th.staff_id FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'staff' AND th.is_internal_note = 0 ORDER BY th.created_at, th.id LIMIT 1) = ?
ORDER BY t.id DESC LIMIT 500"
);
$st->execute([$from, $to, $staffId]);
$sum = 0; $n = 0;
foreach ($st->fetchAll() as $r) {
if (empty($r['created_at']) || empty($r['first_responded_at'])) continue;
$sum += ticket_sla_working_seconds_between((string) $r['created_at'], (string) $r['first_responded_at']);
$n++;
}
[$csatN, $csatAvg, $csatGood] = $one(
"SELECT COUNT(*), AVG(rating), SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) FROM ticket_csat
WHERE staff_id = ? AND rating IS NOT NULL AND answered_at BETWEEN ? AND ?",
[$staffId, $from, $to]
) + [0, null, 0];
return [
'resolved' => (int) $resolved,
'replies' => (int) $replies,
'first_response_seconds' => $n > 0 ? (int) round($sum / $n) : null,
'first_response_count' => $n,
'csat_avg' => (int) $csatN > 0 ? round((float) $csatAvg, 2) : null,
'csat_count' => (int) $csatN,
'csat_positive' => (int) $csatN > 0 ? (int) round(100 * (int) $csatGood / (int) $csatN) : null,
'on_time' => (int) $resolved > 0 ? (int) round(100 * (int) $onTime / (int) $resolved) : null,
];
}
/** Was jetzt offen ist (unabhängig vom Zeitraum). @return array{open:int,overdue:int,waiting:int} */
function my_week_now(PDO $pdo, int $staffId, ?string $now = null): array
{
$now = $now ?? db_now();
$count = function (string $extra, array $params = []) use ($pdo, $staffId): int {
$s = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed'){$extra}");
$s->execute(array_merge([$staffId], $params));
return (int) $s->fetchColumn();
};
return [
'open' => $count(''),
'overdue' => $count(' AND resolution_due_at IS NOT NULL AND resolution_due_at < ?', [$now]),
'waiting' => $count(" AND status = 'open'"),
];
}
/** Gelöste Tickets je Tag im Zeitraum (für das Balkendiagramm). @return array<string,int> Datum => Anzahl */
function my_week_per_day(PDO $pdo, int $staffId, string $from, string $to): array
{
$out = [];
for ($d = strtotime(date('Y-m-d', strtotime($from))); $d <= strtotime($to); $d = strtotime('+1 day', $d)) $out[date('Y-m-d', $d)] = 0;
$s = $pdo->prepare("SELECT COALESCE(resolved_at, closed_at) AS at FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?");
$s->execute([$staffId, $from, $to]);
foreach ($s->fetchAll(PDO::FETCH_COLUMN) as $at) {
$day = date('Y-m-d', strtotime((string) $at));
if (isset($out[$day])) $out[$day]++;
}
return count($out) > 62 ? array_slice($out, -62, null, true) : $out;
}
/** Dauer kurz und lesbar: „2 Std 15 Min“, „40 Min“, „–“. */
function my_week_fmt_duration(?int $seconds): string
{
if ($seconds === null) return '–';
if ($seconds < 60) return 'unter 1 Min';
$m = (int) round($seconds / 60);
if ($m < 60) return $m . ' Min';
$h = intdiv($m, 60);
$rest = $m % 60;
if ($h >= 48) return round($h / 24) . ' Tage';
return $h . ' Std' . ($rest ? ' ' . $rest . ' Min' : '');
}
/**
* Veränderung zum Zeitraum davor als Text und Richtung (+1 besser, -1 schlechter, 0 gleich/unbekannt).
* @return array{text:string,trend:int}
*/
function my_week_delta(?float $now, ?float $before, bool $lowerIsBetter = false, string $unit = ''): array
{
if ($now === null || $before === null) return ['text' => 'kein Vergleich', 'trend' => 0];
$diff = $now - $before;
if (abs($diff) < 0.005) return ['text' => 'wie davor', 'trend' => 0];
$good = $lowerIsBetter ? $diff < 0 : $diff > 0;
$num = abs($diff) >= 10 || floor(abs($diff)) === abs($diff) ? (string) round(abs($diff)) : number_format(abs($diff), 1, ',', '');
return ['text' => ($diff > 0 ? '▲ ' : '▼ ') . $num . $unit . ' zum Zeitraum davor', 'trend' => $good ? 1 : -1];
}