diff --git a/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php new file mode 100644 index 00000000..37446b50 --- /dev/null +++ b/Modules/Helpdesk/Http/Controllers/AttachmentDownloadController.php @@ -0,0 +1,54 @@ +where('id', $attachment)->first(); + + abort_if($row === null, 404); + + $ticketId = $row->ticket_id ?? null; + + if ($ticketId !== null) { + $ticket = Ticket::query()->findOrFail((int) $ticketId); + Gate::authorize('view', $ticket); + } + + $disk = (string) ($row->disk ?? 'public'); + $path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? ''); + $name = (string) ($row->original_name + ?? $row->original_filename + ?? $row->file_name + ?? $row->filename + ?? basename($path)); + + abort_if($path === '', 404); + abort_unless(Storage::disk($disk)->exists($path), 404); + + return Storage::disk($disk)->download($path, $name); + } +} diff --git a/Modules/Helpdesk/Livewire/TicketEdit.php b/Modules/Helpdesk/Livewire/TicketEdit.php index 907cf827..67c84b17 100644 --- a/Modules/Helpdesk/Livewire/TicketEdit.php +++ b/Modules/Helpdesk/Livewire/TicketEdit.php @@ -209,37 +209,40 @@ final class TicketEdit extends Component } /** - * Return assignable users. Falls back to all tenant users when helpdesk flags/roles are absent. + * 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 tenant/company users, then fall back to all users. * * @return EloquentCollection */ private function agentOptions(): EloquentCollection { - return User::query() - ->select(['id', 'name', 'email', 'company_id']) - ->when($this->company_id !== null && Schema::hasColumn('users', 'company_id'), function ($query): void { - $query->where(function ($query): void { + $columns = ['id', 'name', 'email']; + + if (Schema::hasColumn('users', 'company_id')) { + $columns[] = 'company_id'; + } + + $query = User::query()->select($columns)->orderBy('name'); + + if ($this->company_id !== null && Schema::hasColumn('users', 'company_id')) { + $tenantUsers = (clone $query) + ->where(function ($query): void { $query->where('company_id', $this->company_id) ->orWhereNull('company_id'); - }); - }) - ->when(Schema::hasColumn('users', 'helpdesk_agent') || Schema::hasColumn('users', 'helpdesk_admin'), function ($query): void { - $query->where(function ($query): void { - if (Schema::hasColumn('users', 'helpdesk_agent')) { - $query->orWhere('helpdesk_agent', true); - } + }) + ->when($this->agent_id !== null, function ($query): void { + $query->orWhereKey($this->agent_id); + }) + ->get(); - if (Schema::hasColumn('users', 'helpdesk_admin')) { - $query->orWhere('helpdesk_admin', true); - } + if ($tenantUsers->isNotEmpty()) { + return $tenantUsers; + } + } - if ($this->agent_id !== null) { - $query->orWhereKey($this->agent_id); - } - }); - }) - ->orderBy('name') - ->get(); + return $query->get(); } private function freshTicket(Ticket $ticket): Ticket diff --git a/Modules/Helpdesk/Livewire/TicketIndex.php b/Modules/Helpdesk/Livewire/TicketIndex.php index 66439984..2584ac92 100644 --- a/Modules/Helpdesk/Livewire/TicketIndex.php +++ b/Modules/Helpdesk/Livewire/TicketIndex.php @@ -63,6 +63,7 @@ final class TicketIndex extends Component return Ticket::query() ->with(['owner', 'agent', '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')) diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php index 2a8c351c..6aa2a267 100644 --- a/Modules/Helpdesk/Livewire/TicketShow.php +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -9,6 +9,8 @@ use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; use Illuminate\Support\Facades\Schema; use Livewire\Attributes\Validate; use Livewire\Component; @@ -135,55 +137,124 @@ final class TicketShow extends Component $this->ticket = $this->freshTicket($this->ticket); } + /** + * Persist the selected ticket status using a direct table update so Livewire UI wrappers cannot silently fail. + */ public function updateStatus(WorkflowNotificationService $workflow, SlaService $sla): void { $this->authorize('update', $this->ticket); - $this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]); - $status = Status::query()->findOrFail($this->status_id); - $this->ticket->forceFill([ + $statusId = $this->normaliseNullableInt($this->status_id); + + validator( + ['status_id' => $statusId], + ['status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')]], + )->validate(); + + $status = Status::query()->findOrFail($statusId); + + $updates = [ 'status_id' => $status->id, 'completed_at' => $status->is_closed ? now() : null, 'last_activity_at' => now(), - ])->save(); + ]; + + if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) { + $updates['updated_at'] = now(); + } + + DB::table('helpdesk_tickets') + ->where('id', $this->ticket->id) + ->update($updates); + + $this->ticket = $this->freshTicket($this->ticket); + $this->status_id = (int) $this->ticket->status_id; if ($status->is_closed) { $sla->recordClosure($this->ticket); } - $this->ticket = $this->freshTicket($this->ticket); $workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user()); - $this->dispatch('helpdesk-status-updated'); + + session()->flash('ticket-action-updated', 'Status updated to '.$this->ticket->status?->name.'.'); + $this->dispatch('helpdesk-status-updated', statusId: $this->status_id); } + /** + * Persist the selected ticket priority using a direct table update. + */ public function updatePriority(WorkflowNotificationService $workflow): void { $this->authorize('update', $this->ticket); - $this->validate(['priority_id' => ['required', 'integer', 'exists:helpdesk_priorities,id']]); - $this->ticket->forceFill([ - 'priority_id' => $this->priority_id, + $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(), - ])->save(); + ]; + + 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()); - $this->dispatch('helpdesk-priority-updated'); + + session()->flash('ticket-action-updated', 'Priority updated to '.$this->ticket->priority?->name.'.'); + $this->dispatch('helpdesk-priority-updated', priorityId: $this->priority_id); } + /** + * Persist the selected ticket assignee. + */ public function updateAgent(WorkflowNotificationService $workflow): void { $this->authorize('update', $this->ticket); - $this->validate(['agent_id' => ['nullable', 'integer', 'exists:users,id']]); - $this->ticket->forceFill([ - 'agent_id' => $this->agent_id, + $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(), - ])->save(); + ]; + + 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()); - $this->dispatch('helpdesk-agent-updated'); + + 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 render(): View @@ -196,6 +267,18 @@ final class TicketShow extends Component ])->layout('layouts.app'); } + /** + * Convert Livewire select values into a nullable integer. + */ + 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 { $ticket = $ticket->fresh([ @@ -228,45 +311,42 @@ final class TicketShow extends Component } /** - * Return assignable users. Falls back to all tenant users when helpdesk flags/roles are absent. + * 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 tenant/company users, then fall back to all users. * * @return EloquentCollection */ private function agentOptions(): EloquentCollection { - return User::query() - ->select(['id', 'name', 'email', 'company_id']) - ->when($this->ticket->company_id !== null && Schema::hasColumn('users', 'company_id'), function ($query): void { - $query->where(function ($query): void { + $columns = ['id', 'name', 'email']; + + if (Schema::hasColumn('users', 'company_id')) { + $columns[] = 'company_id'; + } + + $query = User::query()->select($columns)->orderBy('name'); + + if ($this->ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) { + $tenantUsers = (clone $query) + ->where(function ($query): void { $query->where('company_id', $this->ticket->company_id) ->orWhereNull('company_id'); - }); - }) - ->when(Schema::hasColumn('users', 'helpdesk_agent') || Schema::hasColumn('users', 'helpdesk_admin'), function ($query): void { - $query->where(function ($query): void { - if (Schema::hasColumn('users', 'helpdesk_agent')) { - $query->orWhere('helpdesk_agent', true); - } + }) + ->when($this->agent_id !== null, function ($query): void { + $query->orWhereKey($this->agent_id); + }) + ->get(); - if (Schema::hasColumn('users', 'helpdesk_admin')) { - $query->orWhere('helpdesk_admin', true); - } + if ($tenantUsers->isNotEmpty()) { + return $tenantUsers; + } + } - if ($this->agent_id !== null) { - $query->orWhereKey($this->agent_id); - } - }); - }) - ->orderBy('name') - ->get(); + return $query->get(); } - /** - * Build an unlimited nested comment tree from a flat eager-loaded collection. - * - * @param Collection> $grouped - * @return Collection - */ private function buildCommentTree(Collection $grouped, ?int $parentId = null): Collection { return $grouped->get($parentId, collect())->map(function ($comment) use ($grouped) { diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php index 043d5b6e..c95f722a 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-edit.blade.php @@ -381,6 +381,7 @@ Uploading attachments... + @if ($ticket->attachments->isNotEmpty())

@@ -396,10 +397,10 @@

- @forelse ($ticket->attachments as $attachment) + @foreach ($ticket->attachments as $attachment) - @empty -
- No ticket-level attachments are currently saved. -
- @endforelse + @endforeach
+ @endif @if ($attachments !== [])
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php index 4c862d5f..408acddb 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php @@ -198,6 +198,7 @@ Status Priority Agent + Files Last activity @@ -216,11 +217,25 @@ {{ $ticket->status->name }} {{ $ticket->priority->name }} {{ $ticket->agent?->name ?? 'Unassigned' }} + + @if (($ticket->attachments_count ?? 0) > 0) + + + {{ $ticket->attachments_count }} + attachment{{ $ticket->attachments_count === 1 ? '' : 's' }} + + @else + + + No attachments + + @endif + {{ $ticket->last_activity_at?->diffForHumans() ?? $ticket->created_at->diffForHumans() }} @empty - No tickets matched your filters. + No tickets matched your filters. @endforelse diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php index 71feb4df..61fc003f 100644 --- a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -37,6 +37,7 @@
+ @if ($ticket->attachments->isNotEmpty())
+ @endif

Conversation

@@ -204,37 +202,65 @@ @can('update', $ticket)
-

Agent actions

- - - Update assigned agent +
+

Ticket controls

+

Assignment, status and priority

+

Use the explicit update buttons below to save each change.

+
- - @foreach ($statuses as $status) - - @endforeach - - Update status -
- + @if (session('ticket-action-updated')) +
+ {{ session('ticket-action-updated') }} +
+ @endif + +
+ + + @error('agent_id')

{{ $message }}

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

{{ $message }}

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

{{ $message }}

@enderror + +
@endcan diff --git a/Modules/Helpdesk/Routes/web.php b/Modules/Helpdesk/Routes/web.php index 7375fff7..037fd28f 100644 --- a/Modules/Helpdesk/Routes/web.php +++ b/Modules/Helpdesk/Routes/web.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; +use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController; use Modules\Helpdesk\Http\Controllers\HelpdeskController; use Modules\Helpdesk\Http\Controllers\TicketStoreController; use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; @@ -25,3 +26,6 @@ Route::post('/tickets', TicketStoreController::class)->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'); +Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) + ->whereNumber('attachment') + ->name('attachments.download');