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 @@