Awaiting Response No qualifying response yet Due Soon SLA is approaching its deadline MET SLA target achieved on time BREACHED SLA target missed
638 lines
21 KiB
PHP
638 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\Helpdesk\Livewire;
|
|
|
|
use App\Models\User;
|
|
use Carbon\CarbonInterval;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Livewire\Attributes\Validate;
|
|
use Livewire\Component;
|
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
use Livewire\WithFileUploads;
|
|
use Modules\Helpdesk\Models\CannedResponse;
|
|
use Modules\Helpdesk\Models\ChecklistItem;
|
|
use Modules\Helpdesk\Models\Comment;
|
|
use Modules\Helpdesk\Models\Priority;
|
|
use Modules\Helpdesk\Models\Status;
|
|
use Modules\Helpdesk\Models\Ticket;
|
|
use Modules\Helpdesk\Services\AttachmentService;
|
|
use Modules\Helpdesk\Services\CannedResponseRenderer;
|
|
use Modules\Helpdesk\Services\ChecklistService;
|
|
use Modules\Helpdesk\Services\SlaService;
|
|
use Modules\Helpdesk\Services\TicketService;
|
|
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
|
|
|
final class TicketShow extends Component
|
|
{
|
|
use WithFileUploads;
|
|
|
|
public Ticket $ticket;
|
|
|
|
#[Validate('required|string|min:2')]
|
|
public string $commentBody = '';
|
|
|
|
public bool $internal = false;
|
|
|
|
/** @var array<int, TemporaryUploadedFile> */
|
|
public array $attachments = [];
|
|
|
|
/** @var array<int, TemporaryUploadedFile> */
|
|
public array $supportingFiles = [];
|
|
|
|
public ?int $status_id = null;
|
|
|
|
public ?int $priority_id = null;
|
|
|
|
public ?int $agent_id = null;
|
|
|
|
public ?int $reply_parent_id = null;
|
|
|
|
public string $replyBody = '';
|
|
|
|
public ?int $canned_response_id = null;
|
|
|
|
public function mount(Ticket $ticket): void
|
|
{
|
|
$this->ticket = $this->freshTicket($ticket);
|
|
$this->authorize('view', $this->ticket);
|
|
$this->status_id = $this->ticket->status_id;
|
|
$this->priority_id = $this->ticket->priority_id;
|
|
$this->agent_id = $this->ticket->agent_id;
|
|
}
|
|
|
|
public function startReply(int $commentId): void
|
|
{
|
|
$this->reply_parent_id = $commentId;
|
|
$this->replyBody = '';
|
|
}
|
|
|
|
public function cancelReply(): void
|
|
{
|
|
$this->reply_parent_id = null;
|
|
$this->replyBody = '';
|
|
}
|
|
|
|
public function addComment(TicketService $tickets): void
|
|
{
|
|
$this->authorize('comment', $this->ticket);
|
|
|
|
$this->validate([
|
|
'commentBody' => ['required', 'string', 'min:2'],
|
|
'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb', 10240).'|mimes:'.implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])),
|
|
]);
|
|
|
|
$tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments);
|
|
|
|
$this->reset(['commentBody', 'internal', 'attachments']);
|
|
$this->ticket = $this->freshTicket($this->ticket);
|
|
$this->dispatch('helpdesk-editor-reset', model: 'commentBody');
|
|
$this->dispatch('helpdesk-comment-added');
|
|
}
|
|
|
|
public function addReply(TicketService $tickets): void
|
|
{
|
|
$this->authorize('comment', $this->ticket);
|
|
|
|
$this->validate([
|
|
'reply_parent_id' => ['required', 'integer', 'exists:helpdesk_comments,id'],
|
|
'replyBody' => ['required', 'string', 'min:2'],
|
|
]);
|
|
|
|
$tickets->comment($this->ticket, Auth::user(), $this->replyBody, $this->internal, [], $this->reply_parent_id);
|
|
|
|
$this->reset(['reply_parent_id', 'replyBody']);
|
|
$this->ticket = $this->freshTicket($this->ticket);
|
|
$this->dispatch('helpdesk-editor-reset', model: 'replyBody');
|
|
$this->dispatch('helpdesk-reply-added');
|
|
}
|
|
|
|
public function applyCannedResponse(CannedResponseRenderer $renderer): void
|
|
{
|
|
$this->authorize('comment', $this->ticket);
|
|
|
|
if ($this->canned_response_id === null) {
|
|
return;
|
|
}
|
|
|
|
$response = CannedResponse::query()
|
|
->where('is_active', true)
|
|
->findOrFail($this->canned_response_id);
|
|
|
|
$rendered = $renderer->render($response->body, $this->ticket, Auth::user());
|
|
$this->commentBody = trim($this->commentBody."\n\n".$rendered);
|
|
$this->dispatch('helpdesk-canned-response-applied', body: $this->commentBody);
|
|
}
|
|
|
|
public function toggleChecklistItem(int $itemId, ChecklistService $checklists): void
|
|
{
|
|
$this->authorize('update', $this->ticket);
|
|
|
|
$item = ChecklistItem::query()
|
|
->where('ticket_id', $this->ticket->id)
|
|
->findOrFail($itemId);
|
|
|
|
$checklists->toggle($item, Auth::user());
|
|
$this->ticket = $this->freshTicket($this->ticket);
|
|
}
|
|
|
|
public function updateStatus(WorkflowNotificationService $workflow, SlaService $sla): void
|
|
{
|
|
$this->authorize('update', $this->ticket);
|
|
|
|
$statusId = $this->normaliseNullableInt($this->status_id);
|
|
|
|
validator(
|
|
['status_id' => $statusId],
|
|
['status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')]],
|
|
)->validate();
|
|
|
|
$status = Status::query()->findOrFail($statusId);
|
|
|
|
$updates = [
|
|
'status_id' => $status->id,
|
|
'completed_at' => $status->is_closed ? now() : null,
|
|
'last_activity_at' => now(),
|
|
];
|
|
|
|
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
|
|
$updates['updated_at'] = now();
|
|
}
|
|
|
|
DB::table('helpdesk_tickets')
|
|
->where('id', $this->ticket->id)
|
|
->update($updates);
|
|
|
|
$this->ticket = $this->freshTicket($this->ticket);
|
|
$this->status_id = (int) $this->ticket->status_id;
|
|
|
|
if ($status->is_closed) {
|
|
$sla->recordClosure($this->ticket);
|
|
}
|
|
|
|
$workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
|
|
|
|
session()->flash('ticket-action-updated', 'Status updated to '.$this->ticket->status?->name.'.');
|
|
$this->dispatch('helpdesk-status-updated', statusId: $this->status_id);
|
|
}
|
|
|
|
public function updatePriority(WorkflowNotificationService $workflow): void
|
|
{
|
|
$this->authorize('update', $this->ticket);
|
|
|
|
$priorityId = $this->normaliseNullableInt($this->priority_id);
|
|
|
|
validator(
|
|
['priority_id' => $priorityId],
|
|
['priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')]],
|
|
)->validate();
|
|
|
|
$updates = [
|
|
'priority_id' => $priorityId,
|
|
'last_activity_at' => now(),
|
|
];
|
|
|
|
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
|
|
$updates['updated_at'] = now();
|
|
}
|
|
|
|
DB::table('helpdesk_tickets')
|
|
->where('id', $this->ticket->id)
|
|
->update($updates);
|
|
|
|
$this->ticket = $this->freshTicket($this->ticket);
|
|
$this->priority_id = (int) $this->ticket->priority_id;
|
|
|
|
$workflow->dispatch($this->ticket, 'ticket.updated', Auth::user());
|
|
|
|
session()->flash('ticket-action-updated', 'Priority updated to '.$this->ticket->priority?->name.'.');
|
|
$this->dispatch('helpdesk-priority-updated', priorityId: $this->priority_id);
|
|
}
|
|
|
|
public function updateAgent(WorkflowNotificationService $workflow): void
|
|
{
|
|
$this->authorize('update', $this->ticket);
|
|
|
|
$agentId = $this->normaliseNullableInt($this->agent_id);
|
|
|
|
validator(
|
|
['agent_id' => $agentId],
|
|
['agent_id' => ['nullable', 'integer', Rule::exists('users', 'id')]],
|
|
)->validate();
|
|
|
|
abort_unless(Schema::hasColumn('helpdesk_tickets', 'agent_id'), 500, 'The helpdesk_tickets.agent_id column is missing.');
|
|
|
|
$updates = [
|
|
'agent_id' => $agentId,
|
|
'last_activity_at' => now(),
|
|
];
|
|
|
|
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
|
|
$updates['updated_at'] = now();
|
|
}
|
|
|
|
DB::table('helpdesk_tickets')
|
|
->where('id', $this->ticket->id)
|
|
->update($updates);
|
|
|
|
$this->ticket = $this->freshTicket($this->ticket);
|
|
$this->agent_id = $this->ticket->agent_id === null ? null : (int) $this->ticket->agent_id;
|
|
|
|
$workflow->dispatch($this->ticket, 'ticket.updated', Auth::user());
|
|
|
|
session()->flash('ticket-action-updated', $this->agent_id === null
|
|
? 'Ticket is now unassigned.'
|
|
: 'Ticket assigned to '.$this->ticket->agent?->name.'.');
|
|
|
|
$this->dispatch('helpdesk-agent-updated', agentId: $this->agent_id);
|
|
}
|
|
|
|
public function saveSupportingFiles(AttachmentService $attachments): void
|
|
{
|
|
$this->authorize('comment', $this->ticket);
|
|
|
|
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
|
|
$allowedMimes = implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']));
|
|
|
|
$this->validate([
|
|
'supportingFiles' => ['required', 'array', 'min:1'],
|
|
'supportingFiles.*' => ['file', 'max:'.$maxUploadKilobytes, 'mimes:'.$allowedMimes],
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
abort_if($user === null, 403);
|
|
|
|
$attachments->storeMany($this->ticket, $this->supportingFiles, (int) $user->id);
|
|
|
|
$this->reset('supportingFiles');
|
|
$this->ticket = $this->freshTicket($this->ticket);
|
|
|
|
session()->flash('ticket-supporting-files-added', 'Supporting files attached.');
|
|
$this->dispatch('helpdesk-supporting-files-added');
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('helpdesk::livewire.ticket-show', [
|
|
'statuses' => Status::query()->orderBy('sort_order')->get(),
|
|
'priorities' => Priority::query()->orderBy('sort_order')->get(),
|
|
'agents' => $this->agentOptions(),
|
|
'cannedResponses' => CannedResponse::query()->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
|
|
])->layout('layouts.app');
|
|
}
|
|
|
|
private function normaliseNullableInt(mixed $value): ?int
|
|
{
|
|
if ($value === null || $value === '' || $value === '0' || $value === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (int) $value;
|
|
}
|
|
|
|
private function freshTicket(Ticket $ticket): Ticket
|
|
{
|
|
app(SlaService::class)->syncFirstResponseFromComments($ticket);
|
|
|
|
$ticket = $ticket->fresh([
|
|
'status',
|
|
'priority',
|
|
'category',
|
|
'project',
|
|
'team',
|
|
'product',
|
|
'slaPolicy',
|
|
'slaEvents',
|
|
'ratings',
|
|
'tags',
|
|
'checklistItems',
|
|
'attachments',
|
|
'gitCommits.repository',
|
|
]) ?? $ticket;
|
|
|
|
$this->hydrateTicketUsers($ticket);
|
|
|
|
$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'])
|
|
->oldest()
|
|
->get();
|
|
|
|
$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')));
|
|
|
|
return $ticket;
|
|
}
|
|
|
|
/**
|
|
* Format the total logged ticket time for display.
|
|
*/
|
|
public function formattedTimeSpent(): string
|
|
{
|
|
$minutes = (int) $this->ticket->time_spent_minutes;
|
|
|
|
if ($minutes < 1) {
|
|
return '0 minutes';
|
|
}
|
|
|
|
return CarbonInterval::minutes($minutes)
|
|
->cascade()
|
|
->forHumans([
|
|
'short' => false,
|
|
'parts' => 2,
|
|
'join' => true,
|
|
]);
|
|
}
|
|
|
|
|
|
/**
|
|
* 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])
|
|
->filter()
|
|
->map(static fn (mixed $id): int => (int) $id)
|
|
->unique()
|
|
->values();
|
|
|
|
if ($userIds->isEmpty()) {
|
|
$ticket->setRelation('user', null);
|
|
$ticket->setRelation('owner', null);
|
|
$ticket->setRelation('agent', null);
|
|
|
|
return;
|
|
}
|
|
|
|
$users = User::query()
|
|
->with('company')
|
|
->whereIn('id', $userIds)
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
$owner = $users->get((int) $ticket->user_id);
|
|
$agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id);
|
|
|
|
$ticket->setRelation('user', $owner);
|
|
$ticket->setRelation('owner', $owner);
|
|
$ticket->setRelation('agent', $agent);
|
|
}
|
|
|
|
private function agentOptions(): EloquentCollection
|
|
{
|
|
$columns = ['id', 'name', 'email'];
|
|
|
|
if (Schema::hasColumn('users', 'company_id')) {
|
|
$columns[] = 'company_id';
|
|
}
|
|
|
|
$query = User::query()->select($columns)->orderBy('name');
|
|
|
|
if ($this->ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) {
|
|
$tenantUsers = (clone $query)
|
|
->where(function ($query): void {
|
|
$query->where('company_id', $this->ticket->company_id)
|
|
->orWhereNull('company_id');
|
|
})
|
|
->when($this->agent_id !== null, function ($query): void {
|
|
$query->orWhere('id', $this->agent_id);
|
|
})
|
|
->get();
|
|
|
|
if ($tenantUsers->isNotEmpty()) {
|
|
return $tenantUsers;
|
|
}
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
private function canSeeInternalNotes(): bool
|
|
{
|
|
$company = Auth::user()?->company;
|
|
|
|
if ($company === null) {
|
|
return false;
|
|
}
|
|
|
|
return (bool) ($company->is_system_company ?? false)
|
|
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|
|
|| Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba');
|
|
}
|
|
|
|
private function buildCommentTree(Collection $grouped, ?int $parentId = null): Collection
|
|
{
|
|
return $grouped->get($parentId, collect())->map(function ($comment) use ($grouped) {
|
|
$comment->setRelation('replies', $this->buildCommentTree($grouped, (int) $comment->id));
|
|
|
|
return $comment;
|
|
})->values();
|
|
}
|
|
}
|