workign kaban board

This commit is contained in:
Mark Gregory
2026-06-17 14:03:54 +01:00
parent 47a67e83dc
commit bfea992994
8 changed files with 140 additions and 47 deletions
@@ -33,7 +33,8 @@ final class TicketAttachmentStoreController extends Controller
abort_if($user === null, 403);
$uploadedFiles = $request->file('attachments', []);
$uploadedFiles = is_array($uploadedFiles) ? array_values(array_filter($uploadedFiles)) : [$uploadedFiles];
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
$uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof \Illuminate\Http\UploadedFile && $file->isValid()));
$attachments->storeMany($ticket, $uploadedFiles, (int) $user->id);
@@ -74,6 +74,7 @@ final class TicketCommentStoreController extends Controller
$uploadedFiles = $request->file('attachments', []);
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
$uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof \Illuminate\Http\UploadedFile && $file->isValid()));
$tickets->comment(
ticket: $ticket,
+41 -26
View File
@@ -21,6 +21,8 @@ final class KanbanBoard extends Component
public int $refreshNonce = 0;
public bool $isMovingTicket = false;
public function mount(): void
{
$this->board_id = KanbanBoardModel::query()
@@ -34,36 +36,48 @@ final class KanbanBoard extends Component
*/
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);
if ($this->isMovingTicket) {
return;
}
$ticket->forceFill([
'board_id' => $board?->id,
'status_id' => $status->id,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
])->save();
$this->isMovingTicket = true;
$workflow->dispatch(
$ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket,
$status->is_closed ? 'ticket.closed' : 'ticket.updated',
Auth::user()
);
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';
$message = 'Ticket '.$ticket->reference.' moved from '.$oldStatusName.' to '.$status->name.'.';
session()->flash('success', $message);
$this->authorize('update', $ticket);
$this->refreshBoard();
$this->dispatch('helpdesk-kanban-refresh');
$this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message);
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;
}
}
/**
@@ -102,7 +116,7 @@ final class KanbanBoard extends Component
session()->flash('success', $message);
$this->refreshBoard();
$this->dispatch('helpdesk-kanban-refresh');
$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);
}
@@ -113,6 +127,7 @@ final class KanbanBoard extends Component
public function refreshBoard(): void
{
$this->refreshNonce++;
unset($this->ticketsByStatus);
}
/**
@@ -263,16 +263,18 @@
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-sm font-bold uppercase tracking-wide text-zinc-900 dark:text-zinc-100">{{ $status->name }}</h2>
<p class="mt-1 text-xs text-zinc-500">{{ $tickets->count() }} tickets</p>
<p class="mt-1 text-xs text-zinc-500"><span data-helpdesk-kanban-count data-status-id="{{ $status->id }}">{{ $tickets->count() }}</span> tickets</p>
</div>
<span class="inline-flex h-4 w-4 rounded-full ring-4 ring-zinc-100 dark:ring-zinc-800" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($status->color) }}"></span>
</div>
</header>
<div
id="helpdesk-kanban-column-{{ $status->id }}"
class="helpdesk-kanban-column min-h-[560px] flex-1 space-y-3 p-3 transition data-[drag-over=true]:bg-blue-50 dark:data-[drag-over=true]:bg-blue-950/30"
data-helpdesk-kanban-column
data-status-id="{{ $status->id }}"
wire:key="kanban-dropzone-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}"
>
@forelse ($tickets as $ticket)
<article
@@ -365,6 +367,39 @@
<script>
const initialiseHelpdeskKanban = function () {
let draggedTicketId = null;
let draggedCard = null;
const updateColumnCounts = function () {
document.querySelectorAll('[data-helpdesk-kanban-count]').forEach(function (counter) {
const statusId = counter.dataset.statusId;
const column = document.querySelector('[data-helpdesk-kanban-column][data-status-id="' + statusId + '"]');
if (!column) {
return;
}
counter.textContent = column.querySelectorAll('[data-helpdesk-kanban-ticket]').length.toString();
});
};
const forceLivewireRefresh = function () {
if (typeof $wire !== 'undefined' && typeof $wire.$refresh === 'function') {
return Promise.resolve($wire.$refresh());
}
if (typeof Livewire !== 'undefined' && typeof Livewire.find === 'function') {
const root = document.querySelector('[wire\\:id]');
const component = root ? Livewire.find(root.getAttribute('wire:id')) : null;
if (component && typeof component.$refresh === 'function') {
return Promise.resolve(component.$refresh());
}
}
window.location.reload();
return Promise.resolve();
};
document.querySelectorAll('[data-helpdesk-kanban-ticket]').forEach(function (card) {
if (card.dataset.nativeDragReady === '1') return;
@@ -372,8 +407,10 @@
card.addEventListener('dragstart', function (event) {
draggedTicketId = card.dataset.ticketId;
draggedCard = card;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', draggedTicketId);
event.dataTransfer.setData('application/x-helpdesk-ticket-id', draggedTicketId);
card.classList.add('opacity-60');
});
@@ -381,6 +418,7 @@
card.classList.remove('opacity-60');
document.querySelectorAll('[data-helpdesk-kanban-column]').forEach(function (column) {
column.dataset.dragOver = 'false';
column.dataset.refreshing = 'false';
});
});
});
@@ -405,26 +443,43 @@
event.preventDefault();
column.dataset.dragOver = 'false';
const ticketId = event.dataTransfer.getData('text/plain') || draggedTicketId;
const ticketId = event.dataTransfer.getData('application/x-helpdesk-ticket-id') || event.dataTransfer.getData('text/plain') || draggedTicketId;
const statusId = column.dataset.statusId;
const card = draggedCard || document.querySelector('[data-helpdesk-kanban-ticket][data-ticket-id="' + ticketId + '"]');
if (!ticketId || !statusId) return;
if (!ticketId || !statusId || !card) return;
Promise.resolve($wire.moveTicket(ticketId, statusId)).then(function () {
setTimeout(function () {
if (typeof $wire.$refresh === 'function') {
$wire.$refresh();
}
initialiseHelpdeskKanban();
}, 100);
});
column.dataset.refreshing = 'true';
column.appendChild(card);
updateColumnCounts();
Promise.resolve($wire.moveTicket(ticketId, statusId))
.then(forceLivewireRefresh)
.finally(function () {
column.dataset.refreshing = 'false';
draggedTicketId = null;
draggedCard = null;
updateColumnCounts();
setTimeout(initialiseHelpdeskKanban, 50);
});
});
});
updateColumnCounts();
};
initialiseHelpdeskKanban();
document.addEventListener('livewire:navigated', initialiseHelpdeskKanban);
document.addEventListener('livewire:updated', initialiseHelpdeskKanban);
document.addEventListener('helpdesk-kanban-moved', function () {
setTimeout(function () {
initialiseHelpdeskKanban();
if (typeof $wire !== 'undefined' && typeof $wire.$refresh === 'function') {
$wire.$refresh();
}
}, 50);
});
</script>
@endscript
</div>
@@ -233,10 +233,10 @@
align-items: center !important;
justify-content: center !important;
gap: 12px !important;
width: 190px !important;
min-width: 190px !important;
height: 56px !important;
min-height: 56px !important;
width: 210px !important;
min-width: 210px !important;
height: 58px !important;
min-height: 58px !important;
padding: 0 22px !important;
border: 1px solid rgba(134, 239, 172, 0.65) !important;
border-radius: 18px !important;
@@ -723,15 +723,16 @@
const fileInput = event.target.closest('[data-helpdesk-file-input]');
if (fileInput) {
const panel = fileInput.closest('details') || fileInput.closest('div');
const panel = fileInput.closest('[data-helpdesk-ticket-attachment-form], [data-helpdesk-comment-form], details, form');
const label = panel?.querySelector('[data-helpdesk-file-label]');
const count = fileInput.files?.length || 0;
const text = count === 0 ? 'No files selected' : `${count} file${count === 1 ? '' : 's'} selected`;
const names = Array.from(fileInput.files || []).map((file) => file.name);
if (label) {
label.textContent = text;
label.textContent = count === 0
? 'No files selected'
: `${count} file${count === 1 ? '' : 's'} selected${names.length ? ': ' + names.join(', ') : ''}`;
}
}
});
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Comment;
@@ -14,6 +15,9 @@ final class AttachmentService
public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void
{
foreach ($files as $file) {
if (! $file instanceof UploadedFile || ! $file->isValid()) {
continue;
}
$disk = (string) config('helpdesk.disk', 'public');
$originalName = $file->getClientOriginalName();
+16
View File
@@ -355,3 +355,19 @@ versions:
- Removed direct Helpdesk Vite manifest loading from ticket edit when the module build manifest is not present.
- Forced the Kanban board to refresh after drag/drop moves and added visible polling for external ticket movement updates.
- Kept module.json version in sync with changelog.yaml.
- version: 1.9.22
date: 2026-06-17
changed:
- Removed the duplicate lower Supporting files dropdown card from the ticket reply form.
- Kept existing-ticket supporting uploads in the main Supporting files card with equal-sized choose and upload buttons.
- Hardened existing-ticket and comment attachment uploads so multiple valid uploaded files are preserved and saved.
- Forced Kanban drag/drop moves to refresh the Livewire board immediately after the database status update.
- version: 1.9.23
date: 2026-06-17
changed:
- Reworked Kanban drag/drop refresh to move the card in the browser immediately, update visible column counts, and then force a Livewire refresh after the database move completes.
- Added a browser reload fallback for Kanban moves if Livewire does not expose a refresh method after drag/drop.
- Kept module.json version in sync with changelog.yaml.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.21"
"version": "1.9.23"
}