Core UI updates for helpdesk
This commit is contained in:
+60
@@ -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.
|
||||
}
|
||||
};
|
||||
+65
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.3.4"
|
||||
"version": "1.3.6"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user