diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index fb4872d0..ea731326 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -320,16 +320,16 @@ final class TicketShow extends Component $this->hydrateTicketUsers($ticket); - $comments = $ticket->comments() + $comments = Comment::query() + ->where('ticket_id', $ticket->id) ->active() ->when(! $this->canSeeInternalNotes(), static function ($query): void { $query->where('is_internal', false); }) - ->with(['user.company', 'attachments', 'replies.user.company', 'replies.attachments']) + ->with(['user.company', 'attachments']) ->oldest() ->get(); - $ticket->setRelation('comments', $comments); $ticket->setAttribute('public_replies_count', $comments->where('is_internal', false)->count()); $ticket->setAttribute('time_spent_minutes', (int) $ticket->getRawOriginal('time_spent_minutes')); $ticket->setRelation('comments', $this->buildCommentTree($comments->groupBy('parent_id'))); @@ -337,7 +337,9 @@ final class TicketShow extends Component return $ticket; } - + /** + * Format the total logged ticket time for display. + */ public function formattedTimeSpent(): string { $minutes = (int) $this->ticket->time_spent_minutes; @@ -355,6 +357,203 @@ final class TicketShow extends Component ]); } + + /** + * Get the first-response SLA headline for the SLA panel. + */ + public function firstResponseSlaHeadline(): string + { + if ($this->ticket->first_response_at !== null) { + return $this->firstResponseWasBreached() ? 'Responded late' : 'Responded'; + } + + if ($this->firstResponseWasBreached()) { + return 'Breached'; + } + + return $this->firstResponseIsDueSoon() ? 'Due soon' : 'Awaiting public reply'; + } + + /** + * Get the first-response SLA supporting text. + */ + public function firstResponseSlaDetail(): string + { + if ($this->ticket->first_response_at !== null) { + return 'Responded in '.$this->formatDurationBetween($this->ticket->created_at, $this->ticket->first_response_at); + } + + if ($this->ticket->sla_first_response_due_at === null) { + return 'No first-response target is set.'; + } + + return 'Due '.$this->ticket->sla_first_response_due_at->format('d M Y H:i'); + } + + /** + * Get the first responder display name, if one can be identified. + */ + public function firstResponderName(): string + { + $comment = Comment::query() + ->where('ticket_id', $this->ticket->id) + ->where('is_internal', false) + ->whereNull('tombstoned_at') + ->with('user') + ->oldest('created_at') + ->first(); + + return $comment?->user?->name ?: 'Unknown responder'; + } + + /** + * Get the first-response SLA colour classes. + */ + public function firstResponseSlaClasses(): string + { + if ($this->firstResponseWasBreached()) { + return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100'; + } + + if ($this->ticket->first_response_at !== null) { + return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100'; + } + + if ($this->firstResponseIsDueSoon()) { + return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100'; + } + + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100'; + } + + /** + * Get the resolution SLA headline for the SLA panel. + */ + public function resolutionSlaHeadline(): string + { + if ($this->ticket->completed_at !== null) { + return $this->resolutionWasBreached() ? 'Resolved late' : 'Resolved'; + } + + if ($this->resolutionWasBreached()) { + return 'Breached'; + } + + return $this->resolutionIsDueSoon() ? 'Due soon' : 'Open'; + } + + /** + * Get the resolution SLA supporting text. + */ + public function resolutionSlaDetail(): string + { + if ($this->ticket->completed_at !== null) { + return 'Resolved in '.$this->formatDurationBetween($this->ticket->created_at, $this->ticket->completed_at); + } + + if ($this->ticket->sla_resolution_due_at === null) { + return 'No resolution target is set.'; + } + + return 'Due '.$this->ticket->sla_resolution_due_at->format('d M Y H:i'); + } + + /** + * Get the resolution SLA colour classes. + */ + public function resolutionSlaClasses(): string + { + if ($this->resolutionWasBreached()) { + return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100'; + } + + if ($this->ticket->completed_at !== null) { + return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100'; + } + + if ($this->resolutionIsDueSoon()) { + return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100'; + } + + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100'; + } + + /** + * Format an SLA event name for the audit trail. + */ + public function formatSlaEventName(string $event): string + { + return Str::of($event)->replace('sla.', '')->replace('_', ' ')->title()->toString(); + } + + /** + * Format a compact duration between two dates. + */ + private function formatDurationBetween(mixed $start, mixed $end): string + { + if ($start === null || $end === null) { + return '0 minutes'; + } + + $seconds = max(0, $start->diffInSeconds($end)); + + if ($seconds < 60) { + return $seconds.' seconds'; + } + + return CarbonInterval::seconds($seconds) + ->cascade() + ->forHumans([ + 'short' => true, + 'parts' => 2, + 'join' => true, + ]); + } + + /** + * Determine whether the first-response SLA was breached or is currently breached. + */ + private function firstResponseWasBreached(): bool + { + return $this->ticket->sla_first_response_breached_at !== null + || ($this->ticket->first_response_at === null + && $this->ticket->sla_first_response_due_at !== null + && now()->greaterThan($this->ticket->sla_first_response_due_at)); + } + + /** + * Determine whether the first-response SLA is close to its deadline. + */ + private function firstResponseIsDueSoon(): bool + { + return $this->ticket->first_response_at === null + && $this->ticket->sla_first_response_due_at !== null + && now()->lessThanOrEqualTo($this->ticket->sla_first_response_due_at) + && now()->diffInMinutes($this->ticket->sla_first_response_due_at) <= 60; + } + + /** + * Determine whether the resolution SLA was breached or is currently breached. + */ + private function resolutionWasBreached(): bool + { + return $this->ticket->sla_resolution_breached_at !== null + || ($this->ticket->completed_at === null + && $this->ticket->sla_resolution_due_at !== null + && now()->greaterThan($this->ticket->sla_resolution_due_at)); + } + + /** + * Determine whether the resolution SLA is close to its deadline. + */ + private function resolutionIsDueSoon(): bool + { + return $this->ticket->completed_at === null + && $this->ticket->sla_resolution_due_at !== null + && now()->lessThanOrEqualTo($this->ticket->sla_resolution_due_at) + && now()->diffInMinutes($this->ticket->sla_resolution_due_at) <= 60; + } + private function hydrateTicketUsers(Ticket $ticket): void { $userIds = collect([$ticket->user_id, $ticket->agent_id]) diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php index 644a3634..bb748864 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -886,28 +886,49 @@
-
-
First response
+
+
+
+
First response
+
{{ $this->firstResponseSlaHeadline() }}
+
{{ $this->firstResponseSlaDetail() }}
+
+ + @if ($ticket->first_response_at) + + Met + + @endif +
@if ($ticket->first_response_at) -
Responded
-
- {{ $ticket->first_response_at->format('d M Y H:i') }} -
- @else -
Awaiting public reply
-
Due - {{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}
+
+
+
Responder
+
{{ $this->firstResponderName() }}
+
+
+
Responded at
+
{{ $ticket->first_response_at->format('d M Y H:i') }}
+
+
@endif
-
-
Resolution
-
{{ $ticket->completed_at?->format('d M Y H:i') ?? 'Open' }}
-
Due - {{ $ticket->sla_resolution_due_at?->format('d M Y H:i') ?? 'Not set' }}
+
+
+
+
Resolution
+
{{ $this->resolutionSlaHeadline() }}
+
{{ $this->resolutionSlaDetail() }}
+
+ + @if ($ticket->completed_at) + + Closed + + @endif +
@@ -938,6 +959,54 @@
+ +
+
+
SLA audit trail
+
+ {{ $ticket->slaEvents->count() }} events +
+
+ +
+ @forelse ($ticket->slaEvents->take(5) as $event) +
+
+
+
{{ $this->formatSlaEventName($event->event) }}
+
+ {{ $event->occurred_at?->format('d M Y H:i') ?? 'Not timestamped' }} +
+
+ + {{ $event->breached ? 'Breached' : 'Met' }} + +
+ + @if ($event->due_at || filled(data_get($event->meta, 'actor_name'))) +
+ @if ($event->due_at) +
+
Due
+
{{ $event->due_at->format('d M Y H:i') }}
+
+ @endif + @if (filled(data_get($event->meta, 'actor_name'))) +
+
Actor
+
{{ data_get($event->meta, 'actor_name') }}
+
+ @endif +
+ @endif +
+ @empty +
+ No SLA events have been recorded yet. +
+ @endforelse +
+
diff --git a/Modules/Helpdesk/Services/SlaService.php b/Modules/Helpdesk/Services/SlaService.php index 0fffc045..8304d18c 100644 --- a/Modules/Helpdesk/Services/SlaService.php +++ b/Modules/Helpdesk/Services/SlaService.php @@ -52,7 +52,7 @@ final class SlaService $respondedAt = now(); if ($ticket->first_response_at === null) { - $this->markFirstResponse($ticket, $respondedAt); + $this->markFirstResponse($ticket, $respondedAt, $actor); } $ticket->increment('public_replies_count'); @@ -76,6 +76,7 @@ final class SlaService ->where('ticket_id', $ticket->id) ->where('is_internal', false) ->whereNull('tombstoned_at') + ->with('user') ->oldest('created_at') ->first(); @@ -83,10 +84,10 @@ final class SlaService return; } - $this->markFirstResponse($ticket, $firstPublicComment->created_at ?? now()); + $this->markFirstResponse($ticket, $firstPublicComment->created_at ?? now(), $firstPublicComment->user); } - private function markFirstResponse(Ticket $ticket, mixed $respondedAt): void + private function markFirstResponse(Ticket $ticket, mixed $respondedAt, ?User $actor = null): void { $respondedAt = $respondedAt instanceof \Illuminate\Support\Carbon ? $respondedAt @@ -110,6 +111,11 @@ final class SlaService 'due_at' => $ticket->sla_first_response_due_at, 'occurred_at' => $respondedAt, 'breached' => $breached, + 'meta' => [ + 'actor_id' => $actor?->id, + 'actor_name' => $actor?->name, + 'response_seconds' => max(0, ($ticket->created_at ?? $respondedAt)->diffInSeconds($respondedAt)), + ], ], ); } @@ -125,6 +131,9 @@ final class SlaService 'due_at' => $ticket->sla_resolution_due_at, 'occurred_at' => now(), 'breached' => $breached, + 'meta' => [ + 'resolved_seconds' => max(0, ($ticket->created_at ?? now())->diffInSeconds(now())), + ], ]); } diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 2aefec8f..f487bc59 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -60,3 +60,15 @@ - Added first-response SLA synchronisation for existing tickets that already have public replies but a missing first_response_at value. - Improved the SLA card wording so pending tickets say Awaiting public reply and responded tickets show Responded. - Added regression coverage for public response SLA state and preserved append-only changelog history. + +- version: 1.9.36 + date: 2026-06-23 + type: feature + summary: Add operational SLA status detail to Helpdesk ticket pages. + changes: + - Added first-response SLA wording that shows Responded, Responded late, Due soon, Breached, or Awaiting public reply as appropriate. + - Added responded-in duration, first responder name, and first public response timestamp to the SLA panel. + - Added resolution SLA wording with resolved-in duration and matching met, due-soon, and breached colour states. + - Added a compact SLA audit trail showing recorded SLA events, due times, breach/met state, and actor details where available. + - Updated public reply counting to include threaded public replies as well as root comments. + - Preserved append-only changelog history. diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 0ff280e8..790dc941 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.9.35" + "version": "1.9.36" }