From 2bc706cb098a0f36c102790f2e2c480f06f4804e Mon Sep 17 00:00:00 2001 From: M_Viper Date: Mon, 26 Oct 2026 21:19:14 +0000 Subject: [PATCH] Upload via GUI (340 Dateien) --- .../staff/_dashboard_recent_partial.php | 2 +- DeskHub-Webseite/staff/_header.php | 3 +- .../staff/_tickets_list_partial.php | 5 +- DeskHub-Webseite/staff/status_page.php | 125 +++++++++++++-- DeskHub-Webseite/staff/ticket_view.php | 144 ++++++++++++++---- DeskHub-Webseite/staff/tickets.php | 48 ++++-- 6 files changed, 273 insertions(+), 54 deletions(-) diff --git a/DeskHub-Webseite/staff/_dashboard_recent_partial.php b/DeskHub-Webseite/staff/_dashboard_recent_partial.php index c09ab6db..7881f4f9 100644 --- a/DeskHub-Webseite/staff/_dashboard_recent_partial.php +++ b/DeskHub-Webseite/staff/_dashboard_recent_partial.php @@ -34,7 +34,7 @@ - + diff --git a/DeskHub-Webseite/staff/_header.php b/DeskHub-Webseite/staff/_header.php index 05d5227a..c4b2a22c 100644 --- a/DeskHub-Webseite/staff/_header.php +++ b/DeskHub-Webseite/staff/_header.php @@ -58,7 +58,6 @@ if ('serviceWorker' in navigator) { Tickets Aufgaben Sperrliste - Verzeichnis Apps @@ -107,7 +106,7 @@ if ('serviceWorker' in navigator) { Vorlagen Seiten Sprachen - Ticket-VorlagenStatusseite + Ticket-VorlagenStatusseiteProblemmanagement E-Mail-Adressen E-Mail-Vorlagen Wissensdatenbank diff --git a/DeskHub-Webseite/staff/_tickets_list_partial.php b/DeskHub-Webseite/staff/_tickets_list_partial.php index dbb74c77..43b938bf 100644 --- a/DeskHub-Webseite/staff/_tickets_list_partial.php +++ b/DeskHub-Webseite/staff/_tickets_list_partial.php @@ -39,7 +39,8 @@ @@ -60,7 +61,7 @@ –' ?> - + diff --git a/DeskHub-Webseite/staff/status_page.php b/DeskHub-Webseite/staff/status_page.php index 01925a60..db89c60e 100644 --- a/DeskHub-Webseite/staff/status_page.php +++ b/DeskHub-Webseite/staff/status_page.php @@ -21,11 +21,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } elseif ($action === 'component_state') { status_set_component($pdo, (int) ($_POST['id'] ?? 0), (string) ($_POST['status'] ?? '')); flash('success', 'Zustand geändert.'); + } elseif ($action === 'component_enabled') { + $enabled = !empty($_POST['enabled']); + if (!status_set_component_enabled($pdo, (int) ($_POST['id'] ?? 0), $enabled)) $errors[] = 'Dienst konnte nicht gefunden werden.'; + else flash('success', $enabled ? 'Dienst aktiviert und wieder zur Prüfung freigegeben.' : 'Dienst deaktiviert, ausgeblendet und von Prüfungen ausgenommen.'); } elseif ($action === 'component_maintenance') { if (!status_toggle_component_maintenance($pdo, (int) ($_POST['id'] ?? 0))) $errors[] = 'Dienst konnte nicht gefunden werden.'; else flash('success', 'Wartungsstatus geändert.'); } elseif ($action === 'component_monitor') { - $ok = status_set_component_monitor($pdo, (int) ($_POST['id'] ?? 0), (string) ($_POST['monitor_type'] ?? 'auto'), (string) ($_POST['monitor_target'] ?? '')); + $ok = status_set_component_monitor($pdo, (int) ($_POST['id'] ?? 0), (string) ($_POST['monitor_type'] ?? 'auto'), (string) ($_POST['monitor_target'] ?? ''), (int) ($_POST['check_interval_minutes'] ?? 1)); if (!$ok) $errors[] = 'Bitte Prüfart und eine gültige Zieladresse prüfen.'; else flash('success', 'Automatische Prüfung gespeichert.'); } elseif ($action === 'auto_discover') { @@ -52,7 +56,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } elseif ($action === 'component_restore_core') { $restored = status_restore_core_components($pdo); audit_log($admin['id'], 'Statusseite: fehlende DeskHub-Standarddienste wiederhergestellt'); - flash('success', 'Fehlende DeskHub-Dienste und passende Kategorien wurden hinzugefuegt. Vorhandene Eintraege blieben erhalten.'); + flash('success', $restored > 0 + ? 'Fehlende DeskHub-Dienste wurden hinzugefügt. Der Minecraft-Server liegt unter „Apps“ und bleibt bis zur Adresseingabe deaktiviert.' + : 'Alle DeskHub-Dienste sind bereits vorhanden. Der Minecraft-Server liegt unter „Apps“; Adresse und Port eintragen und danach aktivieren.'); } elseif ($action === 'incident_create') { $cid = (int) ($_POST['component_id'] ?? 0) ?: null; $r = status_create_incident($pdo, (string) ($_POST['title'] ?? ''), (string) ($_POST['body'] ?? ''), (string) ($_POST['status'] ?? 'investigating'), $cid, ($_POST['component_state'] ?? '') !== '' ? (string) $_POST['component_state'] : null, (int) $admin['id']); @@ -63,21 +69,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } elseif ($action === 'incident_delete') { status_delete_incident($pdo, (int) ($_POST['id'] ?? 0)); flash('success', 'Meldung gelöscht.'); + } elseif ($action === 'maintenance_create') { + $componentId = (int) ($_POST['component_id'] ?? 0) ?: null; + $result = status_create_maintenance_window($pdo, (string) ($_POST['title'] ?? ''), (string) ($_POST['body'] ?? ''), (string) ($_POST['starts_at'] ?? ''), (string) ($_POST['ends_at'] ?? ''), $componentId, (int) $admin['id']); + if (!$result['ok']) $errors[] = $result['error']; + else { audit_log($admin['id'], 'Statusseite: Wartungsfenster geplant'); flash('success', 'Wartungsfenster wurde geplant.'); } + } elseif ($action === 'maintenance_cancel') { + if (!status_cancel_maintenance_window($pdo, (int) ($_POST['id'] ?? 0))) $errors[] = 'Wartungsfenster konnte nicht gefunden werden.'; + else { audit_log($admin['id'], 'Statusseite: Wartungsfenster abgesagt'); flash('success', 'Wartungsfenster wurde abgesagt.'); } } if (!$errors) redirect('/staff/status_page.php'); } -$components = status_components($pdo); +$components = status_components($pdo, true); $categories = status_categories($pdo); $automaticChecks = 0; $pendingChecks = 0; foreach ($components as $component) { + if (empty($component['is_enabled'])) continue; if (status_component_monitor_target($pdo, $component) !== null) $automaticChecks++; elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pendingChecks++; } -$incidents = status_incidents($pdo, 60); +$incidents = status_incidents($pdo, 60, false, true); +$maintenanceWindows = status_maintenance_windows($pdo, true); $states = status_component_states(); $monitorTypes = status_monitor_types(); +$checkIntervals = [1 => 'Jede Minute', 2 => 'Alle 2 Minuten', 5 => 'Alle 5 Minuten', 10 => 'Alle 10 Minuten', 15 => 'Alle 15 Minuten', 30 => 'Alle 30 Minuten', 60 => 'Jede Stunde']; $incStates = status_incident_states(); $pageTitle = 'Statusseite'; $activePage = 'status_page'; @@ -87,11 +104,22 @@ require __DIR__ . '/_header.php';

Eine öffentliche Seite, auf der Ihre Kunden sehen, ob alles läuft, und über Störungen und Wartungen informiert werden. Adresse: /status.php

+ +
+ + +
+
Dienst werden automatisch geprüft · auf ein Prüfziel
- DeskHub erkennt Datenbank, API, konfigurierte Mailserver, Push-Zugang sowie Android- und Windows-Verbindungen automatisch. Android-Apps gelten bei einem API-Kontakt innerhalb der letzten 60 Minuten, Windows-Apps innerhalb der letzten 10 Minuten als verbunden. Statusprüfungen laufen jede Minute; externe Webseiten und weitere Apps benötigen eine Prüf-URL. + DeskHub erkennt Datenbank, API, konfigurierte Mailserver, Push-Zugang sowie Android- und Windows-Verbindungen automatisch. Android-Apps gelten bei einem API-Kontakt innerhalb der letzten 60 Minuten, Windows-Apps innerhalb der letzten 10 Minuten als verbunden. Der Zeitgeber prüft jede Minute, einzelne Dienste können aber mit einem längeren Intervall geprüft werden.
@@ -120,12 +148,22 @@ require __DIR__ . '/_header.php'; .status-component-add{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0!important;padding:0!important;border:0!important} .status-component-add input,.status-component-add select{width:100%;min-width:0;box-sizing:border-box} .status-component-add button{grid-column:1/-1;justify-self:start} +.status-monitor-form{display:grid;grid-template-columns:minmax(160px,210px) minmax(220px,1fr) minmax(145px,175px) auto;gap:8px;align-items:end;padding:10px 0} +.status-monitor-form>div{min-width:0} +.status-monitor-form label{display:block;margin:0 0 6px} +.status-monitor-form input,.status-monitor-form select{box-sizing:border-box;width:100%;min-height:40px} +.status-monitor-form>button{grid-column:4;grid-row:1;min-height:40px;white-space:nowrap} +.status-monitor-help{grid-column:2;margin-top:-4px} +.status-minecraft-row{flex-wrap:nowrap!important;gap:10px!important} +.status-minecraft-row>form:last-child{margin-left:auto} +@media(max-width:650px){.status-monitor-form{grid-template-columns:1fr}.status-monitor-form>button{grid-column:1;grid-row:auto}.status-monitor-help{grid-column:1}} +@media(max-width:900px){.status-minecraft-row{flex-wrap:wrap!important}} @media(max-width:420px){.status-category-row{grid-template-columns:minmax(0,1fr) 94px 30px;gap:5px}}

Dienste & automatische Prüfungen

-

Automatisch geprüfte Dienste erhalten ihren Status selbst. Während geplanter Arbeiten kannst du sie auf Wartung setzen. Für nicht erkannte externe Dienste ist eine Prüf-URL nötig.

+

Automatisch geprüfte Dienste erhalten ihren Status selbst. Jeder Dienst lässt sich vollständig deaktivieren; er wird dann auf der öffentlichen Seite ausgeblendet und nicht mehr geprüft.

Eigenen Dienst hinzufügen
@@ -137,20 +175,22 @@ require __DIR__ . '/_header.php';

Noch keine Dienste. Legen Sie unten zum Beispiel Webseite, E-Mail oder Telefon an.

-
+
-
-
Automatische Prüfung: · Manuell verwaltetPrüfziel fehlt – wartet auf Zuordnung · ms
+
Deaktiviert – nicht öffentlich sichtbar und wird nicht geprüft
+
Automatische Prüfung: · · alle Min.Manuell verwaltetPrüfziel fehlt – wartet auf Zuordnung · ms
+
@@ -164,13 +204,15 @@ require __DIR__ . '/_header.php';
Prüfung konfigurieren -
+
Keine passenden Ziele in der Dienst-Konfiguration gefunden
-
+
+
+
@@ -192,6 +234,36 @@ require __DIR__ . '/_header.php';
+
+ + diff --git a/DeskHub-Webseite/staff/ticket_view.php b/DeskHub-Webseite/staff/ticket_view.php index 59ece48f..7433c284 100644 --- a/DeskHub-Webseite/staff/ticket_view.php +++ b/DeskHub-Webseite/staff/ticket_view.php @@ -86,6 +86,9 @@ $myWorkSessionStartedAt = $myWorkSessionStmt->fetchColumn() ?: null; $collabStmt = $pdo->prepare('SELECT * FROM ticket_collaborators WHERE ticket_id = ? ORDER BY created_at'); $collabStmt->execute([$ticket['id']]); $collaborators = $collabStmt->fetchAll(); +$ticketProblems = ticket_problems_for_ticket($pdo, (int) $ticket['id']); +$openProblems = ticket_problems($pdo, true); +$resolutionReasons = ticket_resolution_reasons(); $departments = departments_for_select($pdo, [(int) $ticket['department_id']]); $priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll(); @@ -164,16 +167,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } if ($banEmail) { + $priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE requester_email=? AND status NOT IN ('closed')"); + $priorStmt->execute([$banEmail]); $priorTickets = $priorStmt->fetchAll(); $affected = $pdo->prepare( - "UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE requester_email=? AND status NOT IN ('closed')" + "UPDATE tickets SET status='closed', resolution_code='spam', resolution_note='Absender gesperrt', closed_at=?, updated_at=? WHERE requester_email=? AND status NOT IN ('closed')" ); $affected->execute([db_now(), db_now(), $banEmail]); + ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']); } if ($banDomain) { + $priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')"); + $priorStmt->execute([$banDomain]); $priorTickets = $priorStmt->fetchAll(); $affected2 = $pdo->prepare( - "UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')" + "UPDATE tickets SET status='closed', resolution_code='spam', resolution_note='Domain gesperrt', closed_at=?, updated_at=? WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')" ); $affected2->execute([db_now(), db_now(), $banDomain]); + ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']); } flash('success', 'Gesperrt und alle zugehörigen Tickets geschlossen.'); redirect('/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number'])); @@ -235,6 +244,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $errors[] = 'Bitte geben Sie eine Nachricht ein.'; } else { $isNote = $action === 'note' ? 1 : 0; + $newStatus = !$isNote ? (string) ($_POST['set_status'] ?? 'answered') : (string) $ticket['status']; + $resolutionCode = (string) ($_POST['resolution_code'] ?? ''); + $resolutionNote = trim(mb_substr((string) ($_POST['resolution_note'] ?? ''), 0, 2000)); + if (!$isNote && in_array($newStatus, ['resolved', 'closed'], true)) { + $requirePerm('close_ticket'); + if (!isset(ticket_resolution_reasons()[$resolutionCode])) { flash('error', 'Bitte beim Schließen einen Abschlussgrund auswählen.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } + } $stmt = $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)' @@ -255,24 +271,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } if (!$isNote) { - $newStatus = $_POST['set_status'] ?: 'answered'; $updateFirstResponse = $ticket['first_responded_at'] === null; // Auto-Zuweisung an antwortenden Mitarbeiter, falls Ticket noch niemandem zugewiesen ist $autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === ''); - $stmt = $pdo->prepare( - 'UPDATE tickets SET status = ?, updated_at = ?' . - ($updateFirstResponse ? ', first_responded_at = ?' : '') . - ($autoAssign ? ', assigned_staff_id = ?' : '') . - ' WHERE id = ?' - ); - $args = []; - $args[] = $newStatus; - $args[] = $now; - if ($updateFirstResponse) $args[] = $now; - if ($autoAssign) $args[] = $staff['id']; - $args[] = $ticket['id']; - $stmt->execute($args); + $resolvedAt = $newStatus === 'resolved' && empty($ticket['resolved_at']) ? $now : $ticket['resolved_at']; + $closedAt = $newStatus === 'closed' && empty($ticket['closed_at']) ? $now : $ticket['closed_at']; + $stmt = $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ?, first_responded_at = COALESCE(first_responded_at, ?), assigned_staff_id = COALESCE(assigned_staff_id, ?), resolved_at = ?, closed_at = ?, resolution_code = ?, resolution_note = ? WHERE id = ?'); + $stmt->execute([ + $newStatus, $now, $now, (int) $staff['id'], $resolvedAt, $closedAt, + in_array($newStatus, ['resolved', 'closed'], true) ? $resolutionCode : $ticket['resolution_code'], + in_array($newStatus, ['resolved', 'closed'], true) ? ($resolutionNote !== '' ? $resolutionNote : $ticket['resolution_note']) : $ticket['resolution_note'], + $ticket['id'], + ]); + ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']); if ($autoAssign) { log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (erste Antwort)'); } @@ -491,14 +503,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { )->execute([$dst['id'], 'staff', $staff['id'], $note, $now]); // Quell-Ticket schließen + merged_into setzen - $pdo->prepare('UPDATE tickets SET status = ?, merged_into = ?, updated_at = ? WHERE id = ?') - ->execute(['closed', $dst['id'], $now, $src['id']]); + $pdo->prepare('UPDATE tickets SET status = ?, merged_into = ?, resolution_code = ?, resolution_note = ?, updated_at = ? WHERE id = ?') + ->execute(['closed', $dst['id'], 'duplicate', 'Zusammengeführt mit Ticket #' . $dst['ticket_number'], $now, $src['id']]); // Ziel-Ticket aktualisieren (updated_at) $pdo->prepare('UPDATE tickets SET updated_at = ? WHERE id = ?') ->execute([$now, $dst['id']]); $pdo->commit(); + ticket_sla_status_transition($pdo, (int) $src['id'], (string) $src['status'], 'closed', (int) $staff['id']); log_activity($pdo, $dst['id'], $staff['id'], "Ticket #{$src['ticket_number']} hierher zusammengeführt"); log_activity($pdo, $src['id'], $staff['id'], "Zusammengeführt mit Ticket #{$dst['ticket_number']}"); audit_log($staff['id'], "Merge: #{$src['ticket_number']} → #{$dst['ticket_number']}"); @@ -512,6 +525,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { redirect('/staff/ticket_view.php?n=' . urlencode($number)); } + if ($action === 'problem_link') { + if ($staff['role'] !== 'admin') { flash('error', 'Nur Administratoren dürfen Tickets mit Problemen verknüpfen.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } + $problemIdToLink = (int) ($_POST['problem_id'] ?? 0); + if (!ticket_problem_link($pdo, $problemIdToLink, (int) $ticket['id'], (int) $staff['id'])) flash('error', 'Problem konnte nicht gefunden werden.'); + else { log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Mit ' . ticket_problem_number($problemIdToLink) . ' verknüpft'); flash('success', 'Ticket mit Problem verknüpft.'); } + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + + if ($action === 'problem_unlink') { + if ($staff['role'] !== 'admin') { flash('error', 'Nur Administratoren dürfen Problem-Verknüpfungen ändern.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } + $problemIdToUnlink = (int) ($_POST['problem_id'] ?? 0); + ticket_problem_unlink($pdo, $problemIdToUnlink, (int) $ticket['id']); + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Problemverknüpfung ' . ticket_problem_number($problemIdToUnlink) . ' entfernt'); + flash('success', 'Ticket vom Problem getrennt.'); + redirect('/staff/ticket_view.php?n=' . urlencode($number)); + } + if ($action === 'update_props') { $deptId = (int) $_POST['department_id']; if ($myDeptIds !== null && !in_array($deptId, $myDeptIds, true) && $deptId !== (int) $ticket['department_id']) { @@ -527,10 +557,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($assignedTo !== ($ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id']) || $teamId !== ($ticket['team_id'] === null ? null : (int) $ticket['team_id'])) $requirePerm('assign_ticket'); if ($status !== $ticket['status'] && in_array($status, ['resolved', 'closed'], true)) $requirePerm('close_ticket'); + $resolutionCode = (string) ($_POST['resolution_code'] ?? $ticket['resolution_code'] ?? ''); + $resolutionNote = trim(mb_substr((string) ($_POST['resolution_note'] ?? $ticket['resolution_note'] ?? ''), 0, 2000)); + if ($status !== $ticket['status'] && in_array($status, ['resolved', 'closed'], true)) { + if (!isset(ticket_resolution_reasons()[$resolutionCode])) { flash('error', 'Bitte beim Schließen einen Abschlussgrund auswählen.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } + } + $prio = null; foreach ($priorities as $p) { if ($p['id'] == $prioId) $prio = $p; } - $sla = $prio ? calc_sla_dates($prio, $ticket['created_at'], $deptId, !empty($ticket['help_topic_id']) ? (int) $ticket['help_topic_id'] : null) : null; + $slaChanged = $deptId !== (int) $ticket['department_id'] || $prioId !== (int) $ticket['priority_id']; + $sla = $prio && $slaChanged ? calc_sla_dates($prio, $ticket['created_at'], $deptId, !empty($ticket['help_topic_id']) ? (int) $ticket['help_topic_id'] : null) : null; + if ($sla) { + $priorPauses = ticket_sla_completed_pause_seconds($pdo, (int) $ticket['id']); + foreach (['first_response_due_at', 'resolution_due_at'] as $deadline) if (!empty($sla[$deadline]) && $priorPauses > 0) $sla[$deadline] = ticket_sla_shift_deadline($sla[$deadline], $priorPauses); + if (!empty($sla['first_response_due_at'])) ticket_sla_history_add($pdo, (int) $ticket['id'], 'adjusted', 'first_response', 'Neue Frist: ' . $sla['first_response_due_at'], (int) $staff['id']); + if (!empty($sla['resolution_due_at'])) ticket_sla_history_add($pdo, (int) $ticket['id'], 'adjusted', 'resolution', 'Neue Frist: ' . $sla['resolution_due_at'], (int) $staff['id']); + } $resolvedAt = $ticket['resolved_at']; $closedAt = $ticket['closed_at']; @@ -539,14 +582,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt = $pdo->prepare( 'UPDATE tickets SET department_id=?, priority_id=?, status=?, assigned_staff_id=?, team_id=?, - first_response_due_at=?, resolution_due_at=?, resolved_at=?, closed_at=?, updated_at=? WHERE id=?' + first_response_due_at=?, resolution_due_at=?, resolved_at=?, closed_at=?, resolution_code=?, resolution_note=?, updated_at=? WHERE id=?' ); $stmt->execute([ $deptId, $prioId, $status, $assignedTo, $teamId, $sla['first_response_due_at'] ?? $ticket['first_response_due_at'], $sla['resolution_due_at'] ?? $ticket['resolution_due_at'], - $resolvedAt, $closedAt, $now, $ticket['id'], + $resolvedAt, $closedAt, + in_array($status, ['resolved', 'closed'], true) ? ($resolutionCode ?: $ticket['resolution_code']) : $ticket['resolution_code'], + in_array($status, ['resolved', 'closed'], true) ? ($resolutionNote ?: $ticket['resolution_note']) : $ticket['resolution_note'], + $now, $ticket['id'], ]); + ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $status, (int) $staff['id']); if ($assignedTo !== (int) ($ticket['assigned_staff_id'] ?? 0) && $assignedTo !== null) { $agentName = ''; @@ -648,7 +695,9 @@ $pageTitle = "Ticket #{$ticket['ticket_number']}"; $activePage = 'tickets'; require __DIR__ . '/_header.php'; [$statusLabel, $statusColor] = status_label($ticket['status']); -$isOverdue = $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time() && !in_array($ticket['status'], ['resolved','closed']); +$slaPaused = !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']); +$slaHistory = ticket_sla_history_for_ticket($pdo, $ticket); +$isOverdue = !empty($ticket['is_overdue']) || (!$slaPaused && $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time() && !in_array($ticket['status'], ['resolved','closed'])); // Bewertung laden $ticketRating = null; if (in_array($ticket['status'], ['resolved','closed'])) { @@ -1143,13 +1192,22 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
-
+ +
@@ -1189,13 +1247,19 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source']; ['Offen'=>'open','Wartet auf Kunde'=>'pending','Beantwortet'=>'answered','Pausiert'=>'on_hold','Gelöst'=>'resolved','Geschlossen'=>'closed']] as $k=>$opts): ?>
- 'Offen','pending'=>'Wartet auf Kunde','answered'=>'Beantwortet','on_hold'=>'Pausiert','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
+
+ + + + +
+

