added jquery and also fixed fontawsome

This commit is contained in:
Mark Gregory
2026-06-15 15:33:54 +01:00
parent 58e5f2e203
commit 0b2d7225cc
150 changed files with 8507 additions and 168 deletions
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\Ticket;
final class HelpdeskDashboardWidget extends Component
{
public function render(): View
{
$user = Auth::user();
$base = Ticket::query()->visibleTo($user);
$totalCount = (clone $base)->count();
$openCount = (clone $base)->whereNull('completed_at')->count();
$closedCount = (clone $base)->whereNotNull('completed_at')->count();
$criticalCount = (clone $base)->whereNull('completed_at')->whereHas('priority', static fn ($q) => $q->whereIn('slug', ['critical', 'urgent', 'high']))->count();
$closedPercent = $totalCount > 0 ? (int) round(($closedCount / $totalCount) * 100) : 0;
$openPercent = $totalCount > 0 ? (int) round(($openCount / $totalCount) * 100) : 0;
return view('helpdesk::livewire.dashboard-widget', [
'totalCount' => $totalCount,
'openCount' => $openCount,
'closedCount' => $closedCount,
'criticalCount' => $criticalCount,
'closedPercent' => $closedPercent,
'openPercent' => $openPercent,
'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(),
'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(),
]);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Modules\Helpdesk\Models\KanbanBoard as KanbanBoardModel;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class KanbanBoard extends Component
{
public ?int $board_id = null;
public function mount(): void
{
$this->board_id = KanbanBoardModel::query()
->where('is_default', true)
->orderBy('sort_order')
->value('id') ?: KanbanBoardModel::query()->orderBy('sort_order')->value('id');
}
/**
* Move a ticket to another workflow status.
*/
public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void
{
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
$status = Status::query()->findOrFail((int) $statusId);
$this->authorize('update', $ticket);
$ticket->forceFill([
'status_id' => $status->id,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
])->save();
$workflow->dispatch(
$ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket,
$status->is_closed ? 'ticket.closed' : 'ticket.updated',
Auth::user()
);
session()->flash('success', 'Ticket moved to ' . $status->name . '.');
}
/**
* Render the Kanban board.
*/
public function render(): View
{
$boards = KanbanBoardModel::query()
->with('statuses')
->orderBy('sort_order')
->orderBy('name')
->get();
$board = $boards->firstWhere('id', $this->board_id) ?? $boards->first();
$statuses = $board?->statuses ?? collect();
if ($statuses->isEmpty()) {
$statuses = Status::query()->orderBy('sort_order')->orderBy('name')->get();
}
return view('helpdesk::livewire.kanban-board', [
'boards' => $boards,
'board' => $board,
'statuses' => $statuses,
'ticketsByStatus' => $this->ticketsByStatus(),
])->layout('layouts.app');
}
/**
* Get visible tickets grouped by status.
*
* @return array<int, Collection<int, Ticket>>
*/
private function ticketsByStatus(): array
{
return Ticket::query()
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems'])
->visibleTo(Auth::user())
->latest('last_activity_at')
->limit(300)
->get()
->groupBy('status_id')
->all();
}
}
+652
View File
@@ -0,0 +1,652 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Livewire\Component;
use Modules\Helpdesk\Models\AutomationRule;
use Modules\Helpdesk\Models\CannedResponse;
use Modules\Helpdesk\Models\ChecklistTemplate;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\SlaPolicy;
use Modules\Helpdesk\Models\Tag;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\WorkflowTemplate;
final class SettingsManager extends Component
{
public string $section = 'statuses';
/** @var array<string, mixed> */
public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
/** @var array<string, mixed> */
public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
/** @var array<string, mixed> */
public array $categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
/** @var array<string, mixed> */
public array $teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
/** @var array<string, mixed> */
public array $projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
/** @var array<string, mixed> */
public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
/** @var array<string, mixed> */
public array $repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
/** @var array<string, mixed> */
public array $cannedForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
/** @var array<string, mixed> */
public array $slaForm = ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true];
/** @var array<string, mixed> */
public array $automationForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0];
/** @var array<string, mixed> */
public array $productForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
/** @var array<string, mixed> */
public array $tagForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc'];
/** @var array<string, mixed> */
public array $checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
/** @var array<string, mixed> */
public array $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
public function mount(): void
{
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
$this->categoryForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
$this->teamForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
$this->projectForm += ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
$this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
$this->repositoryForm += ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
$this->workflowForm += ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
$this->cannedForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
$this->slaForm += ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true];
$this->automationForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0];
$this->productForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
$this->tagForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc'];
$this->checklistTemplateForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
}
public function saveStatus(): void
{
$data = $this->validate([
'statusForm.name' => ['required', 'string', 'max:80'],
'statusForm.slug' => ['nullable', 'string', 'max:100'],
'statusForm.color' => ['required', 'string', 'max:30'],
'statusForm.sort_order' => ['required', 'integer', 'min:0'],
'statusForm.is_closed' => ['boolean'],
])['statusForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
Status::query()->updateOrCreate(['id' => $this->statusForm['id']], $data);
$this->resetStatusForm();
session()->flash('success', 'Status saved.');
}
public function editStatus(int $id): void
{
$this->statusForm = Status::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_closed']);
$this->section = 'statuses';
}
public function deleteStatus(int $id): void
{
Status::query()->findOrFail($id)->delete();
session()->flash('success', 'Status deleted.');
}
public function resetStatusForm(): void
{
$this->statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
}
public function savePriority(): void
{
$data = $this->validate([
'priorityForm.name' => ['required', 'string', 'max:80'],
'priorityForm.slug' => ['nullable', 'string', 'max:100'],
'priorityForm.color' => ['required', 'string', 'max:30'],
'priorityForm.sort_order' => ['required', 'integer', 'min:0'],
])['priorityForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
Priority::query()->updateOrCreate(['id' => $this->priorityForm['id']], $data);
$this->resetPriorityForm();
session()->flash('success', 'Priority saved.');
}
public function editPriority(int $id): void
{
$this->priorityForm = Priority::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order']);
$this->section = 'priorities';
}
public function deletePriority(int $id): void
{
Priority::query()->findOrFail($id)->delete();
session()->flash('success', 'Priority deleted.');
}
public function resetPriorityForm(): void
{
$this->priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
}
public function saveCategory(): void
{
$data = $this->validate([
'categoryForm.company_id' => ['nullable', 'integer'],
'categoryForm.name' => ['required', 'string', 'max:120'],
'categoryForm.slug' => ['nullable', 'string', 'max:140'],
'categoryForm.color' => ['required', 'string', 'max:30'],
'categoryForm.description' => ['nullable', 'string'],
])['categoryForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
Category::query()->updateOrCreate(['id' => $this->categoryForm['id']], $data);
$this->resetCategoryForm();
session()->flash('success', 'Category saved.');
}
public function editCategory(int $id): void
{
$this->categoryForm = Category::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description']);
$this->section = 'categories';
}
public function deleteCategory(int $id): void
{
Category::query()->findOrFail($id)->delete();
session()->flash('success', 'Category deleted.');
}
public function resetCategoryForm(): void
{
$this->categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
}
public function saveTeam(): void
{
$data = $this->validate([
'teamForm.company_id' => ['nullable', 'integer'],
'teamForm.name' => ['required', 'string', 'max:120'],
'teamForm.slug' => ['nullable', 'string', 'max:140'],
'teamForm.color' => ['required', 'string', 'max:30'],
'teamForm.description' => ['nullable', 'string'],
])['teamForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
Team::query()->updateOrCreate(['id' => $this->teamForm['id']], $data);
$this->resetTeamForm();
session()->flash('success', 'Team saved.');
}
public function editTeam(int $id): void
{
$this->teamForm = Team::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description', 'archived_at']);
$this->section = 'teams';
}
public function deleteTeam(int $id): void
{
Team::query()->findOrFail($id)->delete();
session()->flash('success', 'Team deleted.');
}
public function resetTeamForm(): void
{
$this->teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
}
public function saveProject(): void
{
$data = $this->validate([
'projectForm.company_id' => ['nullable', 'integer'],
'projectForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
'projectForm.title' => ['required', 'string', 'max:160'],
'projectForm.slug' => ['nullable', 'string', 'max:180'],
'projectForm.color' => ['required', 'string', 'max:30'],
'projectForm.description' => ['nullable', 'string'],
])['projectForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['title']);
Project::query()->updateOrCreate(['id' => $this->projectForm['id']], $data);
$this->resetProjectForm();
session()->flash('success', 'Project saved.');
}
public function editProject(int $id): void
{
$this->projectForm = Project::query()->findOrFail($id)->only(['id', 'company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at']);
$this->section = 'projects';
}
public function deleteProject(int $id): void
{
Project::query()->findOrFail($id)->delete();
session()->flash('success', 'Project deleted.');
}
public function resetProjectForm(): void
{
$this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
}
public function saveBoard(): void
{
$data = $this->validate([
'boardForm.name' => ['required', 'string', 'max:160'],
'boardForm.slug' => ['nullable', 'string', 'max:180'],
'boardForm.description' => ['nullable', 'string'],
'boardForm.is_default' => ['boolean'],
'boardForm.sort_order' => ['required', 'integer', 'min:0'],
'boardForm.status_ids' => ['array'],
'boardForm.status_ids.*' => ['integer', 'exists:helpdesk_statuses,id'],
])['boardForm'];
$statusIds = collect($data['status_ids'] ?? [])->map(static fn ($id): int => (int) $id)->filter()->values();
unset($data['status_ids']);
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
if ((bool) ($data['is_default'] ?? false)) {
KanbanBoard::query()->whereKeyNot($this->boardForm['id'])->update(['is_default' => false]);
}
$board = KanbanBoard::query()->updateOrCreate(['id' => $this->boardForm['id']], $data);
$sync = [];
foreach ($statusIds as $index => $statusId) {
$sync[$statusId] = ['sort_order' => ($index + 1) * 10];
}
$board->statuses()->sync($sync);
$this->resetBoardForm();
$this->section = 'boards';
session()->flash('success', 'Kanban board saved.');
}
public function editBoard(int $id): void
{
$board = KanbanBoard::query()->with('statuses')->findOrFail($id);
$this->boardForm = [
'id' => $board->id,
'name' => $board->name,
'slug' => $board->slug,
'description' => $board->description,
'is_default' => $board->is_default,
'sort_order' => $board->sort_order,
'status_ids' => $board->statuses->pluck('id')->map(static fn ($id): int => (int) $id)->all(),
];
$this->section = 'boards';
}
public function deleteBoard(int $id): void
{
KanbanBoard::query()->findOrFail($id)->delete();
session()->flash('success', 'Kanban board deleted.');
}
public function resetBoardForm(): void
{
$this->boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
}
public function saveRepository(): void
{
$data = $this->validate([
'repositoryForm.company_id' => ['nullable', 'integer'],
'repositoryForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
'repositoryForm.name' => ['required', 'string', 'max:160'],
'repositoryForm.provider' => ['required', 'string', 'max:40'],
'repositoryForm.remote_url' => ['required', 'string', 'max:500'],
'repositoryForm.default_branch' => ['required', 'string', 'max:120'],
'repositoryForm.webhook_secret' => ['nullable', 'string', 'max:120'],
])['repositoryForm'];
$data['provider'] = strtolower(trim((string) $data['provider']));
GitRepository::query()->updateOrCreate(['id' => $this->repositoryForm['id']], $data);
$this->resetRepositoryForm();
session()->flash('success', 'Git repository saved.');
}
public function editRepository(int $id): void
{
$this->repositoryForm = GitRepository::query()->findOrFail($id)->only(['id', 'company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret']);
$this->section = 'git';
}
public function deleteRepository(int $id): void
{
GitRepository::query()->findOrFail($id)->delete();
session()->flash('success', 'Git repository deleted.');
}
public function resetRepositoryForm(): void
{
$this->repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
}
public function saveWorkflowTemplate(): void
{
$data = $this->validate([
'workflowForm.name' => ['required', 'string', 'max:160'],
'workflowForm.event' => ['required', 'string', 'max:80'],
'workflowForm.subject' => ['required', 'string', 'max:220'],
'workflowForm.html_body' => ['required', 'string'],
'workflowForm.text_body' => ['nullable', 'string'],
'workflowForm.to_role' => ['required', 'string', 'max:40'],
'workflowForm.cc' => ['nullable', 'string', 'max:500'],
'workflowForm.bcc' => ['nullable', 'string', 'max:500'],
'workflowForm.is_active' => ['boolean'],
])['workflowForm'];
WorkflowTemplate::query()->updateOrCreate(['id' => $this->workflowForm['id']], $data);
$this->resetWorkflowTemplateForm();
session()->flash('success', 'Workflow message saved.');
}
public function editWorkflowTemplate(int $id): void
{
$this->workflowForm = WorkflowTemplate::query()->findOrFail($id)->only(['id', 'name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active']);
$this->section = 'workflow';
}
public function deleteWorkflowTemplate(int $id): void
{
WorkflowTemplate::query()->findOrFail($id)->delete();
session()->flash('success', 'Workflow message deleted.');
}
public function resetWorkflowTemplateForm(): void
{
$this->workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
}
public function saveCannedResponse(): void
{
$data = $this->validate([
'cannedForm.company_id' => ['nullable', 'integer'],
'cannedForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
'cannedForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
'cannedForm.name' => ['required', 'string', 'max:160'],
'cannedForm.slug' => ['nullable', 'string', 'max:180'],
'cannedForm.visibility' => ['required', 'string', 'max:40'],
'cannedForm.subject' => ['nullable', 'string', 'max:220'],
'cannedForm.body' => ['required', 'string'],
'cannedForm.is_active' => ['boolean'],
'cannedForm.sort_order' => ['required', 'integer', 'min:0'],
])['cannedForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
CannedResponse::query()->updateOrCreate(['id' => $this->cannedForm['id']], $data);
$this->resetCannedResponseForm();
session()->flash('success', 'Canned response saved.');
}
public function editCannedResponse(int $id): void
{
$this->cannedForm = CannedResponse::query()->findOrFail($id)->only(['id','company_id','category_id','team_id','name','slug','visibility','subject','body','is_active','sort_order']);
$this->section = 'canned';
}
public function deleteCannedResponse(int $id): void
{
CannedResponse::query()->findOrFail($id)->delete();
session()->flash('success', 'Canned response deleted.');
}
public function resetCannedResponseForm(): void
{
$this->cannedForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
}
public function saveSlaPolicy(): void
{
$data = $this->validate([
'slaForm.company_id' => ['nullable', 'integer'],
'slaForm.priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'],
'slaForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
'slaForm.name' => ['required', 'string', 'max:160'],
'slaForm.slug' => ['nullable', 'string', 'max:180'],
'slaForm.first_response_minutes' => ['required', 'integer', 'min:1'],
'slaForm.resolution_minutes' => ['required', 'integer', 'min:1'],
'slaForm.business_hours_only' => ['boolean'],
'slaForm.is_default' => ['boolean'],
'slaForm.is_active' => ['boolean'],
])['slaForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
if ((bool) ($data['is_default'] ?? false)) {
SlaPolicy::query()->whereKeyNot($this->slaForm['id'])->update(['is_default' => false]);
}
SlaPolicy::query()->updateOrCreate(['id' => $this->slaForm['id']], $data);
$this->resetSlaPolicyForm();
session()->flash('success', 'SLA policy saved.');
}
public function editSlaPolicy(int $id): void
{
$this->slaForm = SlaPolicy::query()->findOrFail($id)->only(['id','company_id','priority_id','category_id','name','slug','first_response_minutes','resolution_minutes','business_hours_only','is_default','is_active']);
$this->section = 'sla';
}
public function deleteSlaPolicy(int $id): void
{
SlaPolicy::query()->findOrFail($id)->delete();
session()->flash('success', 'SLA policy deleted.');
}
public function resetSlaPolicyForm(): void
{
$this->slaForm = ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true];
}
public function saveAutomationRule(): void
{
$data = $this->validate([
'automationForm.company_id' => ['nullable', 'integer'],
'automationForm.name' => ['required', 'string', 'max:160'],
'automationForm.slug' => ['nullable', 'string', 'max:180'],
'automationForm.match_sender' => ['nullable', 'string', 'max:190'],
'automationForm.match_subject' => ['nullable', 'string', 'max:220'],
'automationForm.match_mailbox' => ['nullable', 'string', 'max:190'],
'automationForm.match_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
'automationForm.set_priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'],
'automationForm.set_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
'automationForm.set_team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
'automationForm.set_project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
'automationForm.set_status_id' => ['nullable', 'integer', 'exists:helpdesk_statuses,id'],
'automationForm.set_agent_id' => ['nullable', 'integer', 'exists:users,id'],
'automationForm.is_active' => ['boolean'],
'automationForm.sort_order' => ['required', 'integer', 'min:0'],
])['automationForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
AutomationRule::query()->updateOrCreate(['id' => $this->automationForm['id']], $data);
$this->resetAutomationRuleForm();
session()->flash('success', 'Automation rule saved.');
}
public function editAutomationRule(int $id): void
{
$this->automationForm = AutomationRule::query()->findOrFail($id)->only(['id','company_id','name','slug','match_sender','match_subject','match_mailbox','match_category_id','set_priority_id','set_category_id','set_team_id','set_project_id','set_status_id','set_agent_id','is_active','sort_order']);
$this->section = 'automation';
}
public function deleteAutomationRule(int $id): void
{
AutomationRule::query()->findOrFail($id)->delete();
session()->flash('success', 'Automation rule deleted.');
}
public function resetAutomationRuleForm(): void
{
$this->automationForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0];
}
public function saveProduct(): void
{
$data = $this->validate([
'productForm.company_id' => ['nullable', 'integer'],
'productForm.name' => ['required', 'string', 'max:160'],
'productForm.slug' => ['nullable', 'string', 'max:180'],
'productForm.type' => ['required', 'string', 'max:60'],
'productForm.version' => ['nullable', 'string', 'max:80'],
'productForm.release_channel' => ['nullable', 'string', 'max:80'],
'productForm.description' => ['nullable', 'string'],
'productForm.is_active' => ['boolean'],
])['productForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
Product::query()->updateOrCreate(['id' => $this->productForm['id']], $data);
$this->resetProductForm();
session()->flash('success', 'Product/service saved.');
}
public function editProduct(int $id): void
{
$this->productForm = Product::query()->findOrFail($id)->only(['id','company_id','name','slug','type','version','release_channel','description','is_active']);
$this->section = 'products';
}
public function deleteProduct(int $id): void
{
Product::query()->findOrFail($id)->delete();
session()->flash('success', 'Product/service deleted.');
}
public function resetProductForm(): void
{
$this->productForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
}
public function saveTag(): void
{
$data = $this->validate([
'tagForm.company_id' => ['nullable', 'integer'],
'tagForm.name' => ['required', 'string', 'max:80'],
'tagForm.slug' => ['nullable', 'string', 'max:100'],
'tagForm.color' => ['required', 'string', 'max:30'],
])['tagForm'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
Tag::query()->updateOrCreate(['id' => $this->tagForm['id']], $data);
$this->resetTagForm();
session()->flash('success', 'Tag saved.');
}
public function editTag(int $id): void
{
$this->tagForm = Tag::query()->findOrFail($id)->only(['id','company_id','name','slug','color']);
$this->section = 'tags';
}
public function deleteTag(int $id): void
{
Tag::query()->findOrFail($id)->delete();
session()->flash('success', 'Tag deleted.');
}
public function resetTagForm(): void
{
$this->tagForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc'];
}
public function saveChecklistTemplate(): void
{
$data = $this->validate([
'checklistTemplateForm.company_id' => ['nullable', 'integer'],
'checklistTemplateForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
'checklistTemplateForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
'checklistTemplateForm.name' => ['required', 'string', 'max:160'],
'checklistTemplateForm.slug' => ['nullable', 'string', 'max:180'],
'checklistTemplateForm.items_text' => ['required', 'string'],
'checklistTemplateForm.is_active' => ['boolean'],
])['checklistTemplateForm'];
$itemsText = (string) ($data['items_text'] ?? '');
unset($data['items_text']);
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
$data['items'] = collect(preg_split('/\r\n|\r|\n/', $itemsText) ?: [])->map(fn ($line): string => trim($line))->filter()->values()->all();
ChecklistTemplate::query()->updateOrCreate(['id' => $this->checklistTemplateForm['id']], $data);
$this->resetChecklistTemplateForm();
session()->flash('success', 'Checklist template saved.');
}
public function editChecklistTemplate(int $id): void
{
$template = ChecklistTemplate::query()->findOrFail($id);
$this->checklistTemplateForm = $template->only(['id','company_id','category_id','project_id','name','slug','is_active']) + [
'items_text' => collect($template->items ?? [])->map(fn ($item): string => is_array($item) ? (string) ($item['title'] ?? '') : (string) $item)->implode("\n"),
];
$this->section = 'checklists';
}
public function deleteChecklistTemplate(int $id): void
{
ChecklistTemplate::query()->findOrFail($id)->delete();
session()->flash('success', 'Checklist template deleted.');
}
public function resetChecklistTemplateForm(): void
{
$this->checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
}
public function render(): View
{
return view('helpdesk::livewire.settings-manager', [
'companies' => $this->companies(),
'statuses' => Status::query()->withCount('tickets')->orderBy('sort_order')->orderBy('name')->get(),
'priorities' => Priority::query()->withCount('tickets')->orderBy('sort_order')->orderBy('name')->get(),
'categories' => Category::query()->withCount('tickets')->orderBy('name')->get(),
'teams' => Team::query()->withCount(['projects', 'tickets'])->orderBy('name')->get(),
'projects' => Project::query()->with(['team'])->withCount('tickets')->orderBy('title')->get(),
'boards' => KanbanBoard::query()->with('statuses')->orderBy('sort_order')->orderBy('name')->get(),
'repositories' => GitRepository::query()->with(['project'])->withCount(['commits', 'webhookLogs'])->orderBy('name')->get(),
'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(),
'cannedResponses' => CannedResponse::query()->with(['category','team'])->orderBy('sort_order')->orderBy('name')->get(),
'slaPolicies' => SlaPolicy::query()->with(['priority','category'])->orderByDesc('is_default')->orderBy('name')->get(),
'automationRules' => AutomationRule::query()->orderBy('sort_order')->orderBy('name')->get(),
'products' => Product::query()->withCount('tickets')->orderBy('name')->get(),
'tags' => Tag::query()->orderBy('name')->get(),
'checklistTemplates' => ChecklistTemplate::query()->orderBy('name')->get(),
])->layout('layouts.app');
}
/** @return array<int, object> */
private function companies(): array
{
if (! Schema::hasTable('companies')) {
return [];
}
return DB::table('companies')->select(['id', 'name'])->orderBy('name')->get()->all();
}
private function slug(string $value): string
{
return Str::slug($value);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Services\TicketService;
final class TicketCreate extends Component
{
use WithFileUploads;
#[Validate('nullable|integer')]
public ?int $company_id = null;
#[Validate('required|string|max:200')]
public string $subject = '';
#[Validate('required|string|min:10')]
public string $body = '';
#[Validate('required|integer|exists:helpdesk_priorities,id')]
public ?int $priority_id = null;
#[Validate('nullable|integer|exists:helpdesk_categories,id')]
public ?int $category_id = null;
#[Validate('nullable|integer|exists:helpdesk_projects,id')]
public ?int $project_id = null;
#[Validate('nullable|integer|exists:helpdesk_products,id')]
public ?int $product_id = null;
#[Validate('nullable|integer|exists:helpdesk_teams,id')]
public ?int $team_id = null;
#[Validate('nullable|date')]
public ?string $due_at = null;
/** @var array<int, TemporaryUploadedFile> */
#[Validate('array')]
public array $attachments = [];
/**
* Store a new helpdesk ticket.
*/
public function save(TicketService $tickets): mixed
{
$this->validate();
$this->validate([
'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb').'|mimes:'.implode(',', config('helpdesk.allowed_mimes')),
]);
$ticket = $tickets->create(Auth::user(), [
'company_id' => $this->company_id,
'subject' => $this->subject,
'body' => $this->body,
'priority_id' => $this->priority_id,
'category_id' => $this->category_id,
'project_id' => $this->project_id,
'product_id' => $this->product_id,
'team_id' => $this->team_id,
'due_at' => $this->due_at,
], $this->attachments);
return redirect()->route('helpdesk.tickets.index');
}
public function render(): View
{
return view('helpdesk::livewire.ticket-create', [
'priorities' => Priority::query()->orderBy('sort_order')->get(),
'categories' => Category::query()->orderBy('name')->get(),
'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(),
'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(),
'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(),
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
])->layout('layouts.app');
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\SlaService;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class TicketEdit extends Component
{
public Ticket $ticket;
#[Validate('nullable|integer')]
public ?int $company_id = null;
#[Validate('required|string|max:200')]
public string $subject = '';
#[Validate('required|string|min:10')]
public string $body = '';
#[Validate('required|integer|exists:helpdesk_statuses,id')]
public ?int $status_id = null;
#[Validate('required|integer|exists:helpdesk_priorities,id')]
public ?int $priority_id = null;
#[Validate('nullable|integer|exists:helpdesk_categories,id')]
public ?int $category_id = null;
#[Validate('nullable|integer|exists:helpdesk_projects,id')]
public ?int $project_id = null;
#[Validate('nullable|integer|exists:helpdesk_products,id')]
public ?int $product_id = null;
#[Validate('nullable|integer|exists:helpdesk_teams,id')]
public ?int $team_id = null;
#[Validate('nullable|integer|exists:users,id')]
public ?int $agent_id = null;
#[Validate('nullable|date')]
public ?string $due_at = null;
public function mount(Ticket $ticket): void
{
$this->authorize('update', $ticket);
$this->ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'agent']) ?? $ticket;
$this->company_id = $this->ticket->company_id;
$this->subject = $this->ticket->subject;
$this->body = $this->ticket->body;
$this->status_id = $this->ticket->status_id;
$this->priority_id = $this->ticket->priority_id;
$this->category_id = $this->ticket->category_id;
$this->project_id = $this->ticket->project_id;
$this->product_id = $this->ticket->product_id;
$this->team_id = $this->ticket->team_id;
$this->agent_id = $this->ticket->agent_id;
$this->due_at = $this->ticket->due_at?->format('Y-m-d');
}
public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed
{
$this->authorize('update', $this->ticket);
$this->validate();
$status = Status::query()->findOrFail($this->status_id);
$this->ticket->forceFill([
'company_id' => $this->company_id,
'subject' => $this->subject,
'body' => $this->body,
'status_id' => $status->id,
'priority_id' => $this->priority_id,
'category_id' => $this->category_id,
'project_id' => $this->project_id,
'product_id' => $this->product_id,
'team_id' => $this->team_id,
'agent_id' => $this->agent_id,
'due_at' => $this->due_at,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
])->save();
if ($status->is_closed) {
$sla->recordClosure($this->ticket);
}
$fresh = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $this->ticket;
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
session()->flash('success', 'Ticket ' . $fresh->reference . ' has been updated.');
return redirect()->route('helpdesk.tickets.show', $fresh);
}
public function render(): View
{
return view('helpdesk::livewire.ticket-edit', [
'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(),
'categories' => Category::query()->orderBy('name')->get(),
'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(),
'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(),
'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(),
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
'agents' => User::query()
->where(static function ($query): void {
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
})
->orderBy('name')
->get(['id', 'name', 'email']),
])->layout('layouts.app');
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
final class TicketIndex extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
#[Url]
public string $priority = '';
#[Url]
public string $scope = 'open';
/**
* Reset pagination when filters change.
*/
public function updated(): void
{
$this->resetPage();
}
/**
* Render the ticket list.
*/
public function render(): View
{
return view('helpdesk::livewire.ticket-index', [
'tickets' => $this->tickets(),
'statuses' => Status::query()->orderBy('sort_order')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->get(),
])->layout('layouts.app');
}
/**
* Build paginated ticket results.
*
* @return LengthAwarePaginator<int, Ticket>
*/
private function tickets(): LengthAwarePaginator
{
$user = Auth::user();
$search = trim($this->search);
return Ticket::query()
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])
->visibleTo($user)
->when($this->scope === 'open', static fn (Builder $query): Builder => $query->whereNull('completed_at'))
->when($this->scope === 'closed', static fn (Builder $query): Builder => $query->whereNotNull('completed_at'))
->when($this->status !== '', fn (Builder $query): Builder => $query->where('status_id', (int) $this->status))
->when($this->priority !== '', fn (Builder $query): Builder => $query->where('priority_id', (int) $this->priority))
->when($search !== '', static function (Builder $query) use ($search): Builder {
return $query->where(static function (Builder $subQuery) use ($search): void {
$subQuery->where('reference', 'like', '%'.$search.'%')
->orWhere('subject', 'like', '%'.$search.'%')
->orWhere('body', 'like', '%'.$search.'%');
});
})
->latest('last_activity_at')
->paginate(15);
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
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\Priority;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
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 = [];
public ?int $status_id = null;
public ?int $priority_id = null;
public ?int $reply_parent_id = null;
public string $replyBody = '';
public ?int $canned_response_id = null;
public function mount(Ticket $ticket): void
{
$this->authorize('view', $ticket);
$this->ticket = $this->freshTicket($ticket);
$this->status_id = $this->ticket->status_id;
$this->priority_id = $this->ticket->priority_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').'|mimes:'.implode(',', config('helpdesk.allowed_mimes')),
]);
$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);
$this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]);
$status = Status::query()->findOrFail($this->status_id);
$this->ticket->forceFill([
'status_id' => $status->id,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
])->save();
if ($status->is_closed) {
$sla->recordClosure($this->ticket);
}
$this->ticket = $this->freshTicket($this->ticket);
$workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
$this->dispatch('helpdesk-status-updated');
}
public function updatePriority(WorkflowNotificationService $workflow): void
{
$this->authorize('update', $this->ticket);
$this->validate(['priority_id' => ['required', 'integer', 'exists:helpdesk_priorities,id']]);
$this->ticket->forceFill([
'priority_id' => $this->priority_id,
'last_activity_at' => now(),
])->save();
$this->ticket = $this->freshTicket($this->ticket);
$workflow->dispatch($this->ticket, 'ticket.updated', Auth::user());
$this->dispatch('helpdesk-priority-updated');
}
public function render(): View
{
return view('helpdesk::livewire.ticket-show', [
'statuses' => Status::query()->orderBy('sort_order')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->get(),
'cannedResponses' => CannedResponse::query()->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
])->layout('layouts.app');
}
private function freshTicket(Ticket $ticket): Ticket
{
$ticket = $ticket->fresh([
'owner',
'agent',
'status',
'priority',
'category',
'project',
'team',
'product',
'slaPolicy',
'slaEvents',
'ratings',
'tags',
'checklistItems',
'attachments.user',
'gitCommits.repository',
]) ?? $ticket;
$comments = \Modules\Helpdesk\Models\Comment::query()
->where('ticket_id', $ticket->id)
->with(['user', 'attachments.user'])
->oldest()
->get();
$ticket->setRelation('comments', $this->buildCommentTree($comments->groupBy('parent_id')));
return $ticket;
}
/**
* Build an unlimited nested comment tree from a flat eager-loaded collection.
*
* @param Collection<int|string, Collection<int, mixed>> $grouped
* @return Collection<int, mixed>
*/
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();
}
}