From faea90cb6255413ad59d4b787b99f0a8fa22a388 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 29 Jun 2026 13:20:18 +0100 Subject: [PATCH] intermin fix for board move, final fix all working, and also tests --- .../Controllers/TicketBoardController.php | 119 ------ .../Helpdesk/Livewire/KanbanBoard.php | 294 --------------- .../views/livewire/kanban-board.blade.php | 349 ------------------ Modules/Helpdesk/Helpdesk/Routes/web.php | 52 --- .../Tests/Feature/KanbanBoardTest.php | 38 ++ Modules/Routes/web.php | 35 -- 6 files changed, 38 insertions(+), 849 deletions(-) delete mode 100644 Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php delete mode 100644 Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php delete mode 100644 Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php delete mode 100644 Modules/Helpdesk/Helpdesk/Routes/web.php delete mode 100644 Modules/Routes/web.php diff --git a/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php b/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php deleted file mode 100644 index 17010cb4..00000000 --- a/Modules/Helpdesk/Helpdesk/Http/Controllers/TicketBoardController.php +++ /dev/null @@ -1,119 +0,0 @@ -validate([ - 'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], - 'current_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')], - ]); - - $targetBoard = KanbanBoard::query() - ->with(['statuses', 'owner:id,name,email']) - ->findOrFail((int) $validated['board_id']); - - if ($targetBoard->statuses->isEmpty()) { - $this->ensureBoardHasStatuses($targetBoard); - $targetBoard->load(['statuses', 'owner:id,name,email']); - } - - $fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket { - /** @var Ticket $lockedTicket */ - $lockedTicket = Ticket::query() - ->visibleTo(Auth::user()) - ->with(['board', 'status']) - ->lockForUpdate() - ->findOrFail($ticket->id); - - $targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id) - ?: $targetBoard->statuses->first(); - - if ($targetStatus === null) { - $targetStatus = Status::query() - ->orderBy('sort_order') - ->orderBy('name') - ->firstOrFail(); - - $this->addStatusToBoard($targetBoard->id, $targetStatus->id); - } - - $lockedTicket->forceFill([ - 'board_id' => $targetBoard->id, - 'status_id' => $targetStatus->id, - 'completed_at' => $targetStatus->is_closed ? now() : null, - 'last_activity_at' => now(), - ])->save(); - - return $lockedTicket->fresh([ - 'status', - 'priority', - 'category', - 'project', - 'team', - 'product', - 'tags', - 'checklistItems', - 'board', - ]) ?? $lockedTicket; - }); - - $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); - - return redirect() - ->route('helpdesk.kanban', ['board_id' => $targetBoard->id]) - ->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.'); - } - - 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()], - ); - } - - private function ensureBoardHasStatuses(KanbanBoard $board): void - { - $statuses = Status::query() - ->orderBy('sort_order') - ->orderBy('name') - ->get(['id']); - - foreach ($statuses as $index => $status) { - DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( - ['board_id' => $board->id, 'status_id' => $status->id], - [ - 'sort_order' => ($index + 1) * 10, - 'created_at' => now(), - 'updated_at' => now(), - ], - ); - } - } -} diff --git a/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php deleted file mode 100644 index 13dcf90f..00000000 --- a/Modules/Helpdesk/Helpdesk/Livewire/KanbanBoard.php +++ /dev/null @@ -1,294 +0,0 @@ -query('board_id'); - - if (is_numeric($requestedBoardId)) { - $requestedBoardId = (int) $requestedBoardId; - - if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) { - $this->board_id = $requestedBoardId; - - return; - } - } - - $this->board_id = KanbanBoardModel::query() - ->where('is_default', true) - ->orderBy('sort_order') - ->value('id') ?: KanbanBoardModel::query()->orderBy('sort_order')->value('id'); - } - - /** - * Move a ticket to another workflow status on the active board. - */ - public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void - { - if ($this->isMovingTicket) { - return; - } - - $this->isMovingTicket = true; - - try { - $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(), - ])->save(); - - $freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket; - - $workflow->dispatch( - $freshTicket, - $status->is_closed ? 'ticket.closed' : 'ticket.updated', - Auth::user(), - ); - - $message = 'Ticket ' . $freshTicket->reference . ' moved from ' . $oldStatusName . ' to ' . $status->name . '.'; - session()->flash('success', $message); - - $this->refreshBoard(); - $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $freshTicket->id, statusId: (int) $freshTicket->status_id); - $this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message); - } finally { - $this->isMovingTicket = false; - } - } - - /** - * 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(); - - $this->board_id = $board->id; - - $fresh = $ticket->fresh(['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->refreshBoard(); - $this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id); - $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); - } - - /** - * Force the board to fetch fresh database rows after moves or polling. - */ - #[On('helpdesk-kanban-refresh')] - public function refreshBoard(): void - { - $this->refreshNonce++; - unset($this->ticketsByStatus); - } - - /** - * Normalise the board selector value from Livewire/browser select events. - */ - public function updatedBoardId(int|string|null $value): void - { - $this->board_id = is_numeric($value) ? (int) $value : null; - $this->refreshBoard(); - } - - /** - * Switch the visible board without relying on DOM morph side effects. - */ - public function selectBoard(int|string|null $value): void - { - $this->board_id = is_numeric($value) ? (int) $value : null; - $this->refreshBoard(); - } - - /** - * Render the Kanban board. - */ - public function render(): View - { - $boards = KanbanBoardModel::query() - ->with(['statuses', 'owner:id,name,email']) - ->orderBy('sort_order') - ->orderBy('name') - ->get(); - - $board = $this->resolveSelectedBoard($boards); - $this->board_id = $board?->id; - - if ($board instanceof KanbanBoardModel && $board->statuses->isEmpty()) { - $this->ensureBoardHasStatuses($board); - $board->load(['statuses', 'owner:id,name,email']); - - $boards = KanbanBoardModel::query() - ->with(['statuses', 'owner:id,name,email']) - ->orderBy('sort_order') - ->orderBy('name') - ->get(); - } - - return view('helpdesk::livewire.kanban-board', [ - 'boards' => $boards, - 'board' => $board, - 'statuses' => $board?->statuses ?? collect(), - 'ticketsByStatus' => $this->ticketsByStatus($board), - 'refreshNonce' => $this->refreshNonce, - ])->layout('layouts.app'); - } - - /** - * Get visible tickets grouped by status for the selected board only. - * - * @return array> - */ - private function ticketsByStatus(?KanbanBoardModel $board): array - { - if ($board === null) { - return []; - } - - return Ticket::query() - ->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) - ->visibleTo(Auth::user()) - ->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(); - } - - /** - * Resolve the selected board from an already-loaded board collection. - * - * @param Collection $boards - */ - private function resolveSelectedBoard(Collection $boards): ?KanbanBoardModel - { - if ($boards->isEmpty()) { - return null; - } - - if ($this->board_id !== null) { - $selected = $boards->firstWhere('id', (int) $this->board_id); - - if ($selected instanceof KanbanBoardModel) { - return $selected; - } - } - - $default = $boards->firstWhere('is_default', true); - - return $default instanceof KanbanBoardModel ? $default : $boards->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()], - ); - } - - /** - * Ensure a board has workflow columns. This keeps newly-created boards usable immediately. - */ - private function ensureBoardHasStatuses(KanbanBoardModel $board): void - { - if ($board->statuses()->exists()) { - return; - } - - $statuses = Status::query() - ->orderBy('sort_order') - ->orderBy('name') - ->get(['id']); - - foreach ($statuses as $index => $status) { - DB::table('helpdesk_kanban_board_statuses')->updateOrInsert( - ['board_id' => $board->id, 'status_id' => $status->id], - [ - 'sort_order' => ($index + 1) * 10, - 'created_at' => now(), - 'updated_at' => now(), - ], - ); - } - } -} diff --git a/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php deleted file mode 100644 index e3dd773f..00000000 --- a/Modules/Helpdesk/Helpdesk/Resources/views/livewire/kanban-board.blade.php +++ /dev/null @@ -1,349 +0,0 @@ -@vite(['resources/css/app.css']) - -
-
-
-
-
-

