added correct SLA support
Awaiting Response No qualifying response yet Due Soon SLA is approaching its deadline MET SLA target achieved on time BREACHED SLA target missed
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -886,28 +886,49 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-xs">
|
||||
<div
|
||||
class="rounded-lg border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : ($ticket->first_response_at ? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' : 'border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100') }} p-3">
|
||||
<div class="text-[0.68rem] font-semibold uppercase tracking-wide">First response</div>
|
||||
<div class="rounded-lg border {{ $this->firstResponseSlaClasses() }} p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[0.68rem] font-semibold uppercase tracking-wide">First response</div>
|
||||
<div class="mt-1 text-sm font-black">{{ $this->firstResponseSlaHeadline() }}</div>
|
||||
<div class="mt-1 text-[0.68rem] font-semibold opacity-80">{{ $this->firstResponseSlaDetail() }}</div>
|
||||
</div>
|
||||
|
||||
@if ($ticket->first_response_at)
|
||||
<span class="rounded-full bg-white/60 px-2 py-1 text-[0.6rem] font-black uppercase tracking-wide dark:bg-black/20">
|
||||
Met
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($ticket->first_response_at)
|
||||
<div class="mt-1 font-semibold">Responded</div>
|
||||
<div class="mt-1 text-[0.68rem] opacity-75">
|
||||
{{ $ticket->first_response_at->format('d M Y H:i') }}
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-1 font-semibold">Awaiting public reply</div>
|
||||
<div class="mt-1 text-[0.68rem] opacity-75">Due
|
||||
{{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
||||
<dl class="mt-3 grid gap-1 border-t border-current/15 pt-2 text-[0.65rem] font-semibold opacity-85">
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt>Responder</dt>
|
||||
<dd class="text-right">{{ $this->firstResponderName() }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-3">
|
||||
<dt>Responded at</dt>
|
||||
<dd class="text-right">{{ $ticket->first_response_at->format('d M Y H:i') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg border {{ $ticket->sla_resolution_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100' }} p-3">
|
||||
<div class="text-[0.68rem] font-semibold uppercase tracking-wide">Resolution</div>
|
||||
<div class="mt-1">{{ $ticket->completed_at?->format('d M Y H:i') ?? 'Open' }}</div>
|
||||
<div class="mt-1 text-[0.68rem] opacity-75">Due
|
||||
{{ $ticket->sla_resolution_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
||||
<div class="rounded-lg border {{ $this->resolutionSlaClasses() }} p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[0.68rem] font-semibold uppercase tracking-wide">Resolution</div>
|
||||
<div class="mt-1 text-sm font-black">{{ $this->resolutionSlaHeadline() }}</div>
|
||||
<div class="mt-1 text-[0.68rem] font-semibold opacity-80">{{ $this->resolutionSlaDetail() }}</div>
|
||||
</div>
|
||||
|
||||
@if ($ticket->completed_at)
|
||||
<span class="rounded-full bg-white/60 px-2 py-1 text-[0.6rem] font-black uppercase tracking-wide dark:bg-black/20">
|
||||
Closed
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-center">
|
||||
@@ -938,6 +959,54 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-zinc-800 shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-[0.68rem] font-black uppercase tracking-wide">SLA audit trail</div>
|
||||
<div class="text-[0.62rem] font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
{{ $ticket->slaEvents->count() }} events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
@forelse ($ticket->slaEvents->take(5) as $event)
|
||||
<div class="rounded-md border border-zinc-200 bg-white p-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-bold">{{ $this->formatSlaEventName($event->event) }}</div>
|
||||
<div class="mt-0.5 text-[0.62rem] text-zinc-500 dark:text-zinc-400">
|
||||
{{ $event->occurred_at?->format('d M Y H:i') ?? 'Not timestamped' }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full px-2 py-0.5 text-[0.58rem] font-black uppercase {{ $event->breached ? 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-100' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-100' }}">
|
||||
{{ $event->breached ? 'Breached' : 'Met' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if ($event->due_at || filled(data_get($event->meta, 'actor_name')))
|
||||
<dl class="mt-2 grid gap-1 text-[0.6rem] text-zinc-600 dark:text-zinc-300">
|
||||
@if ($event->due_at)
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>Due</dt>
|
||||
<dd>{{ $event->due_at->format('d M Y H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if (filled(data_get($event->meta, 'actor_name')))
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>Actor</dt>
|
||||
<dd>{{ data_get($event->meta, 'actor_name') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-md border border-dashed border-zinc-300 p-3 text-center text-[0.65rem] font-semibold text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
No SLA events have been recorded yet.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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())),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.9.35"
|
||||
"version": "1.9.36"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user