From 6c5e2af806cccb1b5e8495ff7d4672c3e5eda813 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Tue, 16 Jun 2026 09:35:24 +0100 Subject: [PATCH] Core UI updates for helpdesk --- ...fill_helpdesk_ticket_board_assignments.php | 60 +++++++++ ...0_backfill_kanban_board_status_columns.php | 65 +++++++++ Modules/Helpdesk/Livewire/KanbanBoard.php | 92 +++++++++++-- .../views/livewire/kanban-board.blade.php | 127 ++++++++++++++---- .../Tests/Feature/KanbanBoardTest.php | 41 ++++++ Modules/Helpdesk/changelog.yaml | 15 +++ Modules/Helpdesk/module.json | 2 +- 7 files changed, 366 insertions(+), 36 deletions(-) create mode 100644 Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php create mode 100644 Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php b/Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php new file mode 100644 index 00000000..d2c3d6be --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_16_093000_backfill_helpdesk_ticket_board_assignments.php @@ -0,0 +1,60 @@ +where('is_default', true) + ->orderBy('sort_order') + ->value('id') ?: DB::table('helpdesk_kanban_boards')->orderBy('sort_order')->value('id'); + + if ($defaultBoardId === null) { + return; + } + + DB::table('helpdesk_tickets') + ->whereNull('board_id') + ->update([ + 'board_id' => $defaultBoardId, + 'updated_at' => now(), + ]); + + DB::table('helpdesk_tickets') + ->whereNotNull('board_id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('helpdesk_kanban_boards') + ->whereColumn('helpdesk_kanban_boards.id', 'helpdesk_tickets.board_id'); + }) + ->update([ + 'board_id' => $defaultBoardId, + 'updated_at' => now(), + ]); + } + + /** + * This data repair is intentionally not reversed. + */ + public function down(): void + { + // No-op: do not remove repaired board ownership from existing tickets. + } +}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php b/Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php new file mode 100644 index 00000000..cd50180e --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_06_16_094500_backfill_kanban_board_status_columns.php @@ -0,0 +1,65 @@ +orderBy('sort_order') + ->orderBy('name') + ->get(['id']); + + if ($statuses->isEmpty()) { + return; + } + + $boards = DB::table('helpdesk_kanban_boards') + ->orderBy('sort_order') + ->orderBy('name') + ->get(['id']); + + foreach ($boards as $board) { + $hasColumns = DB::table('helpdesk_kanban_board_statuses') + ->where('board_id', $board->id) + ->exists(); + + if ($hasColumns) { + continue; + } + + 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(), + ] + ); + } + } + } + + /** + * The migration only repairs missing board columns and is intentionally not destructive. + */ + public function down(): void + { + // No-op. Do not remove configured board columns. + } +}; diff --git a/Modules/Helpdesk/Livewire/KanbanBoard.php b/Modules/Helpdesk/Livewire/KanbanBoard.php index cba72483..0484aa0b 100644 --- a/Modules/Helpdesk/Livewire/KanbanBoard.php +++ b/Modules/Helpdesk/Livewire/KanbanBoard.php @@ -87,6 +87,8 @@ final class KanbanBoard extends Component 'last_activity_at' => now(), ])->save(); + $this->board_id = $board->id; + $fresh = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; $workflow->dispatch($fresh, 'ticket.updated', Auth::user()); @@ -95,6 +97,17 @@ final class KanbanBoard extends Component $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); } + /** + * Normalise the board selector value from Livewire/browser select events. + * + * @param int|string|null $value + * @return void + */ + public function updatedBoardId(int|string|null $value): void + { + $this->board_id = is_numeric($value) ? (int) $value : null; + } + /** * Render the Kanban board. */ @@ -106,34 +119,43 @@ final class KanbanBoard extends Component ->orderBy('name') ->get(); - $board = $boards->firstWhere('id', $this->board_id) ?? $boards->first(); - $statuses = $board?->statuses ?? collect(); + $board = $this->resolveSelectedBoard($boards); + $this->board_id = $board?->id; - if ($statuses->isEmpty()) { - $statuses = Status::query()->orderBy('sort_order')->orderBy('name')->get(); + 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' => $statuses, - 'ticketsByStatus' => $this->ticketsByStatus(), + 'statuses' => $board?->statuses ?? collect(), + 'ticketsByStatus' => $this->ticketsByStatus($board), ])->layout('layouts.app'); } /** - * Get visible tickets grouped by status for the active board only. + * Get visible tickets grouped by status for the selected board only. * * @return array> */ - private function ticketsByStatus(): array + private function ticketsByStatus(?KanbanBoardModel $board): array { - $board = $this->activeBoard(); + if ($board === null) { + return []; + } return Ticket::query() ->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)) + ->where('board_id', $board->id) ->latest('last_activity_at') ->limit(300) ->get() @@ -156,6 +178,30 @@ final class KanbanBoard extends Component ->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. */ @@ -166,4 +212,30 @@ final class KanbanBoard extends Component ['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/Resources/views/livewire/kanban-board.blade.php b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php index b7d59ed0..95e146ec 100644 --- a/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php +++ b/Modules/Helpdesk/Resources/views/livewire/kanban-board.blade.php @@ -11,6 +11,7 @@ font-size: .875rem; line-height: 1.25rem; font-weight: 800; + font-size: .95rem; color: #ffffff !important; text-decoration: none !important; border: 1px solid rgba(255,255,255,.14); @@ -74,21 +75,83 @@ border-color: rgba(255,255,255,.14) !important; } + .hd-select-wrap { + position: relative; + width: 100%; + max-width: 18rem; + } + .hd-select-wrap::after { + position: absolute; + top: 50%; + right: 1rem; + width: .55rem; + height: .55rem; + content: ""; + pointer-events: none; + border-right: 2px solid #93c5fd; + border-bottom: 2px solid #93c5fd; + transform: translateY(-65%) rotate(45deg); + } .hd-select { width: 100%; - min-height: 3.35rem; - border-radius: 1rem; - border: 1px solid #3f3f46 !important; - background: #09090b !important; - color: #f4f4f5 !important; - padding: .78rem 3rem .78rem 1rem; + min-height: 2.75rem; + appearance: none; + -webkit-appearance: none; + border-radius: .9rem; + border: 1px solid rgba(96, 165, 250, .38) !important; + background: linear-gradient(135deg, #030712 0%, #111827 54%, #0f172a 100%) !important; + color: #f8fafc !important; + padding: .62rem 2.45rem .62rem .9rem; font-weight: 800; outline: none; - box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24); + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 14px 30px rgba(2,6,23,.38); + } + .hd-select option { + background: #020617 !important; + color: #f8fafc !important; } .hd-select:focus { border-color: #60a5fa !important; - box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.05); + box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.08), 0 14px 30px rgba(2,6,23,.38); + } + .hd-ticket-board-select-wrap { + position: relative; + max-width: 12.5rem; + } + .hd-ticket-board-select-wrap::after { + position: absolute; + top: 50%; + right: .7rem; + width: .45rem; + height: .45rem; + content: ""; + pointer-events: none; + border-right: 2px solid #86efac; + border-bottom: 2px solid #86efac; + transform: translateY(-65%) rotate(45deg); + } + .hd-ticket-board-select { + width: 100%; + min-height: 2.35rem; + appearance: none; + -webkit-appearance: none; + border-radius: .85rem; + border: 1px solid rgba(34,197,94,.35) !important; + background: linear-gradient(135deg, #052e16 0%, #064e3b 100%) !important; + color: #ecfdf5 !important; + padding: .48rem 2rem .48rem .75rem; + font-size: .75rem; + font-weight: 800; + outline: none; + box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 10px 22px rgba(0,0,0,.26); + } + .hd-ticket-board-select option { + background: #052e16 !important; + color: #ecfdf5 !important; + } + .hd-ticket-board-select:focus { + border-color: #86efac !important; + box-shadow: 0 0 0 3px rgba(34,197,94,.28), inset 0 1px 0 rgba(255,255,255,.08); } .helpdesk-ticket-form, @@ -168,19 +231,29 @@ No Kanban boards exist yet. Open Helpdesk Settings and create a board with at least one status column. @else -
-
+
+

Active board

{{ $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()) @@ -237,16 +310,19 @@
-
- - +
+ +
+ +
@if ($ticket->tags->isNotEmpty())
@@ -281,6 +357,7 @@ @endforeach
+ @endif @endif @script diff --git a/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php index 67fb7f10..65b92bef 100644 --- a/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php +++ b/Modules/Helpdesk/Tests/Feature/KanbanBoardTest.php @@ -95,4 +95,45 @@ final class KanbanBoardTest extends TestCase $this->assertSame($targetBoard->id, $ticket->board_id); } + /** @test */ + public function kanban_board_filters_tickets_to_the_selected_board_only(): void + { + $lookups = $this->seedLookupData(); + $user = $this->admin(); + + $otherBoard = KanbanBoardModel::query()->create([ + 'name' => 'Escalation Board', + 'slug' => 'escalation-board', + 'is_default' => false, + 'sort_order' => 30, + ]); + $otherBoard->statuses()->sync([$lookups['open']->id => ['sort_order' => 10]]); + + $this->ticket([ + 'owner' => $user, + 'status_id' => $lookups['open']->id, + 'priority_id' => $lookups['normal']->id, + 'board_id' => $lookups['board']->id, + 'subject' => 'Default board ticket', + ]); + + $this->ticket([ + 'owner' => $user, + 'status_id' => $lookups['open']->id, + 'priority_id' => $lookups['normal']->id, + 'board_id' => $otherBoard->id, + 'subject' => 'Escalation board ticket', + ]); + + $this->actingAs($user); + + Livewire::test(KanbanBoard::class) + ->set('board_id', $lookups['board']->id) + ->assertSee('Default board ticket') + ->assertDontSee('Escalation board ticket') + ->set('board_id', $otherBoard->id) + ->assertSee('Escalation board ticket') + ->assertDontSee('Default board ticket'); + } + } diff --git a/Modules/Helpdesk/changelog.yaml b/Modules/Helpdesk/changelog.yaml index 597c1c74..d1226540 100644 --- a/Modules/Helpdesk/changelog.yaml +++ b/Modules/Helpdesk/changelog.yaml @@ -1,3 +1,18 @@ +- 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 diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index f1f73f2c..edc046c4 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -22,5 +22,5 @@ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], "files": [], - "version": "1.3.4" + "version": "1.3.6" }