fixe duploads and attachments now with colors tab and enhancements

This commit is contained in:
Mark Gregory
2026-06-16 20:02:18 +01:00
parent 30471e542c
commit fc3371ea77
12 changed files with 900 additions and 69 deletions
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\Rule;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\SlaService;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class TicketControlsController extends Controller
{
/**
* Persist the assigned agent from the ticket detail controls.
*/
public function updateAgent(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
{
Gate::authorize('update', $ticket);
$validated = $request->validate([
'agent_id' => [
'nullable',
'integer',
Rule::exists('users', 'id')->where(function ($query) use ($ticket): void {
if ($ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) {
$query->where(function ($query) use ($ticket): void {
$query->where('company_id', $ticket->company_id)
->orWhereNull('company_id');
});
}
}),
],
]);
$agentId = $this->nullableInt($validated['agent_id'] ?? null);
$this->updateTicket($ticket, [
'agent_id' => $agentId,
'last_activity_at' => now(),
]);
$fresh = $ticket->fresh(['agent.company', 'owner.company', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $ticket;
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
return redirect()
->route('helpdesk.tickets.show', $fresh)
->with('ticket-action-updated', $agentId === null
? 'Ticket is now unassigned.'
: 'Ticket assigned to '.($fresh->agent?->name ?? 'selected agent').'.');
}
/**
* Persist the selected ticket status from the ticket detail controls.
*/
public function updateStatus(Request $request, Ticket $ticket, WorkflowNotificationService $workflow, SlaService $sla): RedirectResponse
{
Gate::authorize('update', $ticket);
$validated = $request->validate([
'status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')],
]);
$status = Status::query()->findOrFail((int) $validated['status_id']);
$this->updateTicket($ticket, [
'status_id' => $status->id,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
]);
$this->ensureBoardContainsStatus($ticket, (int) $status->id);
$fresh = $ticket->fresh(['agent.company', 'owner.company', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $ticket;
if ($status->is_closed) {
$sla->recordClosure($fresh);
}
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
return redirect()
->route('helpdesk.tickets.show', $fresh)
->with('ticket-action-updated', 'Status updated to '.($fresh->status?->name ?? 'selected status').'.');
}
/**
* Persist the selected ticket priority from the ticket detail controls.
*/
public function updatePriority(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
{
Gate::authorize('update', $ticket);
$validated = $request->validate([
'priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')],
]);
$this->updateTicket($ticket, [
'priority_id' => (int) $validated['priority_id'],
'last_activity_at' => now(),
]);
$fresh = $ticket->fresh(['agent.company', 'owner.company', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $ticket;
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
return redirect()
->route('helpdesk.tickets.show', $fresh)
->with('ticket-action-updated', 'Priority updated to '.($fresh->priority?->name ?? 'selected priority').'.');
}
/**
* Apply a direct guarded ticket update without relying on Livewire hydration.
*
* @param array<string, mixed> $updates
*/
private function updateTicket(Ticket $ticket, array $updates): void
{
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
$updates['updated_at'] = now();
}
DB::table('helpdesk_tickets')
->where('id', $ticket->id)
->update($updates);
}
/**
* Keep the active Kanban board usable when a ticket is moved to a status not yet assigned to the board.
*/
private function ensureBoardContainsStatus(Ticket $ticket, int $statusId): void
{
if ($ticket->board_id === null || ! Schema::hasTable('helpdesk_kanban_board_statuses')) {
return;
}
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
['board_id' => $ticket->board_id, 'status_id' => $statusId],
['updated_at' => now(), 'created_at' => now()],
);
}
/**
* Convert form select values to nullable integers.
*/
private function nullableInt(mixed $value): ?int
{
if ($value === null || $value === '' || $value === '0' || $value === 0) {
return null;
}
return (int) $value;
}
}
+3 -3
View File
@@ -233,7 +233,7 @@ final class TicketEdit extends Component
->orWhereNull('company_id');
})
->when($this->agent_id !== null, function ($query): void {
$query->orWhereKey($this->agent_id);
$query->orWhere('id', $this->agent_id);
})
->get();
@@ -248,8 +248,8 @@ final class TicketEdit extends Component
private function freshTicket(Ticket $ticket): Ticket
{
return $ticket->fresh([
'owner',
'agent',
'owner.company',
'agent.company',
'status',
'priority',
'category',
+10 -40
View File
@@ -41,11 +41,11 @@ final class TicketShow extends Component
/** @var array<int, TemporaryUploadedFile> */
public array $attachments = [];
public int|string|null $status_id = null;
public ?int $status_id = null;
public int|string|null $priority_id = null;
public ?int $priority_id = null;
public int|string|null $agent_id = null;
public ?int $agent_id = null;
public ?int $reply_parent_id = null;
@@ -225,7 +225,10 @@ final class TicketShow extends Component
$agentId = $this->normaliseNullableInt($this->agent_id);
$this->validateAssignableAgent($agentId);
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.');
@@ -279,8 +282,8 @@ final class TicketShow extends Component
private function freshTicket(Ticket $ticket): Ticket
{
$ticket = $ticket->fresh([
'owner',
'agent',
'owner.company',
'agent.company',
'status',
'priority',
'category',
@@ -307,39 +310,6 @@ final class TicketShow extends Component
return $ticket;
}
/**
* Validate that the selected agent exists and belongs to this ticket's company context.
*/
private function validateAssignableAgent(?int $agentId): void
{
if ($agentId === null) {
return;
}
$rule = Rule::exists('users', 'id');
if ($this->ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) {
$ticketCompanyId = (int) $this->ticket->company_id;
$currentAgentId = $this->ticket->agent_id === null ? null : (int) $this->ticket->agent_id;
$rule->where(function ($query) use ($ticketCompanyId, $currentAgentId): void {
$query->where(function ($tenantQuery) use ($ticketCompanyId): void {
$tenantQuery->where('company_id', $ticketCompanyId)
->orWhereNull('company_id');
});
if ($currentAgentId !== null) {
$query->orWhere('id', $currentAgentId);
}
});
}
validator(
['agent_id' => $agentId],
['agent_id' => ['nullable', 'integer', $rule]],
)->validate();
}
/**
* Return assignable users for this ticket.
*
@@ -365,7 +335,7 @@ final class TicketShow extends Component
->orWhereNull('company_id');
})
->when($this->agent_id !== null, function ($query): void {
$query->orWhereKey($this->agent_id);
$query->orWhere('id', $this->agent_id);
})
->get();
@@ -202,10 +202,10 @@
@can('update', $ticket)
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="mb-4 rounded-2xl bg-gradient-to-r from-blue-700 via-indigo-700 to-violet-700 px-4 py-3 text-white shadow-sm">
<div class="mb-5 rounded-2xl bg-gradient-to-r from-blue-700 via-indigo-700 to-violet-700 px-4 py-4 text-white shadow-sm">
<p class="text-xs font-black uppercase tracking-[0.24em] text-blue-100">Ticket controls</p>
<h2 class="mt-1 text-base font-black tracking-tight">Assignment, status and priority</h2>
<p class="mt-1 text-xs font-semibold text-blue-100/90">Choose a value, then save the specific change.</p>
<h2 class="mt-1 text-lg font-black tracking-tight">Assignment, status and priority</h2>
<p class="mt-1 text-sm font-semibold text-blue-100/90">Use the explicit update buttons below to save each change.</p>
</div>
@if (session('ticket-action-updated'))
@@ -214,53 +214,53 @@
</div>
@endif
<div class="mb-5 space-y-2">
<form method="POST" action="{{ route('helpdesk.tickets.agent.update', $ticket) }}" class="mb-5 space-y-3">
@csrf
<label class="block text-sm font-bold text-zinc-700 dark:text-zinc-200" for="show_agent_id">Assigned agent</label>
<select id="show_agent_id" wire:model="agent_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-show-agent">
<option value="">Unassigned</option>
<select id="show_agent_id" name="agent_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 text-sm font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-show-agent">
<option value="" @selected($agent_id === null)>Unassigned</option>
@forelse ($agents as $agent)
<option value="{{ $agent->id }}">{{ $agent->name }} · {{ $agent->email }}</option>
<option value="{{ $agent->id }}" @selected((int) $agent_id === (int) $agent->id)>{{ $agent->name }} · {{ $agent->email }}</option>
@empty
<option value="" disabled>No users available for assignment</option>
@endforelse
</select>
@error('agent_id') <p class="text-sm font-bold text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
<button type="button" wire:click="updateAgent" dusk="update-agent" class="inline-flex w-full items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70 disabled:cursor-not-allowed disabled:opacity-70" wire:loading.attr="disabled" wire:target="updateAgent">
<button type="submit" dusk="update-agent" class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-user-check mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="updateAgent">Assign agent</span>
<span wire:loading wire:target="updateAgent">Assigning...</span>
<span>Assign agent</span>
</button>
</div>
</form>
<div class="mb-5 space-y-2">
<form method="POST" action="{{ route('helpdesk.tickets.status.update', $ticket) }}" class="mb-5 space-y-3">
@csrf
<label class="block text-sm font-bold text-zinc-700 dark:text-zinc-200" for="show_status_id">Status</label>
<select id="show_status_id" wire:model="status_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-show-status">
<select id="show_status_id" name="status_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-show-status">
@foreach ($statuses as $status)
<option value="{{ $status->id }}">{{ $status->name }}</option>
<option value="{{ $status->id }}" @selected((int) $status_id === (int) $status->id)>{{ $status->name }}</option>
@endforeach
</select>
@error('status_id') <p class="text-sm font-bold text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
<button type="button" wire:click="updateStatus" dusk="update-status" class="inline-flex w-full items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70 disabled:cursor-not-allowed disabled:opacity-70" wire:loading.attr="disabled" wire:target="updateStatus">
<button type="submit" dusk="update-status" class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
<i class="fa-solid fa-circle-check mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="updateStatus">Update status</span>
<span wire:loading wire:target="updateStatus">Updating...</span>
<span>Update status</span>
</button>
</div>
</form>
<div class="space-y-2">
<form method="POST" action="{{ route('helpdesk.tickets.priority.update', $ticket) }}" class="space-y-3">
@csrf
<label class="block text-sm font-bold text-zinc-700 dark:text-zinc-200" for="show_priority_id">Priority</label>
<select id="show_priority_id" wire:model="priority_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-900 shadow-sm focus:border-amber-500 focus:ring-amber-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-show-priority">
<select id="show_priority_id" name="priority_id" class="hd-select block w-full rounded-xl border border-zinc-300 bg-white p-2.5 text-sm font-semibold text-zinc-900 shadow-sm focus:border-amber-500 focus:ring-amber-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-show-priority">
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
<option value="{{ $priority->id }}" @selected((int) $priority_id === (int) $priority->id)>{{ $priority->name }}</option>
@endforeach
</select>
@error('priority_id') <p class="text-sm font-bold text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
<button type="button" wire:click="updatePriority" dusk="update-priority" class="inline-flex w-full items-center justify-center rounded-lg bg-amber-500 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-300/70 disabled:cursor-not-allowed disabled:opacity-70" wire:loading.attr="disabled" wire:target="updatePriority">
<button type="submit" dusk="update-priority" class="inline-flex items-center justify-center rounded-lg bg-amber-500 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-300/70">
<i class="fa-solid fa-flag mr-2" aria-hidden="true"></i>
<span wire:loading.remove wire:target="updatePriority">Update priority</span>
<span wire:loading wire:target="updatePriority">Updating...</span>
<span>Update priority</span>
</button>
</div>
</form>
</div>
@endcan
</aside>
+4
View File
@@ -6,6 +6,7 @@ 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\TicketControlsController;
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
use Modules\Helpdesk\Livewire\KanbanBoard;
use Modules\Helpdesk\Livewire\SettingsManager;
@@ -25,6 +26,9 @@ Route::get('/tickets', TicketIndex::class)->name('tickets.index');
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::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update');
Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update');
Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update');
Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class)
->whereNumber('attachment')
+14
View File
@@ -183,3 +183,17 @@ versions:
- Replaced native due date inputs on ticket create/edit with a reusable Helpdesk Flatpickr date-picker component.
- Added Flatpickr module dependency and dark Helpdesk calendar styling.
- Kept module.json version in sync with changelog.yaml.
"1.3.9":
changed:
- Fixed TicketControlsController authorization by using Laravel Gate authorization instead of relying on a missing controller authorize helper.
- Kept normal CSRF-protected POST ticket controls for agent, status and priority persistence.
- Restricted posted agent assignments to tenant/company users or global users when the users.company_id column exists.
- Kept compact ticket control buttons and module.json version in sync with changelog.yaml.
"1.3.10":
changed:
- Fixed the ticket detail and ticket edit agent options queries by replacing unsupported orWhereKey usage with supported id comparisons.
- Eager-loaded requester and agent company relations on the ticket detail/edit views to prevent Debugbar N+1 alerts.
- Kept agent, status and priority persistence on CSRF-protected POST routes with compact action buttons.
- Kept module.json version in sync with changelog.yaml.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.3.7"
"version": "1.3.10"
}