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"
}
@@ -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', 'owner', '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', 'owner', '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', 'owner', '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;
}
}
@@ -0,0 +1,268 @@
@vite([
'resources/css/app.css',
'resources/js/app.js',
])
<div class="hd-helpdesk-surface space-y-6" dusk="helpdesk-ticket-show">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span class="font-mono text-xs text-blue-100">{{ $ticket->reference }}</span>
<flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge>
<flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge>
</div>
<h1 class="text-3xl font-semibold tracking-tight">{{ $ticket->subject }}</h1>
<p class="mt-2 text-sm text-blue-100/90">Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="inline-flex items-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
<i class="fa-solid fa-arrow-left mr-2" aria-hidden="true"></i> Back
</a>
@can('update', $ticket)
<a href="{{ route('helpdesk.tickets.edit', $ticket) }}" wire:navigate class="inline-flex items-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-700">
<i class="fa-solid fa-pen-to-square mr-2" aria-hidden="true"></i> Edit
</a>
@endcan
</div>
</div>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<article class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="prose max-w-none dark:prose-invert">
{!! $ticket->body !!}
</div>
</article>
@if ($ticket->attachments->isNotEmpty())
<div class="overflow-hidden rounded-2xl border border-blue-200 bg-white shadow-sm dark:border-blue-900/60 dark:bg-zinc-900">
<div class="bg-gradient-to-r from-blue-700 via-sky-600 to-emerald-600 px-6 py-4 text-white">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="flex items-center gap-2 text-base font-extrabold text-white drop-shadow-sm">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-white/15 ring-1 ring-white/25">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
</span>
Ticket attachments
</h2>
<p class="mt-1 text-sm font-medium text-blue-50/90">
Supporting screenshots, documents and logs linked to this ticket.
</p>
</div>
<span class="inline-flex items-center gap-2 rounded-full bg-emerald-100 px-3 py-1 text-xs font-extrabold text-emerald-800 shadow-sm ring-1 ring-white/70">
<span class="inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>{{ $ticket->attachments->count() }} file(s)
</span>
</div>
</div>
<div class="grid gap-3 p-6 sm:grid-cols-2">
@foreach ($ticket->attachments as $attachment)
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank" class="rounded-xl border border-zinc-200 p-3 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $attachment->original_name ?? $attachment->original_filename ?? $attachment->filename ?? $attachment->file_name ?? basename($attachment->path ?? $attachment->file_path ?? '') }}</div>
<div class="text-xs text-zinc-500">{{ Number::fileSize($attachment->size ?? $attachment->size_bytes ?? 0) }}</div>
</a>
@endforeach
</div>
</div>
@endif
<section class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Conversation</h2>
@if ($ticket->comments->isNotEmpty())
@include('helpdesk::partials.comment-thread', ['comments' => $ticket->comments, 'level' => 0])
@else
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">No responses yet.</div>
@endif
</section>
<form wire:submit="addComment" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
@if ($cannedResponses->isNotEmpty())
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
<flux:select wire:model="canned_response_id" label="Canned response">
<option value="">Choose a saved response...</option>
@foreach ($cannedResponses as $response)
<option value="{{ $response->id }}">{{ $response->name }}</option>
@endforeach
</flux:select>
<div class="flex items-end">
<flux:button type="button" wire:click="applyCannedResponse" variant="ghost" icon="document-duplicate" class="!bg-blue-600 !text-white hover:!bg-blue-700 focus:!ring-blue-400/60">Insert response</flux:button>
</div>
</div>
@endif
<x-helpdesk::html-editor dusk="ticket-comment-editor" wire:model="commentBody" label="Comment" rows="5" placeholder="Write a professional response..." />
@can('update', $ticket)
<div class="mt-4">
<flux:checkbox wire:model="internal" label="Internal note only" />
</div>
@endcan
<div class="mt-4">
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Attach files</label>
<input wire:model="attachments" type="file" multiple class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" />
@error('attachments.*') <div class="mt-2 text-sm text-red-600">{{ $message }}</div> @enderror
</div>
<div class="mt-5 flex justify-end">
<flux:button dusk="post-comment" type="submit" variant="primary" icon="chat-bubble-left-right" class="!bg-emerald-600 !text-white hover:!bg-emerald-700 focus:!ring-emerald-400/60" wire:loading.attr="disabled">
<span wire:loading.remove>Post reply</span>
<span wire:loading>Posting...</span>
</flux:button>
</div>
</form>
</div>
<aside class="space-y-6">
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket details</h2>
<dl class="space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Product/service</dt><dd>{{ $ticket->product?->name ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Team</dt><dd>{{ $ticket->team?->name ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Created</dt><dd>{{ $ticket->created_at->format('d M Y H:i') }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Due</dt><dd>{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}</dd></div>
</dl>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">SLA metrics</h2>
<div class="grid gap-3 text-sm">
<div class="rounded-xl border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' }} p-3">
<div class="text-xs font-semibold uppercase tracking-wide">First response</div>
<div class="mt-1">{{ $ticket->first_response_at?->format('d M Y H:i') ?? 'Awaiting response' }}</div>
<div class="mt-1 text-xs opacity-75">Due {{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
</div>
<div class="rounded-xl border {{ $ticket->sla_resolution_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100' }} p-3">
<div class="text-xs font-semibold uppercase tracking-wide">Resolution</div>
<div class="mt-1">{{ $ticket->completed_at?->format('d M Y H:i') ?? 'Open' }}</div>
<div class="mt-1 text-xs opacity-75">Due {{ $ticket->sla_resolution_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
</div>
<div class="grid grid-cols-2 gap-2 text-center">
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><div class="text-xl font-semibold">{{ $ticket->public_replies_count }}</div><div class="text-xs text-zinc-500">Public replies</div></div>
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><div class="text-xl font-semibold">{{ $ticket->time_spent_minutes }}</div><div class="text-xs text-zinc-500">Minutes logged</div></div>
</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Requester profile</h2>
<dl class="space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Name</dt><dd>{{ $ticket->owner?->name ?? 'Unknown' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Email</dt><dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Company</dt><dd>{{ $ticket->owner?->company?->name ?? 'Current company' }}</dd></div>
</dl>
@if ($ticket->tags->isNotEmpty())
<div class="mt-4 flex flex-wrap gap-2">
@foreach ($ticket->tags as $tag)
<span class="rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
@endforeach
</div>
@endif
</div>
@if ($ticket->checklistItems->isNotEmpty())
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
@php($completedChecks = $ticket->checklistItems->where('is_completed', true)->count())
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="text-base font-semibold text-zinc-900 dark:text-white">Checklist</h2>
<flux:badge color="blue">{{ $completedChecks }}/{{ $ticket->checklistItems->count() }}</flux:badge>
</div>
<div class="space-y-2">
@foreach ($ticket->checklistItems as $item)
<label class="flex items-start gap-3 rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700">
<input type="checkbox" wire:click="toggleChecklistItem({{ $item->id }})" @checked($item->is_completed) class="mt-1 rounded border-zinc-300">
<span class="{{ $item->is_completed ? 'text-zinc-500 line-through' : 'text-zinc-800 dark:text-zinc-100' }}">{{ $item->title }}</span>
</label>
@endforeach
</div>
</div>
@endif
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Git commits</h2>
<div class="space-y-2">
@forelse ($ticket->gitCommits as $commit)
<a href="{{ $commit->url ?: '#' }}" target="_blank" class="block rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
<div class="mt-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $commit->message }}</div>
</a>
@empty
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">No commits linked.</div>
@endforelse
</div>
</div>
@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-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-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'))
<div class="mb-5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-bold text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
{{ session('ticket-action-updated') }}
</div>
@endif
<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" 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 }}" @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="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>Assign agent</span>
</button>
</form>
<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" 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 }}" @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="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>Update status</span>
</button>
</form>
<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" 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 }}" @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="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>Update priority</span>
</button>
</form>
</div>
@endcan
</aside>
</div>
</div>
+35
View File
@@ -0,0 +1,35 @@
<?php
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\TicketControlsController;
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
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;
Route::get('/workflow/open/{token}', WorkflowOpenController::class)->name('workflow.open');
Route::get('/', HelpdeskController::class)->name('index');
Route::get('/settings', SettingsManager::class)->name('settings');
Route::get('/kanban', KanbanBoard::class)->name('kanban');
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')
->name('attachments.download');
+192
View File
@@ -0,0 +1,192 @@
- version: 1.3.6
date: 2026-06-16
changes:
- Backfilled status columns onto Kanban boards that have none so selected boards render usable workflow columns.
- Added runtime board-column repair to keep newly-created boards usable immediately.
- Tightened the Kanban board selector width and dark styling.
- version: 1.3.5
date: 2026-06-16
changes:
- Fixed Kanban board filtering so the selected board only shows tickets assigned to that single board.
- Added a backfill migration for existing tickets without a board assignment.
- Restyled the active board selector and per-ticket board selector with compact dark controls.
- version: 1.3.4
date: 2026-06-16
changes:
- Added single-board ticket assignment via helpdesk_tickets.board_id.
- Added Kanban board owner support.
- Added board-to-board ticket moves while enforcing one active board per ticket.
- Added status move alerts/toasts when tickets move between workflow statuses.
- Added create/edit ticket board selection and default-board assignment for new tickets.
- version: 1.3.3
date: '2026-06-16'
changes:
- Reworked Helpdesk action styling so header, support, settings, table, save, reset, edit and delete buttons use reliable coloured native styling.
- Replaced settings section card dynamic Tailwind colour classes with inline styles so card colours always render.
- Improved Kanban board dropdown styling.
- Hardened ticket create inputs and Select2 controls to prevent white flashes during save.
url: https://wms.vm
versions:
"1.0.0":
changed:
- Initial Module Definition
"1.0.1":
changed:
- Added Vite Support and removed webpack
"1.0.2":
changed:
- Stabilised the Livewire rich text editor so ticket details no longer clear during typing or submit early.
- Added Helpdesk Kanban board for workflow status movement.
- Added Kanban PHPUnit and Dusk coverage.
- Added Kanban navigation links to the dashboard and ticket list.
"1.0.3":
changed:
- Fixed Helpdesk dashboard rendering by removing dependency on missing x-app-layout/x-layouts.app components.
- Registered anonymous Helpdesk Blade components so x-helpdesk component views resolve correctly.
- Registered the Kanban Livewire component in the Helpdesk service provider.
- Rebuilt the ticket create page to use the correct subject/body Livewire properties.
- Stabilised the rich HTML editor so typing no longer clears the field or accidentally submits the ticket form.
- Added root layout support-menu integration instructions for enabled Helpdesk modules.
"1.0.4":
changed:
- Fixed the Helpdesk page header so dark WMS pages do not render a blank white hero.
- Changed ticket create save to validate all fields and return to the tickets index after creation to avoid route-model redirect issues.
- Added a root layout replacement containing the Support sidebar menu and Livewire update endpoint configuration.
- Kept module.json version in sync with changelog.yaml.
"1.0.5":
changed:
- Added the missing POST helpdesk.tickets.store route so native form submission to /helpdesk/tickets no longer returns 404.
- Added TicketStoreController as a safe non-JavaScript fallback while keeping the Livewire ticket create flow intact.
- Updated the ticket create form with method, action, CSRF, multipart support and named fields for fallback posting.
- Kept module.json version in sync with changelog.yaml.
"1.0.6":
changed:
- Rebuilt the Helpdesk database seeder with the requested Categories, Team, Priority and Status lookup values.
- Added contrasting colour assignments for seeded lookup records.
- Corrected the seeder namespace to Modules\\Helpdesk\\Database\\Seeders for PSR-4 module autoloading.
- Kept module.json version in sync with changelog.yaml.
"1.0.9":
changed:
- Added editable tickets with assignment, status, priority, category, team, project, due date and details updates.
- Changed the default ticket creation status to New.
- Added configurable Kanban boards with status-column assignment and a default Standard Support board.
- Rebuilt the Kanban board into a professional drag-and-drop board with SortableJS fallback loading.
- Added blue Back and green Edit actions on ticket view pages.
- Improved Helpdesk settings styling, blue edit actions, and a dedicated Kanban Boards CRUD section.
- Marked mandatory ticket fields and enhanced create/edit ticket forms with Select2-capable dropdowns.
- Kept module.json version in sync with changelog.yaml.
"1.1.3":
changed:
- Fixed ticket update permissions so platform admins, helpdesk admins, assigned agents and ticket owners can edit tickets where appropriate.
- Added the missing ticket edit Blade view with assignment, status, priority, category, project, team, due date and details controls.
- Replaced the Kanban drag dependency path with native browser drag-and-drop to prevent blocked scripts from breaking ticket movement.
- Added Kanban Boards CRUD to Helpdesk settings with configurable status columns.
- Improved ticket create and edit form layout, mandatory markers, dark footer styling and searchable Select2-capable dropdown loading.
- Kept module.json version in sync with changelog.yaml.
"1.2.0":
changed:
- Added RedmineUP-inspired canned responses with professional reply macro tokens for ticket, requester, agent, status, priority, category, project, team and URL values.
- Added SLA policies, first-response and resolution due dates, breach tracking, SLA event history and ticket/Kanban breach indicators.
- Added incoming email automation rule models and service support for matching sender, subject, mailbox and category to auto-set priority, category, team, project, assignee and status.
- Added database-driven autoresponder enhancements with optional read-receipt tracking tokens, workflow log open timestamps and open counts.
- Added customer/requester profile context on ticket detail pages with tags and richer support metadata.
- Added satisfaction rating data model for closed-ticket feedback reporting by agent, team and category.
- Added ticket tags, checklist templates, checklist items, checklist progress and agent checklist toggling.
- Added product/service/build linkage for tickets with support cost/time fields and product-aware administration.
- Extended Helpdesk settings with professional CRUD sections for canned responses, SLA policies, automation rules, checklist templates, tags and products/services.
- Extended ticket create/edit/show and Kanban screens to include product/service linkage, SLA status, tags and checklist progress without breaking the existing workflow.
- Added PHPUnit coverage for automation, SLA, canned response macro rendering, checklist toggling and product linkage, plus Dusk coverage for the new settings sections.
- Kept module.json version in sync with changelog.yaml.
"1.2.1":
changed:
- Added signed Git webhook ingestion endpoint at POST /api/helpdesk/git/webhook/{repository} for registered repositories.
- Added GitHub X-Hub-Signature-256 HMAC validation, GitLab X-Gitlab-Token validation and a generic X-Helpdesk-Token fallback for internal providers.
- Added automatic commit persistence and Helpdesk ticket linking from HD-YYYY-00000 references in commit messages, branch names and commit URLs.
- Added helpdesk_git_webhook_logs for auditable accepted, ignored and rejected provider deliveries.
- Added Git webhook endpoint visibility and delivery counts to the Helpdesk Git repository settings table.
- Added provider setup documentation for GitHub, GitLab and generic signed Git payloads.
- Added PHPUnit coverage for accepted GitHub/GitLab push webhooks, invalid signature rejection and ignored non-push events.
- Kept module.json version in sync with changelog.yaml.
"1.2.2":
changed:
- Fixed Helpdesk settings rendering during rolling upgrades by defaulting newly added RedmineUP/Git settings collections in the Blade view.
- Prevented undefined variable errors for canned responses, SLA policies, automation rules, checklist templates, tags and products when compiled views and Livewire component code are briefly out of sync.
- Kept module.json version in sync with changelog.yaml.
"1.2.3":
changed:
- Fixed Helpdesk settings Livewire actions for the RedmineUP-inspired sections so SLA, canned responses, automation, products, tags and checklists can be saved from /helpdesk/settings.
- Added SettingsManager mount-time form normalisation to survive stale Livewire snapshots after module upgrades.
- Added a defensive workflow tracking-pixel URL fallback so ticket creation cannot fail when route caches are stale.
- Registered the workflow open pixel route in the web route file as a backup to the module provider route.
- Kept module.json version in sync with changelog.yaml.
"1.2.5":
changed:
- Fixed the Helpdesk settings default-company Livewire action by adding saveDefaultHelpdeskCompany to SettingsManager.
- Added default flags for Helpdesk statuses as well as priorities, categories, teams and projects.
- Updated ticket creation to use the configured default status and lookup defaults.
- Added selectable ticket tags to the create ticket workflow and synced tags onto new tickets.
- Kept the one-line ticket subject input and dark form repaint fixes.
- Kept module.json version in sync with changelog.yaml.
"1.2.6":
changed:
- Fixed the Helpdesk settings default-company Livewire method and made the default company property resilient to select empty-string values.
- Confirmed default statuses, priorities, categories, teams and projects are applied during ticket creation.
- Confirmed ticket tags can be assigned on create and edit and are synced to the ticket-tag pivot.
- Kept module.json version in sync with changelog.yaml.
"1.2.8":
changed:
- Replaced white Flux action buttons on the Helpdesk dashboard, ticket list, Kanban board and settings header with explicit high-contrast coloured action buttons.
- Standardised + New Ticket actions as green buttons across Helpdesk entry points.
- Kept module.json version in sync with changelog.yaml.
"1.2.9":
changed:
- Applied explicit Helpdesk action colour conventions across dashboard, tickets, Kanban and settings pages.
- Coloured settings section navigation tiles by domain so Statuses, Priorities, Categories, Teams, Projects, Boards, Workflow, SLA, Tags, Products and Defaults are visually distinct.
- Coloured save, reset, edit, delete, settings, ticket, Kanban, reply and update action buttons consistently.
- Kept module.json version in sync with changelog.yaml.
"1.3.0":
changed:
- Fixed malformed Helpdesk settings action buttons that caused Blade parse errors.
- Applied explicit coloured action buttons across settings tables, settings forms and Kanban board configuration actions.
- Kept module.json version in sync with changelog.yaml.
"1.3.7":
changed:
- Fixed ticket create/edit clear actions by adding public Livewire clearForm methods to both components.
- Stabilised Select2 clearing by loading module assets on ticket forms, adding placeholder support, manually syncing Select2 changes back to Livewire and clearing browser widgets after Livewire reset events.
- 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.
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Helpdesk",
"alias": "helpdesk",
"description": "Professional Livewire/Flux helpdesk module with ticketing, Kanban, SLA, canned responses, automation rules, checklists, ratings, read receipts, product/service linkage and signed Git webhook tracking.",
"keywords": [
"helpdesk",
"tickets",
"livewire",
"flux",
"sla",
"automation",
"canned-responses",
"kanban",
"checklists",
"git-webhooks",
"github",
"gitlab",
"audit"
],
"priority": 0,
"providers": [
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.3.7"
}