Core UI updates for helpdesk

This commit is contained in:
Mark Gregory
2026-06-16 09:25:41 +01:00
parent a787303ba5
commit dc86970b20
29 changed files with 1303 additions and 214 deletions
+79 -5
View File
@@ -6,6 +6,7 @@ namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Modules\Helpdesk\Models\KanbanBoard as KanbanBoardModel;
@@ -26,16 +27,23 @@ final class KanbanBoard extends Component
}
/**
* Move a ticket to another workflow status.
* Move a ticket to another workflow status on the active board.
*/
public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void
{
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
$status = Status::query()->findOrFail((int) $statusId);
$board = $this->activeBoard();
$oldStatusName = $ticket->status?->name ?? 'previous status';
$this->authorize('update', $ticket);
if ($board !== null && ! $board->statuses()->whereKey($status->id)->exists()) {
$this->addStatusToBoard($board->id, $status->id);
}
$ticket->forceFill([
'board_id' => $board?->id,
'status_id' => $status->id,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
@@ -47,7 +55,44 @@ final class KanbanBoard extends Component
Auth::user()
);
session()->flash('success', 'Ticket moved to ' . $status->name . '.');
$message = 'Ticket '.$ticket->reference.' moved from '.$oldStatusName.' to '.$status->name.'.';
session()->flash('success', $message);
$this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message);
}
/**
* Move a ticket from its current board to another board. A ticket can only belong to one board.
*/
public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void
{
$ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId);
$board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId);
$oldBoardName = $ticket->board?->name ?? 'No board';
$this->authorize('update', $ticket);
if (! $board->statuses->contains('id', $ticket->status_id)) {
$firstStatus = $board->statuses->first();
if ($firstStatus !== null) {
$ticket->status_id = $firstStatus->id;
$ticket->completed_at = $firstStatus->is_closed ? now() : null;
} else {
$this->addStatusToBoard($board->id, (int) $ticket->status_id);
}
}
$ticket->forceFill([
'board_id' => $board->id,
'last_activity_at' => now(),
])->save();
$fresh = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket;
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
$message = 'Ticket '.$fresh->reference.' moved from '.$oldBoardName.' to '.$board->name.'.';
session()->flash('success', $message);
$this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message);
}
/**
@@ -56,7 +101,7 @@ final class KanbanBoard extends Component
public function render(): View
{
$boards = KanbanBoardModel::query()
->with('statuses')
->with(['statuses', 'owner:id,name,email'])
->orderBy('sort_order')
->orderBy('name')
->get();
@@ -77,19 +122,48 @@ final class KanbanBoard extends Component
}
/**
* Get visible tickets grouped by status.
* Get visible tickets grouped by status for the active board only.
*
* @return array<int, Collection<int, Ticket>>
*/
private function ticketsByStatus(): array
{
$board = $this->activeBoard();
return Ticket::query()
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems'])
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
->visibleTo(Auth::user())
->when($board !== null, fn ($query) => $query->where('board_id', $board->id))
->latest('last_activity_at')
->limit(300)
->get()
->groupBy('status_id')
->all();
}
/**
* Resolve the current board from the selected board ID.
*/
private function activeBoard(): ?KanbanBoardModel
{
if ($this->board_id !== null) {
return KanbanBoardModel::query()->find((int) $this->board_id);
}
return KanbanBoardModel::query()
->where('is_default', true)
->orderBy('sort_order')
->first() ?: KanbanBoardModel::query()->orderBy('sort_order')->first();
}
/**
* Attach a missing status to a board when a move requires it.
*/
private function addStatusToBoard(int $boardId, int $statusId): void
{
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
['board_id' => $boardId, 'status_id' => $statusId],
['sort_order' => 999, 'updated_at' => now(), 'created_at' => now()]
);
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -46,7 +47,7 @@ final class SettingsManager extends Component
public array $projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
/** @var array<string, mixed> */
public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
/** @var array<string, mixed> */
public array $repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
@@ -80,7 +81,7 @@ final class SettingsManager extends Component
$this->categoryForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
$this->teamForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
$this->projectForm += ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
$this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
$this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
$this->repositoryForm += ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
$this->workflowForm += ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
$this->cannedForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
@@ -303,6 +304,7 @@ final class SettingsManager extends Component
'boardForm.name' => ['required', 'string', 'max:160'],
'boardForm.slug' => ['nullable', 'string', 'max:180'],
'boardForm.description' => ['nullable', 'string'],
'boardForm.owner_id' => ['nullable', 'integer', 'exists:users,id'],
'boardForm.is_default' => ['boolean'],
'boardForm.sort_order' => ['required', 'integer', 'min:0'],
'boardForm.status_ids' => ['array'],
@@ -312,6 +314,7 @@ final class SettingsManager extends Component
$statusIds = collect($data['status_ids'] ?? [])->map(static fn ($id): int => (int) $id)->filter()->values();
unset($data['status_ids']);
$data['owner_id'] = blank($data['owner_id'] ?? null) ? null : (int) $data['owner_id'];
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
if ((bool) ($data['is_default'] ?? false)) {
@@ -340,6 +343,7 @@ final class SettingsManager extends Component
'name' => $board->name,
'slug' => $board->slug,
'description' => $board->description,
'owner_id' => $board->owner_id,
'is_default' => $board->is_default,
'sort_order' => $board->sort_order,
'status_ids' => $board->statuses->pluck('id')->map(static fn ($id): int => (int) $id)->all(),
@@ -356,7 +360,7 @@ final class SettingsManager extends Component
public function resetBoardForm(): void
{
$this->boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
$this->boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
}
public function saveRepository(): void
@@ -705,7 +709,8 @@ final class SettingsManager extends Component
'categories' => $this->orderedCategories(),
'teams' => $this->orderedTeams(),
'projects' => $this->orderedProjects(),
'boards' => KanbanBoard::query()->with('statuses')->orderBy('sort_order')->orderBy('name')->get(),
'boardOwners' => User::query()->where(static function ($query): void { $query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true); })->orderBy('name')->get(['id', 'name', 'email']),
'boards' => KanbanBoard::query()->with(['statuses', 'owner:id,name,email'])->withCount('tickets')->orderBy('sort_order')->orderBy('name')->get(),
'repositories' => GitRepository::query()->with(['project'])->withCount(['commits', 'webhookLogs'])->orderBy('name')->get(),
'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(),
'cannedResponses' => CannedResponse::query()->with(['category','team'])->orderBy('sort_order')->orderBy('name')->get(),
@@ -13,6 +13,7 @@ use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
@@ -33,6 +34,9 @@ final class TicketCreate extends Component
#[Validate('required|string|min:10')]
public string $body = '';
#[Validate('nullable|integer|exists:helpdesk_kanban_boards,id')]
public ?int $board_id = null;
#[Validate('required|integer|exists:helpdesk_priorities,id')]
public ?int $priority_id = null;
@@ -84,6 +88,7 @@ final class TicketCreate extends Component
'company_id' => $this->company_id,
'subject' => $this->subject,
'body' => $this->body,
'board_id' => $this->board_id,
'priority_id' => $this->priority_id,
'category_id' => $this->category_id,
'project_id' => $this->project_id,
@@ -101,6 +106,7 @@ final class TicketCreate extends Component
public function render(): View
{
return view('helpdesk::livewire.ticket-create', [
'boards' => $this->boardOptions(),
'priorities' => $this->priorityOptions(),
'categories' => $this->categoryOptions(),
'companies' => $this->companyOptions(),
@@ -123,6 +129,14 @@ final class TicketCreate extends Component
->value('id');
}
if (blank($this->board_id) && Schema::hasTable('helpdesk_kanban_boards')) {
$this->board_id = KanbanBoard::query()
->where('is_default', true)
->orderBy('sort_order')
->value('id') ?: KanbanBoard::query()->orderBy('sort_order')->value('id');
}
if (blank($this->priority_id) && $this->hasDefaultColumn('helpdesk_priorities')) {
$this->priority_id = Priority::query()
->where('is_default', true)
@@ -175,6 +189,20 @@ final class TicketCreate extends Component
}
/**
* Return Kanban board options with the default board listed first.
*/
private function boardOptions(): mixed
{
return KanbanBoard::query()
->with('owner:id,name,email')
->orderByDesc('is_default')
->orderBy('sort_order')
->orderBy('name')
->get();
}
/**
* Return priority options with default records listed first.
*/
+7
View File
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\KanbanBoard;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Product;
use Modules\Helpdesk\Models\Project;
@@ -38,6 +39,9 @@ final class TicketEdit extends Component
#[Validate('required|integer|exists:helpdesk_statuses,id')]
public ?int $status_id = null;
#[Validate('nullable|integer|exists:helpdesk_kanban_boards,id')]
public ?int $board_id = null;
#[Validate('required|integer|exists:helpdesk_priorities,id')]
public ?int $priority_id = null;
@@ -73,6 +77,7 @@ final class TicketEdit extends Component
$this->subject = $this->ticket->subject;
$this->body = $this->ticket->body;
$this->status_id = $this->ticket->status_id;
$this->board_id = $this->ticket->board_id;
$this->priority_id = $this->ticket->priority_id;
$this->category_id = $this->ticket->category_id;
$this->project_id = $this->ticket->project_id;
@@ -99,6 +104,7 @@ final class TicketEdit extends Component
'subject' => $this->subject,
'body' => $this->body,
'status_id' => $status->id,
'board_id' => $this->board_id,
'priority_id' => $this->priority_id,
'category_id' => $this->category_id,
'project_id' => $this->project_id,
@@ -127,6 +133,7 @@ final class TicketEdit extends Component
public function render(): View
{
return view('helpdesk::livewire.ticket-edit', [
'boards' => KanbanBoard::query()->with('owner:id,name,email')->orderByDesc('is_default')->orderBy('sort_order')->orderBy('name')->get(),
'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(),
'categories' => Category::query()->orderBy('name')->get(),