Dieses Ticket ist keinem bekannten Problem zugeordnet.

+
SLA-Fristen
+
SLA pausiert seit – die Fristen laufen bei „“ nicht weiter.
Erstreaktion
- +
-
+
Lösung
- +
-
+
+
+ SLA-Verlauf +
+ 'Frist gestartet', 'adjusted' => 'Frist angepasst', 'paused' => 'Frist pausiert', 'resumed' => 'Frist fortgesetzt', 'overdue' => 'Frist überschritten']; + $deadlineLabels = ['first_response' => 'Erstreaktion', 'resolution' => 'Lösung']; + ?> +
+ +

Noch keine SLA-Ereignisse erfasst.

+
Weitere Felder
diff --git a/DeskHub-Webseite/staff/tickets.php b/DeskHub-Webseite/staff/tickets.php index 38a9a15a..e5762ced 100644 --- a/DeskHub-Webseite/staff/tickets.php +++ b/DeskHub-Webseite/staff/tickets.php @@ -10,6 +10,7 @@ $priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetc $agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll(); $bulkErrors = []; +$resolutionReasons = ticket_resolution_reasons(); // --------------------------------------------------------------- // Gespeicherte Ansichten: speichern / löschen @@ -60,6 +61,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== ' if (count($allowedTickets) < count($ticketIds)) { $bulkErrors[] = 'Einige ausgewählte Tickets gehören zu Abteilungen, auf die Sie keinen Zugriff haben, und wurden übersprungen.'; } + $bulkResolutionCode = (string) ($_POST['bulk_resolution_code'] ?? ''); + $bulkResolutionNote = trim(mb_substr((string) ($_POST['bulk_resolution_note'] ?? ''), 0, 2000)); + if ($bulkAction === 'status' && in_array((string) ($_POST['bulk_status'] ?? ''), ['resolved', 'closed'], true) && !isset($resolutionReasons[$bulkResolutionCode])) { + $bulkErrors[] = 'Bitte für das Schließen einen Abschlussgrund auswählen.'; + $allowedTickets = []; + } $now = db_now(); // Abteilung wechseln: Ziel prüfen (muss existieren und für den Mitarbeiter erreichbar sein), Rechte je Ticket später @@ -88,6 +95,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== ' $prioStmt->execute([$t['priority_id']]); $prioRow = $prioStmt->fetch(); $sla = $prioRow ? calc_sla_dates($prioRow, $t['created_at'], (int) $moveTarget['id'], !empty($t['help_topic_id']) ? (int) $t['help_topic_id'] : null) : null; + if ($sla) { + $priorPauses = ticket_sla_completed_pause_seconds($pdo, (int) $t['id']); + foreach (['first_response_due_at', 'resolution_due_at'] as $deadline) if (!empty($sla[$deadline]) && $priorPauses > 0) $sla[$deadline] = ticket_sla_shift_deadline($sla[$deadline], $priorPauses); + if (!empty($sla['first_response_due_at'])) ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'first_response', 'Neue Frist nach Abteilungswechsel: ' . $sla['first_response_due_at'], (int) $staff['id']); + if (!empty($sla['resolution_due_at'])) ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'resolution', 'Neue Frist nach Abteilungswechsel: ' . $sla['resolution_due_at'], (int) $staff['id']); + } $pdo->prepare('UPDATE tickets SET department_id=?, first_response_due_at=?, resolution_due_at=?, updated_at=? WHERE id=?') ->execute([(int) $moveTarget['id'], $sla['first_response_due_at'] ?? $t['first_response_due_at'], $sla['resolution_due_at'] ?? $t['resolution_due_at'], $now, $t['id']]); $oldDeptName = ''; @@ -103,14 +116,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== ' log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Mitarbeiter zugewiesen'); } elseif ($bulkAction === 'priority') { $newPrio = (int) $_POST['bulk_priority_id']; - $pdo->prepare('UPDATE tickets SET priority_id=?, updated_at=? WHERE id=?')->execute([$newPrio, $now, $t['id']]); + $prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); + $prioStmt->execute([$newPrio]); $newPriority = $prioStmt->fetch(); + $sla = $newPriority ? calc_sla_dates($newPriority, $t['created_at'], (int) $t['department_id'], !empty($t['help_topic_id']) ? (int) $t['help_topic_id'] : null) : null; + if ($sla) { + $priorPauses = ticket_sla_completed_pause_seconds($pdo, (int) $t['id']); + foreach (['first_response_due_at', 'resolution_due_at'] as $deadline) if (!empty($sla[$deadline]) && $priorPauses > 0) $sla[$deadline] = ticket_sla_shift_deadline($sla[$deadline], $priorPauses); + ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'first_response', 'Neue Frist nach Prioritaetswechsel: ' . $sla['first_response_due_at'], (int) $staff['id']); + ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'resolution', 'Neue Frist nach Prioritaetswechsel: ' . $sla['resolution_due_at'], (int) $staff['id']); + } + $pdo->prepare('UPDATE tickets SET priority_id=?, first_response_due_at=?, resolution_due_at=?, updated_at=? WHERE id=?') + ->execute([$newPrio, $sla['first_response_due_at'] ?? $t['first_response_due_at'], $sla['resolution_due_at'] ?? $t['resolution_due_at'], $now, $t['id']]); log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Priorität geändert'); } elseif ($bulkAction === 'status') { $newStatus = $_POST['bulk_status']; $resolvedAt = $newStatus === 'resolved' ? $now : $t['resolved_at']; $closedAt = $newStatus === 'closed' ? $now : $t['closed_at']; - $pdo->prepare('UPDATE tickets SET status=?, resolved_at=?, closed_at=?, updated_at=? WHERE id=?') - ->execute([$newStatus, $resolvedAt, $closedAt, $now, $t['id']]); + $pdo->prepare('UPDATE tickets SET status=?, resolved_at=?, closed_at=?, resolution_code=?, resolution_note=?, updated_at=? WHERE id=?') + ->execute([$newStatus, $resolvedAt, $closedAt, in_array($newStatus, ['resolved', 'closed'], true) ? $bulkResolutionCode : $t['resolution_code'], in_array($newStatus, ['resolved', 'closed'], true) ? ($bulkResolutionNote ?: $t['resolution_note']) : $t['resolution_note'], $now, $t['id']]); + ticket_sla_status_transition($pdo, (int) $t['id'], (string) $t['status'], (string) $newStatus, (int) $staff['id']); log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Status geändert'); } elseif ($bulkAction === 'delete') { delete_ticket($pdo, $t, $staff['id']); @@ -118,6 +142,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== ' $email = $t['requester_email']; $domain = $email ? substr($email, strpos($email,'@')+1) : ''; if ($email) { + $priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE requester_email=? AND status NOT IN ('closed')"); + $priorStmt->execute([$email]); $priorTickets = $priorStmt->fetchAll(); $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?'); $chk->execute([$email]); if (!$chk->fetchColumn()) { @@ -125,8 +151,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== ' ->execute(['email', $email, 'Bulk-Sperre', $staff['id'], $now]); } // Alle Tickets dieses Absenders schließen - $pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE requester_email=? AND status NOT IN ('closed')") + $pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Absender im Rahmen einer Sammelaktion gesperrt',closed_at=?,updated_at=? WHERE requester_email=? AND status NOT IN ('closed')") ->execute([$now, $now, $email]); + ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']); } } } @@ -178,7 +205,7 @@ if ($activeQuick === 'me') { $where[] = 't.assigned_staff_id IS NULL'; $where[] = "t.status NOT IN ('resolved','closed')"; } elseif ($activeQuick === 'overdue') { - $where[] = 't.resolution_due_at < ?'; $params[] = db_now(); + $where[] = '(t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at < ?))'; $params[] = db_now(); $where[] = "t.status NOT IN ('resolved','closed')"; } elseif ($activeQuick === 'highprio') { $where[] = 't.priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)'; @@ -203,13 +230,13 @@ if ($filterSla !== '') { $now = db_now(); $riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600); if ($filterSla === 'overdue') { - $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; + $where[] = "t.resolution_due_at IS NOT NULL AND (t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at < ?)) AND t.status NOT IN ('resolved','closed')"; $params[] = $now; } elseif ($filterSla === 'at_risk') { - $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; + $where[] = "t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')"; array_push($params, $now, $riskThreshold); } elseif ($filterSla === 'on_time') { - $where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')"; + $where[] = "t.sla_paused_at IS NULL AND (t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')"; $params[] = $riskThreshold; } } @@ -307,7 +334,7 @@ $_stMe = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id=? A $_stMe->execute([$staff['id']]); $countMe = (int)$_stMe->fetchColumn(); $countUnassigned= (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')")->fetchColumn(); -$_stOd = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolution_due_at < ? AND status NOT IN ('resolved','closed')"); +$_stOd = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE (is_overdue = 1 OR (sla_paused_at IS NULL AND resolution_due_at < ?)) AND status NOT IN ('resolved','closed')"); $_stOd->execute([db_now()]); $countOverdue = (int)$_stOd->fetchColumn(); $countHighPrio = (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)")->fetchColumn(); @@ -602,6 +629,9 @@ document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.st +