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.
Noch keine Dienste. Legen Sie unten zum Beispiel Webseite, E-Mail oder Telefon an.
-
+
-
= e($c['name']) ?> = e($c['description']) ?>
-
Automatische Prüfung: = e($checkInfo['label']) ?> · = $c['last_checked_at'] ? 'zuletzt ' . e(format_dt((string) $c['last_checked_at'])) : 'wartet auf ersten Lauf' ?>Manuell verwaltetPrüfziel fehlt – wartet auf Zuordnung · = (int) $c['last_check_ms'] ?> ms
+
= e($c['name']) ?> = e($c['description']) ?>
Deaktiviert – nicht öffentlich sichtbar und wird nicht geprüft
+
Automatische Prüfung: = e($checkInfo['label']) ?> · = $c['last_checked_at'] ? 'zuletzt ' . e(format_dt((string) $c['last_checked_at'])) : 'wartet auf ersten Lauf' ?> · alle = max(1, (int) ($c['check_interval_minutes'] ?? 1)) ?> Min.Manuell verwaltetPrüfziel fehlt – wartet auf Zuordnung · = (int) $c['last_check_ms'] ?> ms
= e((string) $c['last_check_error']) ?>
+
= e($st[0]) ?>
@@ -164,13 +204,15 @@ require __DIR__ . '/_header.php';
Prüfung konfigurieren
-
@@ -192,6 +234,36 @@ require __DIR__ . '/_header.php';
+
+
+
+
+Wartungsfenster planen
+Kündigt eine geplante Wartung mit Start- und Endzeit auf der öffentlichen Statusseite an. Während des Zeitraums wird der betroffene Dienst als „Wartung“ angezeigt.
+
+Geplante und kürzliche Wartungen
+ = time();
+ $windowFuture = empty($window['cancelled_at']) && strtotime((string) $window['starts_at']) > time();
+ ?>
+
+
= e($window['title']) ?> · = e($window['cancelled_at'] ? 'Abgesagt' : ($windowActive ? 'Läuft jetzt' : ($windowFuture ? 'Geplant' : 'Beendet'))) ?> · = e(format_dt((string) $window['starts_at'])) ?>–= e(format_dt((string) $window['ends_at'])) ?>= $window['component_name'] ? ' · ' . e($window['component_name']) : ' · Gesamte Plattform' ?>
+
= nl2br(e((string) $window['body'])) ?>
+
= csrf_field() ?>Absagen
+
+
+
+
+
Störung oder Wartung melden
= csrf_field() ?>
@@ -225,4 +297,33 @@ 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'];