diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index fec8866d..d7d62491 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -8,6 +8,6 @@ return [ 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'helpdesk'), 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), 'allowed_mimes' => ['md','pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'], - 'default_status_slug' => 'open', + 'default_status_slug' => 'new', 'closed_status_slug' => 'closed', ]; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_12_124726_helpdesk_performance_indexes.php b/Modules/Helpdesk/Database/Migrations/2026_06_12_124726_helpdesk_performance_indexes.php index 014cb6aa..57ca0e30 100644 --- a/Modules/Helpdesk/Database/Migrations/2026_06_12_124726_helpdesk_performance_indexes.php +++ b/Modules/Helpdesk/Database/Migrations/2026_06_12_124726_helpdesk_performance_indexes.php @@ -1,56 +1,131 @@ index(['company_id', 'status_id']); - $table->index(['company_id', 'priority_id']); - $table->index(['company_id', 'category_id']); - $table->index(['company_id', 'project_id']); - $table->index(['company_id', 'team_id']); - $table->index(['agent_id', 'completed_at']); - $table->index(['user_id', 'created_at']); - $table->index(['status_id', 'created_at']); - $table->index(['last_activity_at']); - $table->index(['due_at']); - $table->index(['reference']); - }); + $this->addIndex('helpdesk_tickets', ['company_id', 'status_id'], 'hdesk_tickets_company_status_idx'); + $this->addIndex('helpdesk_tickets', ['company_id', 'priority_id'], 'hdesk_tickets_company_priority_idx'); + $this->addIndex('helpdesk_tickets', ['company_id', 'category_id'], 'hdesk_tickets_company_category_idx'); + $this->addIndex('helpdesk_tickets', ['company_id', 'team_id'], 'hdesk_tickets_company_team_idx'); + $this->addIndex('helpdesk_tickets', ['status_id', 'created_at'], 'hdesk_tickets_status_created_idx'); + $this->addIndex('helpdesk_tickets', ['due_at'], 'hdesk_tickets_due_at_idx'); + $this->addIndex('helpdesk_tickets', ['archived_at'], 'hdesk_tickets_archived_at_idx'); - Schema::table('helpdesk_comments', function (Blueprint $table): void { - $table->index(['ticket_id', 'parent_id']); - $table->index(['ticket_id', 'created_at']); - $table->index(['parent_id', 'created_at']); - $table->index(['user_id', 'created_at']); - }); + $this->addIndex('helpdesk_comments', ['parent_id', 'created_at'], 'hdesk_comments_parent_created_idx'); + $this->addIndex('helpdesk_comments', ['user_id', 'created_at'], 'hdesk_comments_user_created_idx'); - Schema::table('helpdesk_attachments', function (Blueprint $table): void { - $table->index(['ticket_id']); - $table->index(['comment_id']); - $table->index(['user_id']); - }); + $this->addIndex('helpdesk_attachments', ['ticket_id'], 'hdesk_attachments_ticket_idx'); + $this->addIndex('helpdesk_attachments', ['comment_id'], 'hdesk_attachments_comment_idx'); + $this->addIndex('helpdesk_attachments', ['user_id'], 'hdesk_attachments_user_idx'); - Schema::table('helpdesk_projects', function (Blueprint $table): void { - $table->index(['company_id', 'archived_at']); - }); + if (Schema::hasTable('helpdesk_projects')) { + $this->addIndex('helpdesk_projects', ['company_id', 'archived_at'], 'hdesk_projects_company_archived_idx'); + } - Schema::table('helpdesk_teams', function (Blueprint $table): void { - $table->index(['company_id', 'archived_at']); - }); - Schema::table('helpdesk_category_user', function (Blueprint $table): void { - $table->index(['category_id', 'user_id']); - $table->index(['user_id', 'category_id']); + if (Schema::hasTable('helpdesk_teams')) { + $this->addIndex('helpdesk_teams', ['company_id', 'archived_at'], 'hdesk_teams_company_archived_idx'); + } + + if (Schema::hasTable('helpdesk_category_user')) { + $this->addIndex('helpdesk_category_user', ['category_id', 'user_id'], 'hdesk_category_user_category_user_idx'); + $this->addIndex('helpdesk_category_user', ['user_id', 'category_id'], 'hdesk_category_user_user_category_idx'); + } + } + + /** @return void */ + public function down(): void + { + foreach ([ + 'helpdesk_tickets' => [ + 'hdesk_tickets_company_status_idx', + 'hdesk_tickets_company_priority_idx', + 'hdesk_tickets_company_category_idx', + 'hdesk_tickets_company_team_idx', + 'hdesk_tickets_status_created_idx', + 'hdesk_tickets_due_at_idx', + 'hdesk_tickets_archived_at_idx', + ], + 'helpdesk_comments' => [ + 'hdesk_comments_parent_created_idx', + 'hdesk_comments_user_created_idx', + ], + 'helpdesk_attachments' => [ + 'hdesk_attachments_ticket_idx', + 'hdesk_attachments_comment_idx', + 'hdesk_attachments_user_idx', + ], + 'helpdesk_projects' => [ + 'hdesk_projects_company_archived_idx', + ], + 'helpdesk_teams' => [ + 'hdesk_teams_company_archived_idx', + ], + 'helpdesk_category_user' => [ + 'hdesk_category_user_category_user_idx', + 'hdesk_category_user_user_category_idx', + ], + ] as $table => $indexes) { + if (! Schema::hasTable($table)) { + continue; + } + + Schema::table($table, function (Blueprint $tableBlueprint) use ($table, $indexes): void { + foreach ($indexes as $index) { + if ($this->indexExists($table, $index)) { + $tableBlueprint->dropIndex($index); + } + } + }); + } + } + + /** + * Add an index only when the table exists and the named index does not exist. + * + * @param string $table + * @param array $columns + * @param string $index + * @return void + */ + private function addIndex(string $table, array $columns, string $index): void + { + if (! Schema::hasTable($table) || $this->indexExists($table, $index)) { + return; + } + + Schema::table($table, static function (Blueprint $tableBlueprint) use ($columns, $index): void { + $tableBlueprint->index($columns, $index); }); } -} + /** + * Determine whether an index exists for the current connection. + * + * @param string $table + * @param string $index + * @return bool + */ + private function indexExists(string $table, string $index): bool + { + if (method_exists(Schema::getFacadeRoot(), 'hasIndex')) { + return Schema::hasIndex($table, $index); + } + + if (DB::getDriverName() === 'pgsql') { + return DB::selectOne( + 'select 1 from pg_indexes where schemaname = current_schema() and tablename = ? and indexname = ? limit 1', + [$table, $index] + ) !== null; + } + + return false; + } +}; diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php new file mode 100644 index 00000000..85d6ca0d --- /dev/null +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -0,0 +1,129 @@ +authorize('update', $ticket); + $this->ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'agent']) ?? $ticket; + + $this->company_id = $this->ticket->company_id; + $this->agent_id = $this->ticket->agent_id; + $this->subject = $this->ticket->subject; + $this->body = $this->ticket->body; + $this->status_id = $this->ticket->status_id; + $this->priority_id = $this->ticket->priority_id; + $this->category_id = $this->ticket->category_id; + $this->project_id = $this->ticket->project_id; + $this->team_id = $this->ticket->team_id; + $this->due_at = $this->ticket->due_at?->format('Y-m-d'); + } + + /** + * Save ticket changes. + */ + public function save(WorkflowNotificationService $workflow): mixed + { + $this->authorize('update', $this->ticket); + $this->validate(); + + $status = Status::query()->findOrFail($this->status_id); + + $this->ticket->forceFill([ + 'company_id' => $this->company_id, + 'agent_id' => $this->agent_id, + 'subject' => $this->subject, + 'body' => $this->body, + 'status_id' => $status->id, + 'priority_id' => $this->priority_id, + 'category_id' => $this->category_id, + 'project_id' => $this->project_id, + 'team_id' => $this->team_id, + 'due_at' => $this->due_at ?: null, + 'completed_at' => $status->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + $this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $this->ticket; + $workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user()); + + session()->flash('success', 'Ticket ' . $this->ticket->reference . ' has been updated.'); + + return redirect()->route('helpdesk.tickets.show', $this->ticket); + } + + /** + * Render the ticket edit screen. + */ + public function render(): View + { + return view('helpdesk::livewire.ticket-edit', [ + 'statuses' => Status::query()->orderBy('sort_order')->get(), + 'priorities' => Priority::query()->orderBy('sort_order')->get(), + 'categories' => Category::query()->orderBy('name')->get(), + 'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(), + 'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(), + 'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(), + 'agents' => User::query() + ->where(static function ($query): void { + $query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true); + }) + ->orderBy('name') + ->get(['id', 'name', 'email']), + ])->layout('layouts.app'); + } +} diff --git a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php index 7ab847d1..1fdd6e8d 100644 --- a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -1,23 +1,16 @@ -
+

Helpdesk workflow

Kanban board

-

- Move tickets through your configured statuses, keep priorities visible and trigger workflow notifications when work changes state. -

+

Drag tickets between configured statuses. Add, rename and order boards from Helpdesk Settings → Statuses.

-
- - New ticket - - - - List view - + New ticket + Configure boards + List view
@@ -29,130 +22,120 @@
@endif -
+
@foreach ($statuses as $status) @php($tickets = $ticketsByStatus[$status->id] ?? collect())
-
-
-

{{ $status->name }}

-

{{ $tickets->count() }} tickets

+
+
+
+
+ +

{{ $status->name }}

+
+

{{ $tickets->count() }} tickets

+
+ @if ($status->is_closed) + Closed + @endif
- -
-
+
@forelse ($tickets as $ticket)
-
- - {{ $ticket->reference }} - - -

- {{ $ticket->subject }} -

+
+
+ {{ $ticket->reference }} + ⋮⋮ +
+

{{ $ticket->subject }}

- @if ($ticket->priority) - - {{ $ticket->priority->name }} - + {{ $ticket->priority->name }} @endif
Owner: {{ $ticket->owner?->name ?? 'Unknown' }}
- +
Agent: {{ $ticket->agent?->name ?? 'Unassigned' }}
@if ($ticket->project)
Project: {{ $ticket->project->title }}
@endif - @if ($ticket->team)
Team: {{ $ticket->team->name }}
@endif
-
- @foreach ($statuses as $targetStatus) - @continue($targetStatus->id === $status->id) - - - @endforeach +
+ Open + @can('update', $ticket) + Edit + @endcan
@empty
- No tickets in {{ $status->name }}. + Drop tickets here to move them to {{ $status->name }}.
@endforelse
@endforeach
- - @script - - @endscript
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php deleted file mode 100644 index 1a2a134d..00000000 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php +++ /dev/null @@ -1,105 +0,0 @@ -
- @include('helpdesk::partials.page-header', [ - 'eyebrow' => 'Helpdesk', - 'title' => 'Create Ticket', - 'subtitle' => 'Raise a new support request with priority, project, category, team and full details.', - ]) - -
- @csrf - -
-
-

Ticket details

-

Capture the request clearly so the support workflow can route and notify correctly.

-
- -
-
- - - @error('subject')

{{ $message }}

@enderror -
- - @if($companies->isNotEmpty()) -
- - - @error('company_id')

{{ $message }}

@enderror -
- @endif - -
- - - @error('priority_id')

{{ $message }}

@enderror -
- -
- - - @error('category_id')

{{ $message }}

@enderror -
- -
- - - @error('project_id')

{{ $message }}

@enderror -
- -
- - - @error('team_id')

{{ $message }}

@enderror -
- -
- - - @error('due_at')

{{ $message }}

@enderror -
- -
- -
- -
- - - @error('attachments')

{{ $message }}

@enderror - @error('attachments.*')

{{ $message }}

@enderror -
-
- -
- Cancel - -
-
-
-
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php new file mode 100644 index 00000000..ed44be7d --- /dev/null +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php @@ -0,0 +1,116 @@ +
+ @include('helpdesk::partials.page-header', [ + 'eyebrow' => 'Helpdesk', + 'title' => 'Edit Ticket', + 'subtitle' => 'Update assignment, workflow status, priority, project, team and ticket content.', + ]) + +
+
+
+

Ticket information

+

Changes are applied to the ticket and workflow notifications are triggered where configured.

+
+ +
+
+ + + @error('subject')

{{ $message }}

@enderror +
+ + @if($companies->isNotEmpty()) +
+ + + @error('company_id')

{{ $message }}

@enderror +
+ @endif + +
+ + + @error('agent_id')

{{ $message }}

@enderror +
+ +
+ + + @error('status_id')

{{ $message }}

@enderror +
+ +
+ + + @error('priority_id')

{{ $message }}

@enderror +
+ +
+ + + @error('category_id')

{{ $message }}

@enderror +
+ +
+ + + @error('project_id')

{{ $message }}

@enderror +
+ +
+ + + @error('team_id')

{{ $message }}

@enderror +
+ +
+ + + @error('due_at')

{{ $message }}

@enderror +
+ +
+ +
+
+ +
+ Back + +
+
+
+
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php index f16873ec..901762d1 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -11,7 +11,12 @@

{{ $ticket->subject }}

Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}

- Back +
+ Back + @can('update', $ticket) + Edit + @endcan +
@@ -102,12 +107,22 @@ @can('update', $ticket)

Agent actions

+ + + @foreach ($agents as $agent) + + @endforeach + + Update assignment + +
@foreach ($statuses as $status) @endforeach Update status +
@foreach ($priorities as $priority) diff --git a/Modules/Helpdesk/Routes/web.php b/Modules/Helpdesk/Routes/web.php index 1d658a18..25deacdf 100644 --- a/Modules/Helpdesk/Routes/web.php +++ b/Modules/Helpdesk/Routes/web.php @@ -8,6 +8,7 @@ use Modules\Helpdesk\Http\Controllers\TicketStoreController; use Modules\Helpdesk\Livewire\KanbanBoard; use Modules\Helpdesk\Livewire\SettingsManager; use Modules\Helpdesk\Livewire\TicketCreate; +use Modules\Helpdesk\Livewire\TicketEdit; use Modules\Helpdesk\Livewire\TicketIndex; use Modules\Helpdesk\Livewire\TicketShow; @@ -19,4 +20,5 @@ Route::get('/kanban', KanbanBoard::class)->name('kanban'); Route::get('/tickets', TicketIndex::class)->name('tickets.index'); Route::post('/tickets', [TicketStoreController::class, 'store'])->name('tickets.store'); Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); +Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit'); Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); diff --git a/Modules/Helpdesk/Services/TicketService.php b/Modules/Helpdesk/Services/TicketService.php index ecd90fc2..cc3c4118 100644 --- a/Modules/Helpdesk/Services/TicketService.php +++ b/Modules/Helpdesk/Services/TicketService.php @@ -30,16 +30,15 @@ final class TicketService { return DB::transaction(function () use ($user, $data, $files): Ticket { $status = Status::query() - ->where('slug', config('helpdesk.default_status_slug', 'open')) + ->where('slug', config('helpdesk.default_status_slug', 'new')) ->first(); if (! $status) { $status = Status::query()->create([ - 'name' => 'Open', - 'slug' => config('helpdesk.default_status_slug', 'open'), - 'colour' => '#2563eb', - 'sort_order' => 1, - 'is_default' => true, + 'name' => 'New', + 'slug' => config('helpdesk.default_status_slug', 'new'), + 'color' => 'sky', + 'sort_order' => 5, 'is_closed' => false, ]);