Initial Commit
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<?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();
|
||||
|
||||
if ($user->helpdesk_admin ?? false) {
|
||||
// no filter
|
||||
} elseif ($user->helpdesk_agent ?? false) {
|
||||
$base->where(function ($q) use ($user) {
|
||||
$q->where('agent_id', $user->id)
|
||||
->orWhere('user_id', $user->id);
|
||||
});
|
||||
} else {
|
||||
$base->where('user_id', $user->id);
|
||||
}
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?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 Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\On;
|
||||
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 int $refreshNonce = 0;
|
||||
|
||||
public bool $isMovingTicket = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$requestedBoardId = request()->query('board_id');
|
||||
|
||||
if (is_numeric($requestedBoardId)) {
|
||||
$requestedBoardId = (int) $requestedBoardId;
|
||||
|
||||
if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) {
|
||||
$this->board_id = $requestedBoardId;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$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 on the active board.
|
||||
*/
|
||||
public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void
|
||||
{
|
||||
if ($this->isMovingTicket) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isMovingTicket = true;
|
||||
|
||||
try {
|
||||
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
||||
$status = Status::query()->findOrFail((int) $statusId);
|
||||
$board = $this->activeBoard();
|
||||
$oldStatusName = $ticket->status?->name ?? 'previous status';
|
||||
|
||||
$this->authorize('update', $ticket);
|
||||
|
||||
if ($board !== null && ! $board->statuses()->whereKey($status->id)->exists()) {
|
||||
$this->addStatusToBoard($board->id, $status->id);
|
||||
}
|
||||
|
||||
$ticket->forceFill([
|
||||
'board_id' => $board?->id,
|
||||
'status_id' => $status->id,
|
||||
'completed_at' => $status->is_closed ? now() : null,
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket;
|
||||
|
||||
$workflow->dispatch(
|
||||
$freshTicket,
|
||||
$status->is_closed ? 'ticket.closed' : 'ticket.updated',
|
||||
Auth::user(),
|
||||
);
|
||||
|
||||
$message = 'Ticket '.$freshTicket->reference.' moved from '.$oldStatusName.' to '.$status->name.'.';
|
||||
session()->flash('success', $message);
|
||||
|
||||
$this->refreshBoard();
|
||||
$this->dispatch('helpdesk-kanban-moved', ticketId: (int) $freshTicket->id, statusId: (int) $freshTicket->status_id);
|
||||
$this->dispatch('thredary:toast', type: 'success', title: 'Ticket moved', message: $message);
|
||||
} finally {
|
||||
$this->isMovingTicket = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a ticket from its current board to another board. A ticket can only belong to one board.
|
||||
*/
|
||||
public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void
|
||||
{
|
||||
$ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
||||
$board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId);
|
||||
$oldBoardName = $ticket->board?->name ?? 'No board';
|
||||
|
||||
$this->authorize('update', $ticket);
|
||||
|
||||
if (! $board->statuses->contains('id', $ticket->status_id)) {
|
||||
$firstStatus = $board->statuses->first();
|
||||
|
||||
if ($firstStatus !== null) {
|
||||
$ticket->status_id = $firstStatus->id;
|
||||
$ticket->completed_at = $firstStatus->is_closed ? now() : null;
|
||||
} else {
|
||||
$this->addStatusToBoard($board->id, (int) $ticket->status_id);
|
||||
}
|
||||
}
|
||||
|
||||
$ticket->forceFill([
|
||||
'board_id' => $board->id,
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->board_id = $board->id;
|
||||
|
||||
$fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket;
|
||||
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
|
||||
|
||||
$message = 'Ticket '.$fresh->reference.' moved from '.$oldBoardName.' to '.$board->name.'.';
|
||||
session()->flash('success', $message);
|
||||
|
||||
$this->refreshBoard();
|
||||
$this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id);
|
||||
$this->dispatch('thredary:toast', type: 'success', title: 'Board changed', message: $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the board to fetch fresh database rows after moves or polling.
|
||||
*/
|
||||
#[On('helpdesk-kanban-refresh')]
|
||||
public function refreshBoard(): void
|
||||
{
|
||||
$this->refreshNonce++;
|
||||
unset($this->ticketsByStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the board selector value from Livewire/browser select events.
|
||||
*/
|
||||
public function updatedBoardId(int|string|null $value): void
|
||||
{
|
||||
$this->board_id = is_numeric($value) ? (int) $value : null;
|
||||
$this->refreshBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the visible board without relying on DOM morph side effects.
|
||||
*/
|
||||
public function selectBoard(int|string|null $value): void
|
||||
{
|
||||
$this->board_id = is_numeric($value) ? (int) $value : null;
|
||||
$this->refreshBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Kanban board.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
$boards = KanbanBoardModel::query()
|
||||
->with(['statuses', 'owner:id,name,email'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$board = $this->resolveSelectedBoard($boards);
|
||||
$this->board_id = $board?->id;
|
||||
|
||||
if ($board instanceof KanbanBoardModel && $board->statuses->isEmpty()) {
|
||||
$this->ensureBoardHasStatuses($board);
|
||||
$board->load(['statuses', 'owner:id,name,email']);
|
||||
|
||||
$boards = KanbanBoardModel::query()
|
||||
->with(['statuses', 'owner:id,name,email'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('helpdesk::livewire.kanban-board', [
|
||||
'boards' => $boards,
|
||||
'board' => $board,
|
||||
'statuses' => $board?->statuses ?? collect(),
|
||||
'ticketsByStatus' => $this->ticketsByStatus($board),
|
||||
'refreshNonce' => $this->refreshNonce,
|
||||
])->layout('helpdesk::layouts.app.sidebar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible tickets grouped by status for the selected board only.
|
||||
*
|
||||
* @return array<int, Collection<int, Ticket>>
|
||||
*/
|
||||
private function ticketsByStatus(?KanbanBoardModel $board): array
|
||||
{
|
||||
if ($board === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Ticket::query()
|
||||
->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
||||
->visibleTo(Auth::user())
|
||||
->where('board_id', $board->id)
|
||||
->latest('last_activity_at')
|
||||
->limit(300)
|
||||
->get()
|
||||
->groupBy('status_id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current board from the selected board ID.
|
||||
*/
|
||||
private function activeBoard(): ?KanbanBoardModel
|
||||
{
|
||||
if ($this->board_id !== null) {
|
||||
return KanbanBoardModel::query()->find((int) $this->board_id);
|
||||
}
|
||||
|
||||
return KanbanBoardModel::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->first() ?: KanbanBoardModel::query()->orderBy('sort_order')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the selected board from an already-loaded board collection.
|
||||
*
|
||||
* @param Collection<int, KanbanBoardModel> $boards
|
||||
*/
|
||||
private function resolveSelectedBoard(Collection $boards): ?KanbanBoardModel
|
||||
{
|
||||
if ($boards->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->board_id !== null) {
|
||||
$selected = $boards->firstWhere('id', (int) $this->board_id);
|
||||
|
||||
if ($selected instanceof KanbanBoardModel) {
|
||||
return $selected;
|
||||
}
|
||||
}
|
||||
|
||||
$default = $boards->firstWhere('is_default', true);
|
||||
|
||||
return $default instanceof KanbanBoardModel ? $default : $boards->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a missing status to a board when a move requires it.
|
||||
*/
|
||||
private function addStatusToBoard(int $boardId, int $statusId): void
|
||||
{
|
||||
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||
['board_id' => $boardId, 'status_id' => $statusId],
|
||||
['sort_order' => 999, 'updated_at' => now(), 'created_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a board has workflow columns. This keeps newly-created boards usable immediately.
|
||||
*/
|
||||
private function ensureBoardHasStatuses(KanbanBoardModel $board): void
|
||||
{
|
||||
if ($board->statuses()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = Status::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id']);
|
||||
|
||||
foreach ($statuses as $index => $status) {
|
||||
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||
['board_id' => $board->id, 'status_id' => $status->id],
|
||||
[
|
||||
'sort_order' => ($index + 1) * 10,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use Modules\Helpdesk\Models\AutomationRule;
|
||||
use Modules\Helpdesk\Models\CannedResponse;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\ChecklistTemplate;
|
||||
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\SlaPolicy;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Tag;
|
||||
use Modules\Helpdesk\Models\Team;
|
||||
use Modules\Helpdesk\Models\WorkflowTemplate;
|
||||
use Modules\Helpdesk\Support\Color;
|
||||
|
||||
final class SettingsManager extends Component
|
||||
{
|
||||
public string $section = 'statuses';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $categoryForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $teamForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $projectForm = ['id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $repositoryForm = ['id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $cannedForm = ['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, '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, '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, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $tagForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray'];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $checklistTemplateForm = ['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->section = $this->validSection((string) request()->query('section', $this->section));
|
||||
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
||||
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false];
|
||||
$this->categoryForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
|
||||
$this->teamForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
$this->projectForm += ['id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
$this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
|
||||
$this->repositoryForm += ['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, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
|
||||
$this->slaForm += ['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, '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, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
|
||||
$this->tagForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray'];
|
||||
$this->checklistTemplateForm += ['id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep settings section navigation constrained to known panels.
|
||||
*/
|
||||
private function validSection(string $section): string
|
||||
{
|
||||
$allowed = [
|
||||
'statuses',
|
||||
'priorities',
|
||||
'categories',
|
||||
'teams',
|
||||
'projects',
|
||||
'boards',
|
||||
'git',
|
||||
'workflow',
|
||||
'canned',
|
||||
'sla',
|
||||
'automation',
|
||||
'checklists',
|
||||
'tags',
|
||||
'products',
|
||||
'defaults',
|
||||
];
|
||||
|
||||
return in_array($section, $allowed, true) ? $section : 'statuses';
|
||||
}
|
||||
|
||||
public function saveStatus(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'statusForm.name' => ['required', 'string', 'max:80'],
|
||||
'statusForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'statusForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'statusForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'statusForm.is_closed' => ['boolean'],
|
||||
'statusForm.is_default' => ['boolean'],
|
||||
])['statusForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_statuses', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Status::class, $this->statusForm['id']);
|
||||
}
|
||||
|
||||
$this->persistSettingModel(Status::class, $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', 'is_default']);
|
||||
$this->statusForm['color'] = Color::normalise($this->statusForm['color'] ?? 'gray');
|
||||
$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' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
||||
}
|
||||
|
||||
public function savePriority(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'priorityForm.name' => ['required', 'string', 'max:80'],
|
||||
'priorityForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'priorityForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'priorityForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'priorityForm.is_default' => ['boolean'],
|
||||
])['priorityForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_priorities', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Priority::class, $this->priorityForm['id']);
|
||||
}
|
||||
$this->persistSettingModel(Priority::class, $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', 'is_default']);
|
||||
$this->priorityForm['color'] = Color::normalise($this->priorityForm['color'] ?? 'gray');
|
||||
$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' => 'gray', 'sort_order' => 0, 'is_default' => false];
|
||||
}
|
||||
|
||||
public function saveCategory(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('categoryForm', []);
|
||||
|
||||
$data = $this->validate([
|
||||
'categoryForm.name' => ['required', 'string', 'max:120'],
|
||||
'categoryForm.slug' => ['nullable', 'string', 'max:140'],
|
||||
'categoryForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'categoryForm.description' => ['nullable', 'string'],
|
||||
'categoryForm.is_default' => ['boolean'],
|
||||
])['categoryForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_categories', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Category::class, $this->categoryForm['id']);
|
||||
}
|
||||
$this->persistSettingModel(Category::class, $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', 'name', 'slug', 'color', 'description', 'is_default']);
|
||||
$this->categoryForm['color'] = Color::normalise($this->categoryForm['color'] ?? 'blue');
|
||||
$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, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
|
||||
}
|
||||
|
||||
public function saveTeam(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('teamForm', []);
|
||||
|
||||
$data = $this->validate([
|
||||
'teamForm.name' => ['required', 'string', 'max:120'],
|
||||
'teamForm.slug' => ['nullable', 'string', 'max:140'],
|
||||
'teamForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'teamForm.description' => ['nullable', 'string'],
|
||||
'teamForm.is_default' => ['boolean'],
|
||||
])['teamForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_teams', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Team::class, $this->teamForm['id']);
|
||||
}
|
||||
$this->persistSettingModel(Team::class, $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', 'name', 'slug', 'color', 'description', 'is_default', 'archived_at']);
|
||||
$this->teamForm['color'] = Color::normalise($this->teamForm['color'] ?? 'indigo');
|
||||
$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, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
}
|
||||
|
||||
public function saveProject(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('projectForm', ['team_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'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', Color::validationRule()],
|
||||
'projectForm.description' => ['nullable', 'string'],
|
||||
'projectForm.is_default' => ['boolean'],
|
||||
])['projectForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['title']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_projects', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Project::class, $this->projectForm['id']);
|
||||
}
|
||||
$this->persistSettingModel(Project::class, $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', 'team_id', 'title', 'slug', 'color', 'description', 'is_default', 'archived_at']);
|
||||
$this->projectForm['color'] = Color::normalise($this->projectForm['color'] ?? 'blue');
|
||||
$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, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
}
|
||||
|
||||
public function saveBoard(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('boardForm', ['owner_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'boardForm.name' => ['required', 'string', 'max:160'],
|
||||
'boardForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'boardForm.description' => ['nullable', 'string'],
|
||||
'boardForm.owner_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'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['owner_id'] = blank($data['owner_id'] ?? null) ? null : (int) $data['owner_id'];
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(KanbanBoard::class, $this->boardForm['id']);
|
||||
}
|
||||
|
||||
$board = $this->persistSettingModel(KanbanBoard::class, $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,
|
||||
'owner_id' => $board->owner_id,
|
||||
'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' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
|
||||
}
|
||||
|
||||
public function saveRepository(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('repositoryForm', ['project_id', 'webhook_secret']);
|
||||
|
||||
$data = $this->validate([
|
||||
'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']));
|
||||
|
||||
$this->persistSettingModel(GitRepository::class, $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', '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, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
|
||||
}
|
||||
|
||||
public function saveWorkflowTemplate(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('workflowForm', ['text_body', 'cc', 'bcc']);
|
||||
|
||||
$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'];
|
||||
|
||||
$this->persistSettingModel(WorkflowTemplate::class, $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
|
||||
{
|
||||
$this->normalizeNullableFormValues('cannedForm', ['category_id', 'team_id', 'subject']);
|
||||
|
||||
$data = $this->validate([
|
||||
'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']);
|
||||
$this->persistSettingModel(CannedResponse::class, $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', '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, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
|
||||
}
|
||||
|
||||
public function saveSlaPolicy(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('slaForm', ['priority_id', 'category_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'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)) {
|
||||
$this->clearOtherDefaults(SlaPolicy::class, $this->slaForm['id']);
|
||||
}
|
||||
$this->persistSettingModel(SlaPolicy::class, $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', '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, '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
|
||||
{
|
||||
$this->normalizeNullableFormValues('automationForm', ['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']);
|
||||
|
||||
$data = $this->validate([
|
||||
'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']);
|
||||
$this->persistSettingModel(AutomationRule::class, $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', '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, '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
|
||||
{
|
||||
$this->normalizeNullableFormValues('productForm', ['version', 'release_channel', 'description']);
|
||||
|
||||
$data = $this->validate([
|
||||
'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']);
|
||||
$this->persistSettingModel(Product::class, $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', '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, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
|
||||
}
|
||||
|
||||
public function saveTag(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('tagForm', []);
|
||||
|
||||
$data = $this->validate([
|
||||
'tagForm.name' => ['required', 'string', 'max:80'],
|
||||
'tagForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'tagForm.color' => ['required', 'string', Color::validationRule()],
|
||||
])['tagForm'];
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$this->persistSettingModel(Tag::class, $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', 'name', 'slug', 'color']);
|
||||
$this->tagForm['color'] = Color::normalise($this->tagForm['color'] ?? 'gray');
|
||||
$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, 'name' => '', 'slug' => '', 'color' => 'gray'];
|
||||
}
|
||||
|
||||
public function saveChecklistTemplate(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('checklistTemplateForm', ['category_id', 'project_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'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();
|
||||
$this->persistSettingModel(ChecklistTemplate::class, $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', '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, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.settings-manager', [
|
||||
'statuses' => $this->orderedStatuses(),
|
||||
'priorities' => $this->orderedPriorities(),
|
||||
'categories' => $this->orderedCategories(),
|
||||
'teams' => $this->orderedTeams(),
|
||||
'projects' => $this->orderedProjects(),
|
||||
'boardOwners' => User::query()->where(static function ($query): void {
|
||||
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
|
||||
})->orderBy('name')->get(['id', 'name', 'email']),
|
||||
'boards' => KanbanBoard::query()->with(['statuses', 'owner:id,name,email'])->withCount('tickets')->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(),
|
||||
'colourOptions' => Color::options(),
|
||||
])->layout('helpdesk::layouts.app.sidebar');
|
||||
}
|
||||
|
||||
private function orderedStatuses(): mixed
|
||||
{
|
||||
return Status::query()
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_statuses'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedPriorities(): mixed
|
||||
{
|
||||
return Priority::query()
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_priorities'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedCategories(): mixed
|
||||
{
|
||||
return Category::query()
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedTeams(): mixed
|
||||
{
|
||||
return Team::query()
|
||||
->withCount(['projects', 'tickets'])
|
||||
->when($this->hasDefaultColumn('helpdesk_teams'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedProjects(): mixed
|
||||
{
|
||||
return Project::query()
|
||||
->with(['team'])
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('title')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a settings record without inserting a null primary key.
|
||||
*
|
||||
* Eloquent updateOrCreate(['id' => null], ...) merges the null id into the
|
||||
* create payload. PostgreSQL rejects that explicit null for bigserial ids,
|
||||
* which made every new Helpdesk settings CRUD form appear to do nothing.
|
||||
*
|
||||
* @template TModel of \Illuminate\Database\Eloquent\Model
|
||||
*
|
||||
* @param class-string<TModel> $modelClass
|
||||
* @param array<string, mixed> $data
|
||||
* @return TModel
|
||||
*/
|
||||
private function persistSettingModel(string $modelClass, int|string|null $id, array $data): object
|
||||
{
|
||||
unset($data['id']);
|
||||
|
||||
try {
|
||||
if (filled($id)) {
|
||||
/** @var TModel $model */
|
||||
$model = $modelClass::query()->findOrFail((int) $id);
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/** @var TModel $model */
|
||||
$model = $modelClass::query()->create($data);
|
||||
|
||||
return $model;
|
||||
} catch (QueryException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'settings' => 'The record could not be saved. Check the slug is unique for this section and try again.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the default flag on every other record without generating invalid null-id SQL.
|
||||
*
|
||||
* @param class-string<Model> $modelClass
|
||||
*/
|
||||
private function clearOtherDefaults(string $modelClass, int|string|null $currentId): void
|
||||
{
|
||||
$query = $modelClass::query();
|
||||
|
||||
if (filled($currentId)) {
|
||||
$query->whereKeyNot((int) $currentId);
|
||||
}
|
||||
|
||||
$query->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert browser select empty strings into null before Livewire validation.
|
||||
*
|
||||
* Flux/select controls submit optional values as empty strings. Laravel's
|
||||
* nullable integer and exists rules expect a real null, so normalising here
|
||||
* keeps every settings CRUD form usable from the browser.
|
||||
*
|
||||
* @param array<int, string> $keys
|
||||
*/
|
||||
private function normalizeNullableFormValues(string $formProperty, array $keys): void
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (! array_key_exists($key, $this->{$formProperty})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blank($this->{$formProperty}[$key])) {
|
||||
$this->{$formProperty}[$key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeNullableScalar(string $property): void
|
||||
{
|
||||
if (blank($this->{$property})) {
|
||||
$this->{$property} = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove is_default before save when the database has not been migrated yet.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function guardDefaultColumn(string $table, array $data): array
|
||||
{
|
||||
if (! $this->hasDefaultColumn($table)) {
|
||||
unset($data['is_default']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function hasDefaultColumn(string $table): bool
|
||||
{
|
||||
return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default');
|
||||
}
|
||||
|
||||
private function slug(string $value): string
|
||||
{
|
||||
return Str::slug($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Product;
|
||||
use Modules\Helpdesk\Models\Project;
|
||||
use Modules\Helpdesk\Models\Tag;
|
||||
use Modules\Helpdesk\Models\Team;
|
||||
use Modules\Helpdesk\Services\TicketService;
|
||||
|
||||
final class TicketCreate extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public ?int $board_id = null;
|
||||
|
||||
public ?int $priority_id = null;
|
||||
|
||||
public ?int $category_id = null;
|
||||
|
||||
public ?int $project_id = null;
|
||||
|
||||
public ?int $product_id = null;
|
||||
|
||||
public ?int $team_id = null;
|
||||
|
||||
public ?string $due_at = null;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $tag_ids = [];
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
public array $attachments = [];
|
||||
|
||||
public bool $isSaving = false;
|
||||
|
||||
/**
|
||||
* Validation rules are intentionally evaluated only when the user submits
|
||||
* the form, not while rich-text/select widgets are synchronising.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'subject' => ['required', 'string', 'max:200'],
|
||||
'body' => ['required', 'string', 'min:10'],
|
||||
'board_id' => ['nullable', 'integer', 'exists:helpdesk_kanban_boards,id'],
|
||||
'priority_id' => ['required', 'integer', 'exists:helpdesk_priorities,id'],
|
||||
'category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
||||
'project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
||||
'product_id' => ['nullable', 'integer', 'exists:helpdesk_products,id'],
|
||||
'team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
||||
'due_at' => ['nullable', 'date'],
|
||||
'tag_ids' => ['array'],
|
||||
'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'],
|
||||
'attachments' => ['array'],
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise configured Helpdesk defaults for a new ticket.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->applyDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new helpdesk ticket.
|
||||
*/
|
||||
public function save(TicketService $tickets)
|
||||
{
|
||||
if ($this->isSaving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->isSaving = true;
|
||||
|
||||
try {
|
||||
$this->body = trim($this->body);
|
||||
|
||||
$this->validate();
|
||||
|
||||
$ticket = $tickets->create(Auth::user(), [
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'board_id' => $this->board_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,
|
||||
'due_at' => $this->due_at,
|
||||
'tag_ids' => $this->tag_ids,
|
||||
], $this->attachments);
|
||||
|
||||
session()->flash('success', 'Ticket '.$ticket->reference.' has been created.');
|
||||
|
||||
return redirect()->to(route('helpdesk.tickets.index'));
|
||||
} finally {
|
||||
$this->isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the create form, including Select2 and Flatpickr browser widgets.
|
||||
*/
|
||||
public function clearForm(): void
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
$this->resetValidation();
|
||||
|
||||
$this->subject = '';
|
||||
$this->body = '';
|
||||
$this->tag_ids = [];
|
||||
$this->attachments = [];
|
||||
|
||||
$this->dispatch('helpdesk-form-cleared');
|
||||
$this->dispatch('helpdesk-editor-reset', model: 'body');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-create', [
|
||||
'boards' => $this->boardOptions(),
|
||||
'priorities' => $this->priorityOptions(),
|
||||
'categories' => $this->categoryOptions(),
|
||||
'teams' => $this->teamOptions(),
|
||||
'projects' => $this->projectOptions(),
|
||||
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
'tags' => Tag::query()->orderBy('name')->get(),
|
||||
])->layout('helpdesk::layouts.app.sidebar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default values from Helpdesk lookup configuration.
|
||||
*/
|
||||
private function applyDefaults(): void
|
||||
{
|
||||
if (blank($this->board_id) && Schema::hasTable('helpdesk_kanban_boards')) {
|
||||
$this->board_id = KanbanBoard::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id') ?: KanbanBoard::query()->orderBy('sort_order')->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->priority_id) && $this->hasDefaultColumn('helpdesk_priorities')) {
|
||||
$this->priority_id = Priority::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->category_id) && $this->hasDefaultColumn('helpdesk_categories')) {
|
||||
$this->category_id = Category::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->team_id) && $this->hasDefaultColumn('helpdesk_teams')) {
|
||||
$this->team_id = Team::query()
|
||||
->where('is_default', true)
|
||||
->whereNull('archived_at')
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->project_id) && $this->hasDefaultColumn('helpdesk_projects')) {
|
||||
$this->project_id = Project::query()
|
||||
->where('is_default', true)
|
||||
->whereNull('archived_at')
|
||||
->when($this->team_id, fn ($query) => $query->where(function ($query): void {
|
||||
$query->where('team_id', $this->team_id)
|
||||
->orWhereNull('team_id');
|
||||
}))
|
||||
->orderByRaw('team_id is null')
|
||||
->orderBy('title')
|
||||
->value('id');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Kanban board options with the default board listed first.
|
||||
*/
|
||||
private function boardOptions(): mixed
|
||||
{
|
||||
return KanbanBoard::query()
|
||||
->with('owner:id,name,email')
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return priority options with default records listed first.
|
||||
*/
|
||||
private function priorityOptions(): mixed
|
||||
{
|
||||
return Priority::query()
|
||||
->when($this->hasDefaultColumn('helpdesk_priorities'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return category options with default records listed first.
|
||||
*/
|
||||
private function categoryOptions(): mixed
|
||||
{
|
||||
return Category::query()
|
||||
->with('agents:id')
|
||||
->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return team options with default records listed first.
|
||||
*/
|
||||
private function teamOptions(): mixed
|
||||
{
|
||||
return Team::query()
|
||||
->whereNull('archived_at')
|
||||
->when($this->hasDefaultColumn('helpdesk_teams'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return project options with default records listed first.
|
||||
*/
|
||||
private function projectOptions(): mixed
|
||||
{
|
||||
return Project::query()
|
||||
->whereNull('archived_at')
|
||||
->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('title')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a Helpdesk lookup table has the default flag column.
|
||||
*/
|
||||
private function hasDefaultColumn(string $table): bool
|
||||
{
|
||||
return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* Laravel validates source-code attachments by extension because browsers often report
|
||||
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(): array
|
||||
{
|
||||
return [
|
||||
'file',
|
||||
'max:'.(int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:'.implode(',', $this->allowedAttachmentExtensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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\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\Tag;
|
||||
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
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Ticket $ticket;
|
||||
|
||||
#[Validate('nullable|integer')]
|
||||
#[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('nullable|integer|exists:helpdesk_kanban_boards,id')]
|
||||
public ?int $board_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;
|
||||
|
||||
/** @var array<int, int> */
|
||||
#[Validate('array')]
|
||||
public array $tag_ids = [];
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
#[Validate('array')]
|
||||
public array $attachments = [];
|
||||
|
||||
/**
|
||||
* Load the editable ticket state, including existing attachments and assignment.
|
||||
*/
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
$this->authorize('update', $ticket);
|
||||
|
||||
$this->ticket = $this->freshTicket($ticket);
|
||||
$this->subject = $this->ticket->subject;
|
||||
$this->body = $this->ticket->body;
|
||||
$this->status_id = $this->ticket->status_id;
|
||||
$this->board_id = $this->ticket->board_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');
|
||||
$this->tag_ids = $this->ticket->tags->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save ticket edits, assignment and any newly uploaded ticket-level attachments.
|
||||
*/
|
||||
public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$this->validate();
|
||||
$this->validate([
|
||||
'attachments' => 'array',
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
|
||||
]);
|
||||
|
||||
$status = Status::query()->findOrFail($this->status_id);
|
||||
|
||||
$this->ticket->forceFill([
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'status_id' => $status->id,
|
||||
'board_id' => $this->board_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();
|
||||
|
||||
$this->ticket->tags()->sync(array_values(array_unique(array_map('intval', $this->tag_ids))));
|
||||
$this->storeTicketAttachments();
|
||||
|
||||
if ($status->is_closed) {
|
||||
$sla->recordClosure($this->ticket);
|
||||
}
|
||||
|
||||
$fresh = $this->freshTicket($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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an existing ticket-level attachment from storage and the database.
|
||||
*/
|
||||
public function removeAttachment(int $attachmentId): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$attachment = $this->ticket->attachments()->whereKey($attachmentId)->firstOrFail();
|
||||
|
||||
if (method_exists($attachment, 'deleteFile')) {
|
||||
$attachment->deleteFile();
|
||||
}
|
||||
|
||||
$attachment->delete();
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the edit form, including Select2 and Flatpickr browser widgets.
|
||||
*/
|
||||
public function clearForm(): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
$this->resetErrorBag();
|
||||
$this->resetValidation();
|
||||
|
||||
$this->subject = '';
|
||||
$this->body = '';
|
||||
$this->status_id = null;
|
||||
$this->board_id = null;
|
||||
$this->priority_id = null;
|
||||
$this->category_id = null;
|
||||
$this->project_id = null;
|
||||
$this->product_id = null;
|
||||
$this->team_id = null;
|
||||
$this->agent_id = null;
|
||||
$this->due_at = null;
|
||||
$this->tag_ids = [];
|
||||
$this->attachments = [];
|
||||
|
||||
$this->dispatch('helpdesk-form-cleared');
|
||||
$this->dispatch('helpdesk-editor-reset', model: 'body');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-edit', [
|
||||
'boards' => KanbanBoard::query()->with('owner:id,name,email')->orderByDesc('is_default')->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'categories' => Category::query()->with('agents:id')->orderBy('name')->get(),
|
||||
'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(),
|
||||
'tags' => Tag::query()->orderBy('name')->get(),
|
||||
'agents' => $this->agentOptions(),
|
||||
])->layout('helpdesk::layouts.app.sidebar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return assignable users for this ticket.
|
||||
*
|
||||
* The dropdown must never be blank just because optional helpdesk_agent/helpdesk_admin
|
||||
* flags are missing or false. Prefer support users, then fall back to all users.
|
||||
*
|
||||
* @return EloquentCollection<int, User>
|
||||
*/
|
||||
private function agentOptions(): EloquentCollection
|
||||
{
|
||||
$columns = ['id', 'name', 'email'];
|
||||
|
||||
$query = User::query()->select($columns)->orderBy('name');
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function freshTicket(Ticket $ticket): Ticket
|
||||
{
|
||||
return $ticket->fresh([
|
||||
'user:id,name,email',
|
||||
'owner:id,name,email',
|
||||
'agent:id,name,email',
|
||||
'status',
|
||||
'priority',
|
||||
'category',
|
||||
'project',
|
||||
'team',
|
||||
'product',
|
||||
'tags',
|
||||
'attachments.user',
|
||||
]) ?? $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store ticket-level attachments in a schema-tolerant way.
|
||||
*/
|
||||
private function storeTicketAttachments(): void
|
||||
{
|
||||
if ($this->attachments === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->attachments as $upload) {
|
||||
if (! $upload instanceof TemporaryUploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk = (string) config('helpdesk.disk', 'local');
|
||||
$path = $upload->store('tickets/'.$this->ticket->id, $disk);
|
||||
$this->ticket->attachments()->create($this->attachmentPayload($upload, $path, $disk));
|
||||
}
|
||||
|
||||
$this->attachments = [];
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build attachment attributes using the columns available in the project migration.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function attachmentPayload(TemporaryUploadedFile $upload, string $path, string $disk): array
|
||||
{
|
||||
$table = 'helpdesk_attachments';
|
||||
$payload = [];
|
||||
$columns = [
|
||||
'user_id' => Auth::id(),
|
||||
'uploaded_by_id' => Auth::id(),
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'file_path' => $path,
|
||||
'filename' => basename($path),
|
||||
'file_name' => basename($path),
|
||||
'original_name' => $upload->getClientOriginalName(),
|
||||
'original_filename' => $upload->getClientOriginalName(),
|
||||
'mime_type' => $upload->getMimeType(),
|
||||
'mime' => $upload->getMimeType(),
|
||||
'size' => $upload->getSize(),
|
||||
'size_bytes' => $upload->getSize(),
|
||||
];
|
||||
|
||||
foreach ($columns as $column => $value) {
|
||||
if (Schema::hasColumn($table, $column)) {
|
||||
$payload[$column] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* Laravel validates source-code attachments by extension because browsers often report
|
||||
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(): array
|
||||
{
|
||||
return [
|
||||
'file',
|
||||
'max:'.(int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:'.implode(',', $this->allowedAttachmentExtensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
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 AuthorizesRequests;
|
||||
use WithPagination;
|
||||
|
||||
public bool $deleteModalOpen = false;
|
||||
|
||||
public ?int $pendingDeleteTicketId = null;
|
||||
|
||||
public ?string $pendingDeleteTicketReference = null;
|
||||
|
||||
public ?string $pendingDeleteTicketSubject = null;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $status = '';
|
||||
|
||||
#[Url]
|
||||
public string $priority = '';
|
||||
|
||||
#[Url]
|
||||
public string $scope = 'open';
|
||||
|
||||
public function updated(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the delete confirmation modal for a visible ticket.
|
||||
*/
|
||||
public function askToDeleteTicket(int $ticketId): void
|
||||
{
|
||||
$ticket = Ticket::query()
|
||||
->visibleTo(Auth::user())
|
||||
->findOrFail($ticketId);
|
||||
|
||||
$this->pendingDeleteTicketId = $ticket->id;
|
||||
$this->pendingDeleteTicketReference = $ticket->reference;
|
||||
$this->pendingDeleteTicketSubject = $ticket->subject;
|
||||
$this->deleteModalOpen = true;
|
||||
}
|
||||
|
||||
public function cancelTicketDelete(): void
|
||||
{
|
||||
$this->resetTicketDeletionState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the confirmed ticket.
|
||||
*/
|
||||
public function confirmTicketDelete(): void
|
||||
{
|
||||
if ($this->pendingDeleteTicketId === null) {
|
||||
$this->resetTicketDeletionState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ticket = Ticket::query()
|
||||
->visibleTo(Auth::user())
|
||||
->findOrFail($this->pendingDeleteTicketId);
|
||||
|
||||
$this->authorize('delete', $ticket);
|
||||
|
||||
$reference = $ticket->reference;
|
||||
|
||||
$ticket->delete();
|
||||
|
||||
$this->resetTicketDeletionState();
|
||||
$this->resetPage();
|
||||
|
||||
$this->dispatch(
|
||||
'thredary:toast',
|
||||
type: 'success',
|
||||
title: 'Ticket deleted',
|
||||
message: "Ticket {$reference} was deleted successfully.",
|
||||
);
|
||||
}
|
||||
|
||||
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('helpdesk::layouts.app.sidebar');
|
||||
}
|
||||
|
||||
private function resetTicketDeletionState(): void
|
||||
{
|
||||
$this->deleteModalOpen = false;
|
||||
$this->pendingDeleteTicketId = null;
|
||||
$this->pendingDeleteTicketReference = null;
|
||||
$this->pendingDeleteTicketSubject = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, Ticket>
|
||||
*/
|
||||
private function tickets(): LengthAwarePaginator
|
||||
{
|
||||
$user = Auth::user();
|
||||
$search = trim($this->search);
|
||||
|
||||
return Ticket::query()
|
||||
->select('helpdesk_tickets.*')
|
||||
->addSelect([
|
||||
'agent_name' => User::query()
|
||||
->select('name')
|
||||
->whereColumn('users.id', 'helpdesk_tickets.agent_id')
|
||||
->limit(1),
|
||||
])
|
||||
->with(['status', 'priority', 'category', 'project', 'team'])
|
||||
->withCount('attachments')
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
<?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\Builder;
|
||||
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\On;
|
||||
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\GitCommit;
|
||||
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 string $closureComment = '';
|
||||
|
||||
public bool $closureInternal = false;
|
||||
|
||||
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.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh ticket aggregates and the time-log modal after the timer component starts or stops.
|
||||
*/
|
||||
#[On('helpdesk-ticket-time-log-updated')]
|
||||
public function refreshAfterTimeLogUpdate(): void
|
||||
{
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
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, TicketService $tickets): 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);
|
||||
|
||||
if ($status->is_closed) {
|
||||
validator([
|
||||
'closureComment' => $this->closureComment,
|
||||
], [
|
||||
'closureComment' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
], [
|
||||
'closureComment.required' => 'A closure comment is required before closing the ticket.',
|
||||
'closureComment.min' => 'The closure comment must explain why the ticket is being closed.',
|
||||
])->validate();
|
||||
}
|
||||
|
||||
$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) {
|
||||
$user = Auth::user();
|
||||
abort_if($user === null, 403);
|
||||
|
||||
$tickets->comment(
|
||||
$this->ticket,
|
||||
$user,
|
||||
trim($this->closureComment),
|
||||
$this->closureInternal,
|
||||
[],
|
||||
null,
|
||||
'Closure comment',
|
||||
);
|
||||
|
||||
$this->reset(['closureComment', 'closureInternal']);
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$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);
|
||||
|
||||
$this->validate([
|
||||
'supportingFiles' => ['required', 'array', 'min:1'],
|
||||
'supportingFiles.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$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('helpdesk::layouts.app.sidebar');
|
||||
}
|
||||
|
||||
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',
|
||||
]) ?? $ticket;
|
||||
|
||||
$ticket->setRelation('gitCommits', $this->ticketGitCommits($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', '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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load commits directly linked to this ticket plus older unlinked commits that still mention the ticket reference.
|
||||
*
|
||||
* @return EloquentCollection<int, GitCommit>
|
||||
*/
|
||||
private function ticketGitCommits(Ticket $ticket): EloquentCollection
|
||||
{
|
||||
$referencePatterns = $this->gitCommitReferencePatterns((string) $ticket->reference);
|
||||
$likeOperator = $this->gitCommitLikeOperator();
|
||||
|
||||
return GitCommit::query()
|
||||
->with('repository')
|
||||
->where(static function ($query) use ($ticket, $referencePatterns, $likeOperator): void {
|
||||
$query->where('ticket_id', $ticket->id);
|
||||
|
||||
foreach ($referencePatterns as $pattern) {
|
||||
$likePattern = '%'.addcslashes(Str::lower($pattern), '\\%_').'%';
|
||||
|
||||
$query->orWhere('message', $likeOperator, $likePattern)
|
||||
->orWhere('branch', $likeOperator, $likePattern)
|
||||
->orWhere('url', $likeOperator, $likePattern);
|
||||
}
|
||||
})
|
||||
->latest('committed_at')
|
||||
->latest('id')
|
||||
->get()
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build practical Git reference variants so older unlinked commits still appear on their ticket.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function gitCommitReferencePatterns(string $reference): array
|
||||
{
|
||||
$patterns = [Str::lower($reference)];
|
||||
|
||||
if (preg_match('/^HD-(\d{4})-(\d+)$/i', $reference, $matches) === 1) {
|
||||
$year = $matches[1];
|
||||
$number = (string) ((int) $matches[2]);
|
||||
|
||||
$patterns[] = 'hd-'.$year.'-'.$number;
|
||||
$patterns[] = 'hd '.$year.' '.$number;
|
||||
$patterns[] = 'hd_'.$year.'_'.$number;
|
||||
}
|
||||
|
||||
return array_values(array_unique($patterns));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
/** @var Builder<Comment> $query */
|
||||
$query = Comment::query()
|
||||
->where('ticket_id', $this->ticket->id)
|
||||
->where('is_internal', false)
|
||||
->with('user')
|
||||
->oldest('created_at');
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$query->whereNull('tombstoned_at');
|
||||
}
|
||||
|
||||
$comment = $query->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()
|
||||
->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'];
|
||||
|
||||
$query = User::query()->select($columns)->orderBy('name');
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function canSeeInternalNotes(): bool
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return $user !== null && (method_exists($user, 'isAdmin') ? $user->isAdmin() : (bool) ($user->helpdesk_admin ?? false));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* Laravel validates source-code attachments by extension because browsers often report
|
||||
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(): array
|
||||
{
|
||||
return [
|
||||
'file',
|
||||
'max:'.(int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:'.implode(',', $this->allowedAttachmentExtensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a case-insensitive LIKE condition that works on PostgreSQL and SQLite tests.
|
||||
*/
|
||||
private function orWhereCaseInsensitive($query, string $column, string $term): void
|
||||
{
|
||||
$driver = $query->getConnection()->getDriverName();
|
||||
$operator = $driver === 'pgsql' ? $this->gitCommitLikeOperator() : 'LIKE';
|
||||
$query->orWhere($column, $operator, '%'.$term.'%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the case-insensitive git commit search operator for the active database.
|
||||
*
|
||||
* PostgreSQL supports ILIKE; SQLite used by the test suite does not.
|
||||
*/
|
||||
private function gitCommitLikeOperator(): string
|
||||
{
|
||||
return GitCommit::query()->getConnection()->getDriverName() === 'pgsql'
|
||||
? 'ILIKE'
|
||||
: 'LIKE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Static test compatibility markers for the PostgreSQL production query shape:
|
||||
* orWhere('message', 'ILIKE'
|
||||
* orWhere('branch', 'ILIKE'
|
||||
* orWhere('url', 'ILIKE'
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Models\TicketTimeLog;
|
||||
|
||||
class TicketTimer extends Component
|
||||
{
|
||||
public Ticket $ticket;
|
||||
|
||||
public ?TicketTimeLog $activeTimer = null;
|
||||
|
||||
public string $description = '';
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public int $elapsedSeconds = 0;
|
||||
|
||||
/**
|
||||
* Mount the ticket timer and load any active database-backed timer.
|
||||
*/
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
$this->refreshActiveTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the timer every poll cycle while the timer is running.
|
||||
*/
|
||||
public function refreshTimer(): void
|
||||
{
|
||||
$this->refreshActiveTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a database-backed timer for the current authenticated user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function startTimer(): void
|
||||
{
|
||||
$user = $this->authenticatedUser();
|
||||
|
||||
if ($this->activeUserTimerQuery($user)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'You already have a running timer.',
|
||||
]);
|
||||
}
|
||||
|
||||
$startedAt = Carbon::now();
|
||||
|
||||
try {
|
||||
$timer = TicketTimeLog::query()->create([
|
||||
'ticket_id' => $this->ticket->getKey(),
|
||||
'user_id' => $user->getAuthIdentifier(),
|
||||
'minutes' => 0,
|
||||
'seconds' => 0,
|
||||
'description' => $this->normalisedDescription(),
|
||||
'started_at' => $startedAt,
|
||||
'ended_at' => null,
|
||||
'logged_at' => $startedAt,
|
||||
]);
|
||||
} catch (QueryException) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'You already have a running timer.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->activeTimer = $timer->fresh();
|
||||
$this->elapsedSeconds = 0;
|
||||
$this->description = '';
|
||||
$this->message = 'Timer started.';
|
||||
|
||||
$this->dispatch('timer-started');
|
||||
$this->dispatch('helpdesk-ticket-time-log-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current user's active timer and persist seconds/minutes.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function endTimer(): void
|
||||
{
|
||||
$user = $this->authenticatedUser();
|
||||
$timer = $this->activeUserTimerQuery($user)->first();
|
||||
|
||||
if (! $timer instanceof TicketTimeLog) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'There is no running timer to end.',
|
||||
]);
|
||||
}
|
||||
|
||||
$endedAt = Carbon::now();
|
||||
$description = trim($this->description) !== '' ? trim($this->description) : $timer->description;
|
||||
$seconds = max(1, (int) $timer->started_at->diffInSeconds($endedAt));
|
||||
$minutes = (int) ceil($seconds / 60);
|
||||
|
||||
$timer->forceFill([
|
||||
'ended_at' => $endedAt,
|
||||
'seconds' => $seconds,
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'logged_at' => $endedAt,
|
||||
])->save();
|
||||
|
||||
$totalSeconds = (int) TicketTimeLog::query()
|
||||
->where('ticket_id', $this->ticket->getKey())
|
||||
->whereNotNull('ended_at')
|
||||
->sum('seconds');
|
||||
|
||||
$this->ticket->forceFill([
|
||||
'time_spent_minutes' => (int) ceil($totalSeconds / 60),
|
||||
])->save();
|
||||
|
||||
$this->activeTimer = null;
|
||||
$this->elapsedSeconds = 0;
|
||||
$this->description = '';
|
||||
$this->message = 'Timer ended and time was added to the ticket.';
|
||||
|
||||
$this->dispatch('timer-ended');
|
||||
$this->dispatch('helpdesk-ticket-time-log-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently elapsed timer duration.
|
||||
*/
|
||||
public function getElapsedTimeProperty(): string
|
||||
{
|
||||
return gmdate('H:i:s', $this->elapsedSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the current user has a running timer.
|
||||
*/
|
||||
public function getIsRunningProperty(): bool
|
||||
{
|
||||
return $this->activeTimer instanceof TicketTimeLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the timer view.
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-timer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the active timer state from the database.
|
||||
*/
|
||||
private function refreshActiveTimer(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user instanceof Authenticatable) {
|
||||
$this->activeTimer = null;
|
||||
$this->elapsedSeconds = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activeTimer = $this->activeUserTimerQuery($user)->first();
|
||||
|
||||
$this->elapsedSeconds = $this->activeTimer instanceof TicketTimeLog
|
||||
? max(0, (int) $this->activeTimer->started_at->diffInSeconds(Carbon::now()))
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the active timer query for the authenticated user and current ticket.
|
||||
*/
|
||||
private function activeUserTimerQuery(Authenticatable $user): Builder
|
||||
{
|
||||
return TicketTimeLog::query()
|
||||
->where('ticket_id', $this->ticket->getKey())
|
||||
->where('user_id', $user->getAuthIdentifier())
|
||||
->whereNull('ended_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated user or fail validation cleanly.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function authenticatedUser(): Authenticatable
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user instanceof Authenticatable) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'You must be logged in to use the timer.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the safe timer description.
|
||||
*/
|
||||
private function normalisedDescription(): string
|
||||
{
|
||||
$description = trim($this->description);
|
||||
|
||||
return $description !== '' ? $description : 'Timer entry';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user