new helpdesk module
This commit is contained in:
@@ -7,6 +7,7 @@ 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
|
||||
@@ -15,13 +16,22 @@ 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', [
|
||||
'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->where('slug', 'critical'))->count(),
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?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\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||
|
||||
final class KanbanBoard extends Component
|
||||
{
|
||||
/**
|
||||
* Move a ticket to another workflow status.
|
||||
*
|
||||
* @param int $ticketId
|
||||
* @param int $statusId
|
||||
* @param WorkflowNotificationService $workflow
|
||||
* @return void
|
||||
*/
|
||||
public function moveTicket(int $ticketId, int $statusId, WorkflowNotificationService $workflow): void
|
||||
{
|
||||
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail($ticketId);
|
||||
$status = Status::query()->findOrFail($statusId);
|
||||
|
||||
$oldStatus = $ticket->status?->name;
|
||||
|
||||
$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']),
|
||||
$status->is_closed ? 'ticket_closed' : 'ticket_updated',
|
||||
Auth::user()
|
||||
);
|
||||
|
||||
session()->flash('success', 'Ticket moved to ' . $status->name . '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Kanban board.
|
||||
*
|
||||
* @return View
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.kanban-board', [
|
||||
'statuses' => Status::query()->orderBy('sort_order')->get(),
|
||||
'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'])
|
||||
->visibleTo(Auth::user())
|
||||
->latest('last_activity_at')
|
||||
->limit(200)
|
||||
->get()
|
||||
->groupBy('status_id')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@ use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\GitRepository;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Project;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Team;
|
||||
use Modules\Helpdesk\Models\WorkflowTemplate;
|
||||
|
||||
final class SettingsManager extends Component
|
||||
{
|
||||
@@ -34,6 +36,12 @@ final class SettingsManager extends Component
|
||||
/** @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 $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 $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
|
||||
|
||||
/** @return void */
|
||||
public function saveStatus(): void
|
||||
{
|
||||
@@ -219,6 +227,86 @@ final class SettingsManager extends Component
|
||||
$this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
|
||||
}
|
||||
|
||||
|
||||
/** @return void */
|
||||
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'];
|
||||
|
||||
GitRepository::query()->updateOrCreate(['id' => $this->repositoryForm['id']], $data);
|
||||
$this->resetRepositoryForm();
|
||||
session()->flash('success', 'Git repository saved.');
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
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';
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function deleteRepository(int $id): void
|
||||
{
|
||||
GitRepository::query()->findOrFail($id)->delete();
|
||||
session()->flash('success', 'Git repository deleted.');
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function resetRepositoryForm(): void
|
||||
{
|
||||
$this->repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
|
||||
}
|
||||
|
||||
|
||||
/** @return void */
|
||||
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.');
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
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';
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function deleteWorkflowTemplate(int $id): void
|
||||
{
|
||||
WorkflowTemplate::query()->findOrFail($id)->delete();
|
||||
session()->flash('success', 'Workflow message deleted.');
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
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];
|
||||
}
|
||||
|
||||
/** @return View */
|
||||
public function render(): View
|
||||
{
|
||||
@@ -229,6 +317,8 @@ final class SettingsManager extends Component
|
||||
'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(),
|
||||
'repositories' => GitRepository::query()->with(['project'])->withCount('commits')->orderBy('name')->get(),
|
||||
'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ final class TicketCreate extends Component
|
||||
|
||||
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
|
||||
|
||||
return $this->redirectRoute('helpdesk.tickets.show', $ticket, navigate: true);
|
||||
return $this->redirectRoute('helpdesk.tickets.show', ['ticket' => $ticket->getKey()]);
|
||||
}
|
||||
|
||||
/** @return View */
|
||||
|
||||
@@ -10,9 +10,11 @@ use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\TicketService;
|
||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||
|
||||
final class TicketShow extends Component
|
||||
{
|
||||
@@ -30,25 +32,36 @@ final class TicketShow extends Component
|
||||
|
||||
public ?int $status_id = null;
|
||||
|
||||
/**
|
||||
* Mount the component.
|
||||
*
|
||||
* @param Ticket $ticket
|
||||
* @return void
|
||||
*/
|
||||
public ?int $priority_id = null;
|
||||
|
||||
public ?int $reply_parent_id = null;
|
||||
|
||||
public string $replyBody = '';
|
||||
|
||||
/** @return void */
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
$this->authorize('view', $ticket);
|
||||
$this->ticket = $ticket->load(['owner', 'agent', 'status', 'priority', 'category', 'project', 'attachments.user']);
|
||||
$this->ticket = $this->freshTicket($ticket);
|
||||
$this->status_id = $this->ticket->status_id;
|
||||
$this->priority_id = $this->ticket->priority_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to the ticket.
|
||||
*
|
||||
* @param TicketService $tickets
|
||||
* @return void
|
||||
*/
|
||||
/** @return void */
|
||||
public function startReply(int $commentId): void
|
||||
{
|
||||
$this->reply_parent_id = $commentId;
|
||||
$this->replyBody = '';
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function cancelReply(): void
|
||||
{
|
||||
$this->reply_parent_id = null;
|
||||
$this->replyBody = '';
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function addComment(TicketService $tickets): void
|
||||
{
|
||||
$this->authorize('comment', $this->ticket);
|
||||
@@ -61,16 +74,29 @@ final class TicketShow extends Component
|
||||
$tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments);
|
||||
|
||||
$this->reset(['commentBody', 'internal', 'attachments']);
|
||||
$this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'comments.user', 'comments.attachments', 'attachments.user']);
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$this->dispatch('helpdesk-comment-added');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status using Livewire AJAX.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updateStatus(): void
|
||||
/** @return void */
|
||||
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->cancelReply();
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$this->dispatch('helpdesk-reply-added');
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function updateStatus(WorkflowNotificationService $workflow): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
$this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]);
|
||||
@@ -82,15 +108,43 @@ final class TicketShow extends Component
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'comments.user', 'comments.attachments', 'attachments.user']);
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
|
||||
$this->dispatch('helpdesk-status-updated');
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
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');
|
||||
}
|
||||
|
||||
/** @return View */
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-show', [
|
||||
'statuses' => Status::query()->orderBy('sort_order')->get(),
|
||||
'priorities' => Priority::query()->orderBy('sort_order')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
/** @return Ticket */
|
||||
private function freshTicket(Ticket $ticket): Ticket
|
||||
{
|
||||
return $ticket->fresh([
|
||||
'owner', 'agent', 'status', 'priority', 'category', 'project', 'team',
|
||||
'comments.user', 'comments.attachments', 'comments.replies.user', 'comments.replies.attachments', 'comments.replies.replies.user',
|
||||
'attachments.user', 'gitCommits.repository',
|
||||
]) ?? $ticket;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user