diff --git a/DeskHub-Webseite/api/staff/v1/banlist.php b/DeskHub-Webseite/api/staff/v1/banlist.php index fe98099c..9fec42af 100644 --- a/DeskHub-Webseite/api/staff/v1/banlist.php +++ b/DeskHub-Webseite/api/staff/v1/banlist.php @@ -31,8 +31,12 @@ $action = $body['action'] ?? ''; function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void { $col = $matchType === 'email' ? 'requester_email' : sql_email_domain('requester_email'); - $pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')") + $before = $pdo->prepare("SELECT id,status FROM tickets WHERE $col=? AND status NOT IN ('closed')"); + $before->execute([$value]); + $rows = $before->fetchAll(); + $pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Im Rahmen einer Sperre geschlossen',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')") ->execute([db_now(), db_now(), $value]); + ticket_sla_transition_rows($pdo, $rows, 'closed', (int) ($GLOBALS['__api_staff']['id'] ?? 0) ?: null); } if ($action === 'create') { diff --git a/DeskHub-Webseite/api/staff/v1/common.php b/DeskHub-Webseite/api/staff/v1/common.php index c5c24b9e..326ae5b4 100644 --- a/DeskHub-Webseite/api/staff/v1/common.php +++ b/DeskHub-Webseite/api/staff/v1/common.php @@ -248,6 +248,8 @@ function api_ticket_summary(array $ticket): array 'requesterName' => $ticket['requester_name'], 'requesterEmail' => $ticket['requester_email'], 'status' => $ticket['status'], + 'resolutionCode' => $ticket['resolution_code'] ?? null, + 'resolutionNote' => $ticket['resolution_note'] ?? null, 'priority' => [ 'id' => (int) $ticket['priority_id'], 'name' => $ticket['priority_name'], @@ -261,14 +263,16 @@ function api_ticket_summary(array $ticket): array 'id' => (int) $ticket['assigned_staff_id'], 'name' => $ticket['staff_name'], ], - 'isOverdue' => !empty($ticket['resolution_due_at']) + 'slaPaused' => !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']), + 'slaPausedAt' => $ticket['sla_paused_at'] ?? null, + 'isOverdue' => !in_array($ticket['status'], ['resolved', 'closed'], true) && (!empty($ticket['is_overdue']) || (empty($ticket['sla_paused_at']) && !empty($ticket['resolution_due_at']) && strtotime($ticket['resolution_due_at']) < time() - && !in_array($ticket['status'], ['resolved', 'closed'], true), + )), // Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min // (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im // Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus // den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format). - 'dueLabel' => !empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null, + 'dueLabel' => !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']) ? 'Pausiert' : (!empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null), 'isSpam' => !empty($ticket['is_spam']), 'createdAt' => $ticket['created_at'], 'updatedAt' => $ticket['updated_at'], @@ -304,7 +308,7 @@ function api_ticket_collaborators(PDO $pdo, int $ticketId): array return array_map(fn($r) => ['id' => (int) $r['id'], 'email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? '')], $s->fetchAll()); } -function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int +function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null, ?string $resolutionCode = null, string $resolutionNote = ''): int { $permission = $type === 'note' ? 'post_note' : 'reply_ticket'; api_require_permission($staff, $permission, (int) $ticket['department_id']); @@ -352,6 +356,9 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t } if (in_array($newStatus, ['resolved', 'closed'], true)) { api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']); + if (!isset(ticket_resolution_reasons()[$resolutionCode ?? ''])) { + api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.'); + } } } $now = db_now(); @@ -370,15 +377,23 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t } else { $firstResponse = empty($ticket['first_responded_at']); $autoAssign = empty($ticket['assigned_staff_id']); + $terminal = in_array($newStatus, ['resolved', 'closed'], true); $sql = 'UPDATE tickets SET status = ?, updated_at = ?' + . ($terminal ? ', resolution_code = ?, resolution_note = ?' : '') + . ($newStatus === 'resolved' && empty($ticket['resolved_at']) ? ', resolved_at = ?' : '') + . ($newStatus === 'closed' && empty($ticket['closed_at']) ? ', closed_at = ?' : '') . ($firstResponse ? ', first_responded_at = ?' : '') . ($autoAssign ? ', assigned_staff_id = ?' : '') . ' WHERE id = ?'; $args = [$newStatus, $now]; + if ($terminal) { $args[] = $resolutionCode; $args[] = trim($resolutionNote); } + if ($newStatus === 'resolved' && empty($ticket['resolved_at'])) $args[] = $now; + if ($newStatus === 'closed' && empty($ticket['closed_at'])) $args[] = $now; if ($firstResponse) $args[] = $now; if ($autoAssign) $args[] = $staff['id']; $args[] = $ticket['id']; $pdo->prepare($sql)->execute($args); + ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']); save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)'); } diff --git a/DeskHub-Webseite/api/staff/v1/dashboard.php b/DeskHub-Webseite/api/staff/v1/dashboard.php index 31d4986c..c9947bf7 100644 --- a/DeskHub-Webseite/api/staff/v1/dashboard.php +++ b/DeskHub-Webseite/api/staff/v1/dashboard.php @@ -18,7 +18,7 @@ $myOpen = (int) $stmt->fetchColumn(); $stmt = $pdo->prepare( "SELECT COUNT(*) FROM tickets - WHERE resolution_due_at IS NOT NULL AND resolution_due_at < ? + 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 < ?)) AND status NOT IN ('resolved','closed'){$deptFilter}" ); $stmt->execute([db_now(), ...$deptParams]); @@ -86,7 +86,7 @@ if ($departmentIds === null) { "SELECT d.id, d.name, COUNT(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 END) AS open_count, COUNT(CASE WHEN t.status NOT IN ('resolved','closed') - AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? + AND (t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?)) THEN 1 END) AS overdue_count FROM departments d LEFT JOIN tickets t ON t.department_id = d.id diff --git a/DeskHub-Webseite/api/staff/v1/live_chat.php b/DeskHub-Webseite/api/staff/v1/live_chat.php index a4221683..7dc6d3e7 100644 --- a/DeskHub-Webseite/api/staff/v1/live_chat.php +++ b/DeskHub-Webseite/api/staff/v1/live_chat.php @@ -151,6 +151,7 @@ if ($action === 'send') { if ($autoAssign) $args[] = $staff['id']; $args[] = $ticket['id']; $pdo->prepare($sql)->execute($args); + ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'answered', (int) $staff['id']); if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)'); save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); @@ -167,8 +168,9 @@ if ($action === 'end') { } $now = db_now(); $pdo->prepare( - "UPDATE tickets SET status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?" + "UPDATE tickets SET status='closed', resolution_code='other', resolution_note='Live-Chat beendet', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?" )->execute([$now, $now, $ticket['id']]); + ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', (int) $staff['id']); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)'); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); diff --git a/DeskHub-Webseite/api/staff/v1/ticket_message.php b/DeskHub-Webseite/api/staff/v1/ticket_message.php index 8216e99a..da9a7811 100644 --- a/DeskHub-Webseite/api/staff/v1/ticket_message.php +++ b/DeskHub-Webseite/api/staff/v1/ticket_message.php @@ -16,5 +16,7 @@ if ($number === '') { } $pdo = get_db(); $ticket = api_find_ticket($pdo, $staff, $number); -$threadId = api_add_ticket_message($pdo, $staff, $ticket, $type, $message, $setStatus, $signatureChoice); +$resolutionCode = isset($body['resolutionCode']) ? trim((string) $body['resolutionCode']) : null; +$resolutionNote = trim((string) ($body['resolutionNote'] ?? '')); +$threadId = api_add_ticket_message($pdo, $staff, $ticket, $type, $message, $setStatus, $signatureChoice, $resolutionCode, $resolutionNote); api_json(['threadId' => $threadId], 201); diff --git a/DeskHub-Webseite/api/staff/v1/ticket_update.php b/DeskHub-Webseite/api/staff/v1/ticket_update.php index 136ba8e9..1601baeb 100644 --- a/DeskHub-Webseite/api/staff/v1/ticket_update.php +++ b/DeskHub-Webseite/api/staff/v1/ticket_update.php @@ -29,6 +29,12 @@ if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) api_error(422, 'INVALID_STATUS', 'Der Status ist ungültig.'); } api_require_permission($staff, in_array($status, ['resolved', 'closed'], true) ? 'close_ticket' : 'reply_ticket', (int) $ticket['department_id']); + if (in_array($status, ['resolved', 'closed'], true)) { + $resolutionCode = trim((string) ($body['resolutionCode'] ?? '')); + if (!isset(ticket_resolution_reasons()[$resolutionCode])) api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.'); + $updates[] = 'resolution_code = ?'; $params[] = $resolutionCode; + $updates[] = 'resolution_note = ?'; $params[] = trim((string) ($body['resolutionNote'] ?? '')); + } $updates[] = 'status = ?'; $params[] = $status; if ($status === 'resolved' && empty($ticket['resolved_at'])) { @@ -90,6 +96,7 @@ $params[] = $now; $params[] = $ticket['id']; $pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params); if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) { + ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], (string) $body['status'], (int) $staff['id']); [$label] = status_label((string) $body['status']); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Status geändert: ' . $label . ' (Mobile App)'); } diff --git a/DeskHub-Webseite/api/staff/v1/tickets.php b/DeskHub-Webseite/api/staff/v1/tickets.php index 51a8b095..6ac9e0ce 100644 --- a/DeskHub-Webseite/api/staff/v1/tickets.php +++ b/DeskHub-Webseite/api/staff/v1/tickets.php @@ -70,13 +70,13 @@ if ($sla !== '') { $now = db_now(); $riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600); if ($sla === '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 ($sla === '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 ($sla === '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; } }