Helpdesk workflow

-

Kanban board

-

Drag tickets between board columns. Boards and - columns are configured in Helpdesk Settings.

-
- - -
-
-
- @if ($boards->isEmpty()) -
- No Kanban boards exist yet. Open Helpdesk Settings and create a board with at least one status column. -
- @else -
-
-

Active board: {{ $board?->name ?? 'None' }}

-

- {{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }} -

- @if ($board?->owner) -

Owner: - {{ $board->owner->name }}

- @endif -
- -
- -
-
- - @if ($statuses->isEmpty()) -
- This board is being initialised with the default workflow statuses. Refresh the page if the columns do - not appear immediately. -
- @else -
- @foreach ($statuses as $status) - @php($tickets = $ticketsByStatus[$status->id] ?? collect()) - -
-
-
-
-

- {{ $status->name }}

-

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

-
- -
-
- -
- @forelse ($tickets as $ticket) -
-
-
-
- {{ $ticket->reference }}
-

- {{ $ticket->subject }}

-
- - @if ($ticket->priority) - - {{ $ticket->priority->name }} - - @endif -
- -
-
{{ $ticket->owner?->name ?? 'Unknown' }}
-
{{ $ticket->agent?->name ?? 'Unassigned' }} -
- @if ($ticket->project) -
- {{ $ticket->project->title }}
- @endif - @if ($ticket->team) -
{{ $ticket->team->name }}
- @endif - @if ($ticket->product) -
{{ $ticket->product->name }}
- @endif -
- - -
- @csrf - - -
- -
-
- @if ($ticket->tags->isNotEmpty()) -
- @foreach ($ticket->tags as $tag) - {{ $tag->name }} - @endforeach -
- @endif - - @if ($ticket->checklistItems->isNotEmpty()) - @php($done = $ticket->checklistItems->where('is_completed', true)->count()) -
Checklist - {{ $done }}/{{ $ticket->checklistItems->count() }}
- @endif - - @if ($ticket->sla_first_response_breached || $ticket->sla_resolution_breached) -
- SLA breach
- @endif - -
- Open - @can('update', $ticket) - Edit - @endcan -
-
- @empty -
- Drop tickets here for {{ $status->name }}. -
- @endforelse -
-
- @endforeach -
- @endif - @endif - - @script - - @endscript -
diff --git a/Modules/Helpdesk/Helpdesk/Routes/web.php b/Modules/Helpdesk/Helpdesk/Routes/web.php deleted file mode 100644 index 353f833a..00000000 --- a/Modules/Helpdesk/Helpdesk/Routes/web.php +++ /dev/null @@ -1,52 +0,0 @@ -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::post('/tickets/{ticket}/board', TicketBoardController::class)->name('tickets.board.update'); -Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store'); -Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store'); -Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy'); -Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); -Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) - ->whereNumber('attachment') - ->name('attachments.download'); -Route::get('/attachments/{attachment}/preview', [AttachmentDownloadController::class, 'preview']) - ->whereNumber('attachment') - ->name('attachments.preview'); -Route::delete('/attachments/{attachment}', [AttachmentDownloadController::class, 'destroy']) - ->whereNumber('attachment') - ->name('attachments.destroy'); -Route::post('/tickets/{ticket}/time-logs', [TicketTimeLogController::class, 'store']) - ->name('tickets.time-logs.store'); diff --git a/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php index 53de72c5..e88ee2c1 100644 --- a/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php +++ b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php @@ -48,6 +48,44 @@ final class KanbanBoardTest extends TestCase ->assertSee('High'); } + /** @test */ + public function test_ticket_can_be_moved_to_another_board(): void + { + $lookups = $this->seedLookupData(); + $user = $this->admin(); + + $boardA = $lookups['board']; + + $boardB = KanbanBoardModel::query()->create([ + 'name' => 'Regression Target Board', + 'slug' => 'regression-target-board', + 'is_default' => false, + 'sort_order' => 50, + ]); + + $boardB->statuses()->sync([ + $lookups['open']->id => ['sort_order' => 10], + ]); + + $ticket = $this->ticket([ + 'owner' => $user, + 'status_id' => $lookups['open']->id, + 'priority_id' => $lookups['normal']->id, + 'board_id' => $boardA->id, + ]); + + $this->actingAs($user); + + Livewire::test(KanbanBoard::class) + ->call('moveTicketToBoard', $ticket->id, $boardB->id) + ->assertHasNoErrors(); + + $ticket->refresh(); + + $this->assertSame($boardB->id, $ticket->board_id); + $this->assertSame($lookups['open']->id, $ticket->status_id); + } + /** @test */ public function kanban_board_can_move_ticket_to_closed_status(): void { diff --git a/Modules/Routes/web.php b/Modules/Routes/web.php deleted file mode 100644 index d077ae32..00000000 --- a/Modules/Routes/web.php +++ /dev/null @@ -1,35 +0,0 @@ -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');