workign kaban board
This commit is contained in:
@@ -33,7 +33,8 @@ final class TicketAttachmentStoreController extends Controller
|
|||||||
abort_if($user === null, 403);
|
abort_if($user === null, 403);
|
||||||
|
|
||||||
$uploadedFiles = $request->file('attachments', []);
|
$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);
|
$attachments->storeMany($ticket, $uploadedFiles, (int) $user->id);
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ final class TicketCommentStoreController extends Controller
|
|||||||
|
|
||||||
$uploadedFiles = $request->file('attachments', []);
|
$uploadedFiles = $request->file('attachments', []);
|
||||||
$uploadedFiles = is_array($uploadedFiles) ? $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()));
|
||||||
|
|
||||||
$tickets->comment(
|
$tickets->comment(
|
||||||
ticket: $ticket,
|
ticket: $ticket,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ final class KanbanBoard extends Component
|
|||||||
|
|
||||||
public int $refreshNonce = 0;
|
public int $refreshNonce = 0;
|
||||||
|
|
||||||
|
public bool $isMovingTicket = false;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->board_id = KanbanBoardModel::query()
|
$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
|
public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void
|
||||||
{
|
{
|
||||||
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
if ($this->isMovingTicket) {
|
||||||
$status = Status::query()->findOrFail((int) $statusId);
|
return;
|
||||||
$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([
|
$this->isMovingTicket = true;
|
||||||
'board_id' => $board?->id,
|
|
||||||
'status_id' => $status->id,
|
|
||||||
'completed_at' => $status->is_closed ? now() : null,
|
|
||||||
'last_activity_at' => now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$workflow->dispatch(
|
try {
|
||||||
$ticket->fresh(['status', 'priority', 'category', 'project', 'team']) ?? $ticket,
|
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
||||||
$status->is_closed ? 'ticket.closed' : 'ticket.updated',
|
$status = Status::query()->findOrFail((int) $statusId);
|
||||||
Auth::user()
|
$board = $this->activeBoard();
|
||||||
);
|
$oldStatusName = $ticket->status?->name ?? 'previous status';
|
||||||
|
|
||||||
$message = 'Ticket '.$ticket->reference.' moved from '.$oldStatusName.' to '.$status->name.'.';
|
$this->authorize('update', $ticket);
|
||||||
session()->flash('success', $message);
|
|
||||||
|
|
||||||
$this->refreshBoard();
|
if ($board !== null && ! $board->statuses()->whereKey($status->id)->exists()) {
|
||||||
$this->dispatch('helpdesk-kanban-refresh');
|
$this->addStatusToBoard($board->id, $status->id);
|
||||||
$this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message);
|
}
|
||||||
|
|
||||||
|
$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);
|
session()->flash('success', $message);
|
||||||
|
|
||||||
$this->refreshBoard();
|
$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);
|
$this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +127,7 @@ final class KanbanBoard extends Component
|
|||||||
public function refreshBoard(): void
|
public function refreshBoard(): void
|
||||||
{
|
{
|
||||||
$this->refreshNonce++;
|
$this->refreshNonce++;
|
||||||
|
unset($this->ticketsByStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -263,16 +263,18 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-sm font-bold uppercase tracking-wide text-zinc-900 dark:text-zinc-100">{{ $status->name }}</h2>
|
<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>
|
</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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<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"
|
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-helpdesk-kanban-column
|
||||||
data-status-id="{{ $status->id }}"
|
data-status-id="{{ $status->id }}"
|
||||||
|
wire:key="kanban-dropzone-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}"
|
||||||
>
|
>
|
||||||
@forelse ($tickets as $ticket)
|
@forelse ($tickets as $ticket)
|
||||||
<article
|
<article
|
||||||
@@ -365,6 +367,39 @@
|
|||||||
<script>
|
<script>
|
||||||
const initialiseHelpdeskKanban = function () {
|
const initialiseHelpdeskKanban = function () {
|
||||||
let draggedTicketId = null;
|
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) {
|
document.querySelectorAll('[data-helpdesk-kanban-ticket]').forEach(function (card) {
|
||||||
if (card.dataset.nativeDragReady === '1') return;
|
if (card.dataset.nativeDragReady === '1') return;
|
||||||
@@ -372,8 +407,10 @@
|
|||||||
|
|
||||||
card.addEventListener('dragstart', function (event) {
|
card.addEventListener('dragstart', function (event) {
|
||||||
draggedTicketId = card.dataset.ticketId;
|
draggedTicketId = card.dataset.ticketId;
|
||||||
|
draggedCard = card;
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
event.dataTransfer.setData('text/plain', draggedTicketId);
|
event.dataTransfer.setData('text/plain', draggedTicketId);
|
||||||
|
event.dataTransfer.setData('application/x-helpdesk-ticket-id', draggedTicketId);
|
||||||
card.classList.add('opacity-60');
|
card.classList.add('opacity-60');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -381,6 +418,7 @@
|
|||||||
card.classList.remove('opacity-60');
|
card.classList.remove('opacity-60');
|
||||||
document.querySelectorAll('[data-helpdesk-kanban-column]').forEach(function (column) {
|
document.querySelectorAll('[data-helpdesk-kanban-column]').forEach(function (column) {
|
||||||
column.dataset.dragOver = 'false';
|
column.dataset.dragOver = 'false';
|
||||||
|
column.dataset.refreshing = 'false';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -405,26 +443,43 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
column.dataset.dragOver = 'false';
|
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 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 () {
|
column.dataset.refreshing = 'true';
|
||||||
setTimeout(function () {
|
column.appendChild(card);
|
||||||
if (typeof $wire.$refresh === 'function') {
|
updateColumnCounts();
|
||||||
$wire.$refresh();
|
|
||||||
}
|
Promise.resolve($wire.moveTicket(ticketId, statusId))
|
||||||
initialiseHelpdeskKanban();
|
.then(forceLivewireRefresh)
|
||||||
}, 100);
|
.finally(function () {
|
||||||
});
|
column.dataset.refreshing = 'false';
|
||||||
|
draggedTicketId = null;
|
||||||
|
draggedCard = null;
|
||||||
|
updateColumnCounts();
|
||||||
|
setTimeout(initialiseHelpdeskKanban, 50);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateColumnCounts();
|
||||||
};
|
};
|
||||||
|
|
||||||
initialiseHelpdeskKanban();
|
initialiseHelpdeskKanban();
|
||||||
document.addEventListener('livewire:navigated', initialiseHelpdeskKanban);
|
document.addEventListener('livewire:navigated', initialiseHelpdeskKanban);
|
||||||
document.addEventListener('livewire:updated', 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>
|
</script>
|
||||||
@endscript
|
@endscript
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -233,10 +233,10 @@
|
|||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
gap: 12px !important;
|
gap: 12px !important;
|
||||||
width: 190px !important;
|
width: 210px !important;
|
||||||
min-width: 190px !important;
|
min-width: 210px !important;
|
||||||
height: 56px !important;
|
height: 58px !important;
|
||||||
min-height: 56px !important;
|
min-height: 58px !important;
|
||||||
padding: 0 22px !important;
|
padding: 0 22px !important;
|
||||||
border: 1px solid rgba(134, 239, 172, 0.65) !important;
|
border: 1px solid rgba(134, 239, 172, 0.65) !important;
|
||||||
border-radius: 18px !important;
|
border-radius: 18px !important;
|
||||||
@@ -723,15 +723,16 @@
|
|||||||
const fileInput = event.target.closest('[data-helpdesk-file-input]');
|
const fileInput = event.target.closest('[data-helpdesk-file-input]');
|
||||||
|
|
||||||
if (fileInput) {
|
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 label = panel?.querySelector('[data-helpdesk-file-label]');
|
||||||
const count = fileInput.files?.length || 0;
|
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) {
|
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;
|
namespace Modules\Helpdesk\Services;
|
||||||
|
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Modules\Helpdesk\Models\Attachment;
|
use Modules\Helpdesk\Models\Attachment;
|
||||||
use Modules\Helpdesk\Models\Comment;
|
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
|
public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void
|
||||||
{
|
{
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
if (! $file instanceof UploadedFile || ! $file->isValid()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$disk = (string) config('helpdesk.disk', 'public');
|
$disk = (string) config('helpdesk.disk', 'public');
|
||||||
|
|
||||||
$originalName = $file->getClientOriginalName();
|
$originalName = $file->getClientOriginalName();
|
||||||
|
|||||||
@@ -355,3 +355,19 @@ versions:
|
|||||||
- Removed direct Helpdesk Vite manifest loading from ticket edit when the module build manifest is not present.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -22,5 +22,5 @@
|
|||||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||||
],
|
],
|
||||||
"files": [],
|
"files": [],
|
||||||
"version": "1.9.21"
|
"version": "1.9.23"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user