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:
Mark Gregory
2026-06-23 10:46:32 +01:00
parent d449f01593
commit 373f78ea15
5 changed files with 314 additions and 25 deletions
+203 -4
View File
@@ -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>
+12 -3
View File
@@ -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())),
],
]);
}
+12
View File
@@ -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.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.35"
"version": "1.9.36"
}