Core UI updates for helpdesk

This commit is contained in:
Mark Gregory
2026-06-16 09:35:24 +01:00
parent dc86970b20
commit 6c5e2af806
7 changed files with 366 additions and 36 deletions
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Ensure existing tickets have one concrete Kanban board assignment.
*/
public function up(): void
{
if (! Schema::hasTable('helpdesk_tickets') || ! Schema::hasColumn('helpdesk_tickets', 'board_id')) {
return;
}
if (! Schema::hasTable('helpdesk_kanban_boards')) {
return;
}
$defaultBoardId = DB::table('helpdesk_kanban_boards')
->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.
}
};
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Backfill status columns for every Kanban board that currently has no columns.
*/
public function up(): void
{
if (! Schema::hasTable('helpdesk_kanban_boards')
|| ! Schema::hasTable('helpdesk_kanban_board_statuses')
|| ! Schema::hasTable('helpdesk_statuses')) {
return;
}
$statuses = DB::table('helpdesk_statuses')
->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.
}
};
+82 -10
View File
@@ -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<int, Collection<int, Ticket>>
*/
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<int, KanbanBoardModel> $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(),
]
);
}
}
}
@@ -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.
</div>
@else
<div class="flex flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:flex-row md:items-center md:justify-between">
<div>
<div class="flex flex-col gap-4 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Active board</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">{{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }}</p>
@if ($board?->owner)
<p class="mt-1 text-xs font-semibold text-emerald-600 dark:text-emerald-300">Owner: {{ $board->owner->name }}</p>
@endif
</div>
<select wire:model.live="board_id" class="hd-select min-w-[320px]">
@foreach ($boards as $availableBoard)
<option value="{{ $availableBoard->id }}">{{ $availableBoard->name }}{{ $availableBoard->owner ? ' · '.$availableBoard->owner->name : '' }}</option>
@endforeach
</select>
<div class="hd-select-wrap shrink-0 md:w-72">
<select wire:model.live="board_id" class="hd-select" aria-label="Select Kanban board">
@foreach ($boards as $availableBoard)
<option value="{{ $availableBoard->id }}">{{ $availableBoard->name }}{{ $availableBoard->owner ? ' · '.$availableBoard->owner->name : '' }}</option>
@endforeach
</select>
</div>
</div>
@if ($statuses->isEmpty())
<div class="rounded-2xl border border-amber-500/40 bg-amber-950/70 px-5 py-4 text-sm font-semibold text-amber-100">
This board is being initialised with the default workflow statuses. Refresh the page if the columns do not appear immediately.
</div>
@else
<div class="flex gap-4 overflow-x-auto pb-4" data-helpdesk-kanban-board>
@foreach ($statuses as $status)
@php($tickets = $ticketsByStatus[$status->id] ?? collect())
@@ -237,16 +310,19 @@
</div>
<div class="mt-3 rounded-xl border border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-950/70">
<label class="mb-1 block text-[11px] font-bold uppercase tracking-wide text-zinc-500">Board</label>
<select
class="w-full rounded-lg border border-zinc-700 bg-zinc-950 px-2 py-1.5 text-xs font-semibold text-zinc-100 focus:border-blue-500 focus:ring-blue-500"
wire:change="moveTicketToBoard({{ $ticket->id }}, $event.target.value)"
>
@foreach ($boards as $ticketBoard)
<option value="{{ $ticketBoard->id }}" @selected((int) $ticket->board_id === (int) $ticketBoard->id)>{{ $ticketBoard->name }}</option>
@endforeach
</select>
<div class="mt-3 rounded-xl border border-emerald-500/20 bg-emerald-950/20 p-2">
<label class="mb-1 block text-[11px] font-bold uppercase tracking-wide text-emerald-200/80">Board</label>
<div class="hd-ticket-board-select-wrap">
<select
class="hd-ticket-board-select"
wire:change="moveTicketToBoard({{ $ticket->id }}, $event.target.value)"
aria-label="Move {{ $ticket->reference }} to another board"
>
@foreach ($boards as $ticketBoard)
<option value="{{ $ticketBoard->id }}" @selected((int) $ticket->board_id === (int) $ticketBoard->id)>{{ $ticketBoard->name }}</option>
@endforeach
</select>
</div>
</div>
@if ($ticket->tags->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-1">
@@ -281,6 +357,7 @@
</section>
@endforeach
</div>
@endif
@endif
@script
@@ -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');
}
}
+15
View File
@@ -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
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.3.4"
"version": "1.3.6"
}