ui updates for ticket layout now with multipel attachments

This commit is contained in:
Mark Gregory
2026-06-17 13:23:52 +01:00
parent 4bf1604542
commit a3ac516138
8 changed files with 187 additions and 102 deletions
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\AttachmentService;
final class TicketAttachmentStoreController extends Controller
{
/**
* Store one or more ticket-level supporting files for an existing ticket.
*/
public function __invoke(Request $request, Ticket $ticket, AttachmentService $attachments): RedirectResponse
{
Gate::authorize('update', $ticket);
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
$request->validate([
'attachments' => ['required', 'array', 'min:1'],
'attachments.*' => ['required', 'file', 'max:'.$maxUploadKilobytes, 'mimes:'.implode(',', $allowedMimes)],
], [
'attachments.required' => 'Choose at least one supporting file.',
'attachments.*.file' => 'Each supporting file must be a valid uploaded file.',
]);
$user = Auth::user();
abort_if($user === null, 403);
$uploadedFiles = $request->file('attachments', []);
$uploadedFiles = is_array($uploadedFiles) ? array_values($uploadedFiles) : [$uploadedFiles];
$attachments->storeMany($ticket, $uploadedFiles, (int) $user->id);
$ticket->forceFill(['last_activity_at' => now()])->save();
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-added', count($uploadedFiles).' supporting file'.(count($uploadedFiles) === 1 ? '' : 's').' attached.');
}
}
+19 -3
View File
@@ -8,6 +8,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
use Modules\Helpdesk\Models\KanbanBoard as KanbanBoardModel; use Modules\Helpdesk\Models\KanbanBoard as KanbanBoardModel;
use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Status;
@@ -55,12 +56,13 @@ final class KanbanBoard extends Component
Auth::user() Auth::user()
); );
$this->board_id = $board?->id; $this->skipRender();
$message = 'Ticket '.$ticket->reference.' moved from '.$oldStatusName.' to '.$status->name.'.'; $message = 'Ticket '.$ticket->reference.' moved from '.$oldStatusName.' to '.$status->name.'.';
session()->flash('success', $message); session()->flash('success', $message);
$this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message); $this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message);
$this->dispatch('helpdesk-kanban-ticket-moved', ticketId: (int) $ticket->id, statusId: (int) $status->id); $this->dispatch('helpdesk-kanban-refresh');
$this->dispatch('helpdesk-kanban-ticket-moved', ticketId: $ticket->id, statusId: $status->id);
} }
/** /**
@@ -95,10 +97,13 @@ final class KanbanBoard extends Component
$fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket; $fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket;
$workflow->dispatch($fresh, 'ticket.updated', Auth::user()); $workflow->dispatch($fresh, 'ticket.updated', Auth::user());
$this->skipRender();
$message = 'Ticket '.$fresh->reference.' moved from '.$oldBoardName.' to '.$board->name.'.'; $message = 'Ticket '.$fresh->reference.' moved from '.$oldBoardName.' to '.$board->name.'.';
session()->flash('success', $message); session()->flash('success', $message);
$this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message); $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message);
$this->dispatch('helpdesk-kanban-ticket-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id); $this->dispatch('helpdesk-kanban-refresh');
$this->dispatch('helpdesk-kanban-ticket-moved', ticketId: $fresh->id, boardId: $board->id);
} }
/** /**
@@ -109,6 +114,17 @@ final class KanbanBoard extends Component
$this->board_id = is_numeric($value) ? (int) $value : null; $this->board_id = is_numeric($value) ? (int) $value : null;
} }
/**
* Refresh the board after a drag/drop move, board change or external ticket update.
*/
#[On('helpdesk-kanban-refresh')]
#[On('ticket-updated')]
#[On('ticket-moved')]
public function refreshBoard(): void
{
// Livewire will re-run render(), which reloads all board columns from the database.
}
/** /**
* Render the Kanban board. * Render the Kanban board.
*/ */
+6 -4
View File
@@ -108,6 +108,7 @@ final class TicketEdit extends Component
$this->validate(); $this->validate();
$this->validate([ $this->validate([
'attachments' => 'array',
'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb', 10240).'|mimes:'.implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])), 'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb', 10240).'|mimes:'.implode(',', config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip'])),
'tag_ids.*' => 'integer|exists:helpdesk_tags,id', 'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
]); ]);
@@ -274,8 +275,9 @@ final class TicketEdit extends Component
continue; continue;
} }
$path = $upload->store('helpdesk/tickets/'.$this->ticket->id, 'public'); $disk = (string) config('helpdesk.disk', 'local');
$this->ticket->attachments()->create($this->attachmentPayload($upload, $path)); $path = $upload->store('tickets/'.$this->ticket->id, $disk);
$this->ticket->attachments()->create($this->attachmentPayload($upload, $path, $disk));
} }
$this->attachments = []; $this->attachments = [];
@@ -287,14 +289,14 @@ final class TicketEdit extends Component
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function attachmentPayload(TemporaryUploadedFile $upload, string $path): array private function attachmentPayload(TemporaryUploadedFile $upload, string $path, string $disk): array
{ {
$table = 'helpdesk_attachments'; $table = 'helpdesk_attachments';
$payload = []; $payload = [];
$columns = [ $columns = [
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'uploaded_by_id' => Auth::id(), 'uploaded_by_id' => Auth::id(),
'disk' => 'public', 'disk' => $disk,
'path' => $path, 'path' => $path,
'file_path' => $path, 'file_path' => $path,
'filename' => basename($path), 'filename' => basename($path),
@@ -199,7 +199,7 @@
} }
</style> </style>
<div class="hd-helpdesk-surface mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board"> <div class="hd-helpdesk-surface mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board" wire:poll.15s="refreshBoard">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"> <div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@@ -254,11 +254,11 @@
This board is being initialised with the default workflow statuses. Refresh the page if the columns do not appear immediately. This board is being initialised with the default workflow statuses. Refresh the page if the columns do not appear immediately.
</div> </div>
@else @else
<div class="flex gap-4 overflow-x-auto pb-4" data-helpdesk-kanban-board wire:key="helpdesk-kanban-board-{{ $board?->id ?? 'none' }}"> <div class="flex gap-4 overflow-x-auto pb-4" data-helpdesk-kanban-board wire:key="helpdesk-kanban-board-{{ $board?->id }}-{{ now()->timestamp }}">
@foreach ($statuses as $status) @foreach ($statuses as $status)
@php($tickets = $ticketsByStatus[$status->id] ?? collect()) @php($tickets = $ticketsByStatus[$status->id] ?? collect())
<section wire:key="helpdesk-kanban-column-{{ $board?->id }}-{{ $status->id }}" class="flex min-h-[640px] w-[330px] shrink-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-100 shadow-sm dark:border-zinc-700 dark:bg-zinc-950" dusk="kanban-column-{{ $status->slug }}"> <section class="flex min-h-[640px] w-[330px] shrink-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-100 shadow-sm dark:border-zinc-700 dark:bg-zinc-950" dusk="kanban-column-{{ $status->slug }}" wire:key="helpdesk-kanban-column-{{ $board?->id }}-{{ $status->id }}">
<header class="border-b border-zinc-200 bg-white px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900"> <header class="border-b border-zinc-200 bg-white px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
@@ -276,12 +276,12 @@
> >
@forelse ($tickets as $ticket) @forelse ($tickets as $ticket)
<article <article
wire:key="helpdesk-kanban-ticket-{{ $ticket->id }}-{{ $ticket->status_id }}-{{ $ticket->updated_at?->timestamp }}"
class="group cursor-grab rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing dark:border-zinc-800 dark:bg-zinc-900" class="group cursor-grab rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing dark:border-zinc-800 dark:bg-zinc-900"
draggable="true" draggable="true"
data-helpdesk-kanban-ticket data-helpdesk-kanban-ticket
data-ticket-id="{{ $ticket->id }}" data-ticket-id="{{ $ticket->id }}"
dusk="kanban-ticket-{{ $ticket->id }}" dusk="kanban-ticket-{{ $ticket->id }}"
wire:key="helpdesk-kanban-ticket-{{ $ticket->id }}-{{ $ticket->status_id }}-{{ optional($ticket->updated_at)->timestamp ?? 0 }}"
> >
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
@@ -411,7 +411,7 @@
if (!ticketId || !statusId) return; if (!ticketId || !statusId) return;
$wire.moveTicket(ticketId, statusId).then(function () { $wire.moveTicket(ticketId, statusId).then(function () {
initialiseHelpdeskKanban(); $wire.dispatch('helpdesk-kanban-refresh');
}); });
}); });
}); });
@@ -420,8 +420,10 @@
initialiseHelpdeskKanban(); initialiseHelpdeskKanban();
document.addEventListener('livewire:navigated', initialiseHelpdeskKanban); document.addEventListener('livewire:navigated', initialiseHelpdeskKanban);
document.addEventListener('livewire:updated', initialiseHelpdeskKanban); document.addEventListener('livewire:updated', initialiseHelpdeskKanban);
document.addEventListener('livewire:update', initialiseHelpdeskKanban); document.addEventListener('helpdesk-kanban-ticket-moved', function () {
document.addEventListener('helpdesk-kanban-ticket-moved', initialiseHelpdeskKanban); $wire.dispatch('helpdesk-kanban-refresh');
setTimeout(initialiseHelpdeskKanban, 150);
});
</script> </script>
@endscript @endscript
</div> </div>
@@ -3,18 +3,13 @@
'resources/css/app.css', 'resources/css/app.css',
'resources/js/app.js', 'resources/js/app.js',
]) ])
@vite([
'Resources/assets/css/app.css',
'Resources/assets/js/app.js',
], 'build/helpdesk')
@include('helpdesk::partials.page-header', [ @include('helpdesk::partials.page-header', [
'eyebrow' => 'Helpdesk', 'eyebrow' => 'Helpdesk',
'title' => 'Edit Ticket', 'title' => 'Edit Ticket',
'subtitle' => 'Update ownership, assignment, workflow status, product/service, project, team, category, priority and ticket details.', 'subtitle' => 'Update ownership, assignment, workflow status, product/service, project, team, category, priority and ticket details.',
]) ])
<form wire:submit.prevent="save" class="space-y-6"> <form wire:submit.prevent="save" enctype="multipart/form-data" class="space-y-6">
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm"> <div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4"> <div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@@ -307,106 +307,80 @@
</div> </div>
</article> </article>
<section class="overflow-hidden rounded-[2rem] border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900" data-helpdesk-supporting-files-card> <details open class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900" data-helpdesk-supporting-files-card>
<div class="bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-7 py-7 text-white sm:px-9"> <summary class="flex cursor-pointer list-none items-center justify-between gap-5 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-7 py-7 text-white marker:hidden sm:px-9 [&::-webkit-details-marker]:hidden">
<div class="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between"> <div class="flex min-w-0 items-start gap-5">
<div class="flex min-w-0 items-start gap-5"> <span class="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-blue-500/25 text-white ring-1 ring-white/20 shadow-lg shadow-black/20">
<span class="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-blue-500/25 text-white ring-1 ring-white/20 shadow-lg shadow-black/20"> <i class="fa-solid fa-paperclip text-xl" aria-hidden="true"></i>
<i class="fa-solid fa-paperclip text-xl" aria-hidden="true"></i>
</span>
<div class="min-w-0 space-y-2">
<p class="text-xs font-black uppercase tracking-[0.22em] text-blue-200">Ticket attachments</p>
<h2 class="text-2xl font-extrabold tracking-tight sm:text-3xl">Supporting files</h2>
<p class="text-base font-medium leading-7 text-blue-50/85">Attach multiple screenshots, documents, logs and evidence directly to this existing ticket.</p>
</div>
</div>
<span class="inline-flex w-fit shrink-0 items-center gap-3 rounded-2xl bg-white/15 px-5 py-3 text-base font-extrabold ring-1 ring-white/20 shadow-sm">
<i class="fa-solid fa-paperclip text-sm" aria-hidden="true"></i>
{{ $ticket->attachments->count() }} file{{ $ticket->attachments->count() === 1 ? '' : 's' }}
</span> </span>
<div class="min-w-0 space-y-2">
<p class="text-xs font-black uppercase tracking-[0.22em] text-blue-200">Ticket attachments</p>
<h2 class="text-2xl font-extrabold tracking-tight sm:text-3xl">Supporting files</h2>
<p class="text-base font-medium leading-7 text-blue-50/85">Attach screenshots, documents, logs and evidence directly to this existing ticket.</p>
</div>
</div> </div>
</div>
<div class="space-y-5 p-6 sm:p-7"> <span class="inline-flex shrink-0 items-center gap-3 rounded-2xl bg-white/15 px-5 py-3 text-base font-extrabold ring-1 ring-white/20 shadow-sm">
@if (session('ticket-supporting-files-added')) <i class="fa-solid fa-paperclip text-sm" aria-hidden="true"></i>
<div class="rounded-2xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100"> {{ $ticket->attachments->count() }} file{{ $ticket->attachments->count() === 1 ? '' : 's' }}
{{ session('ticket-supporting-files-added') }} <i class="fa-solid fa-chevron-right transition" aria-hidden="true"></i>
</span>
</summary>
<div class="space-y-5 p-6">
@if (session('ticket-attachments-added'))
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
{{ session('ticket-attachments-added') }}
</div> </div>
@endif @endif
@can('comment', $ticket) @can('update', $ticket)
<div class="rounded-[1.5rem] border border-blue-200 bg-blue-50/80 p-5 shadow-sm dark:border-blue-900/60 dark:bg-blue-950/30" data-helpdesk-attachment-shell> <form method="POST" action="{{ route('helpdesk.tickets.attachments.store', $ticket) }}" enctype="multipart/form-data" class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-950/70" data-helpdesk-supporting-files-form>
<div class="flex min-h-[5.25rem] flex-col gap-4 sm:flex-row sm:items-center sm:justify-between" data-helpdesk-attachment-picker-row> @csrf
<div class="flex min-h-[5.25rem] flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-4"> <div class="flex min-w-0 items-center gap-4">
<span class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-blue-200 bg-white text-blue-700 shadow-sm dark:border-blue-800 dark:bg-zinc-950 dark:text-blue-200"> <span class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-zinc-300 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200">
<i class="fa-solid fa-file-arrow-up text-base" aria-hidden="true"></i> <i class="fa-solid fa-file-arrow-up text-sm" aria-hidden="true"></i>
</span> </span>
<span class="min-w-0"> <span class="min-w-0">
<span class="block text-sm font-black text-zinc-900 dark:text-zinc-100">Attach supporting evidence</span> <span class="block text-sm font-black text-zinc-900 dark:text-zinc-100">Attach supporting evidence</span>
<span class="block truncate text-xs font-semibold text-zinc-600 dark:text-zinc-300" data-helpdesk-supporting-file-label> <span class="block truncate text-xs font-semibold text-zinc-500 dark:text-zinc-400" data-helpdesk-file-label>No files selected</span>
{{ count($supportingFiles) === 0 ? 'No files selected' : count($supportingFiles).' file'.(count($supportingFiles) === 1 ? '' : 's').' selected' }}
</span>
</span> </span>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-3"> <div class="flex shrink-0 flex-wrap items-center justify-center gap-3 sm:justify-end">
<label data-helpdesk-file-button> <label data-helpdesk-file-button class="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-black text-white shadow-sm transition hover:bg-blue-700 focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-blue-500">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i> <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Choose files</span> <span>Choose files</span>
<input id="helpdesk-supporting-files" type="file" multiple class="sr-only" wire:model="supportingFiles" data-helpdesk-supporting-file-input> <input name="attachments[]" type="file" multiple class="sr-only" data-helpdesk-file-input>
</label> </label>
<button <button type="submit" class="inline-flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-2.5 text-sm font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
type="button" <i class="fa-solid fa-upload" aria-hidden="true"></i>
wire:click="saveSupportingFiles" Upload
wire:loading.attr="disabled"
wire:target="supportingFiles,saveSupportingFiles"
@disabled(count($supportingFiles) === 0)
class="inline-flex min-h-12 items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-5 py-3 text-sm font-black text-white shadow-sm transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i>
Save files
</button> </button>
</div> </div>
</div> </div>
<div wire:loading wire:target="supportingFiles" class="mt-4 rounded-xl border border-blue-200 bg-white px-4 py-3 text-sm font-bold text-blue-800 dark:border-blue-800 dark:bg-zinc-950 dark:text-blue-100"> @error('attachments') <div class="mt-3 text-sm font-semibold text-red-600">{{ $message }}</div> @enderror
Preparing selected files… @error('attachments.*') <div class="mt-3 text-sm font-semibold text-red-600">{{ $message }}</div> @enderror
</div> </form>
@if (count($supportingFiles) > 0)
<div class="mt-4 rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-950">
<p class="mb-3 text-xs font-black uppercase tracking-[0.2em] text-zinc-500 dark:text-zinc-400">Ready to attach</p>
<ul class="space-y-2">
@foreach ($supportingFiles as $file)
<li class="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm font-semibold text-zinc-800 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100">
<span class="truncate">{{ $file->getClientOriginalName() }}</span>
<span class="shrink-0 text-xs text-zinc-500">{{ Number::fileSize((int) $file->getSize()) }}</span>
</li>
@endforeach
</ul>
</div>
@endif
@error('supportingFiles') <div class="mt-2 text-sm font-bold text-red-600">{{ $message }}</div> @enderror
@error('supportingFiles.*') <div class="mt-2 text-sm font-bold text-red-600">{{ $message }}</div> @enderror
</div>
@endcan @endcan
@if ($ticket->attachments->isEmpty()) @if ($ticket->attachments->isEmpty())
<div class="rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 px-5 py-8 text-center text-sm font-semibold text-zinc-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-300"> <div class="rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 p-8 text-center dark:border-zinc-700 dark:bg-zinc-950">
No supporting files have been attached yet. <p class="text-sm font-black text-zinc-800 dark:text-zinc-100">No supporting files yet.</p>
<p class="mt-1 text-sm font-semibold text-zinc-500 dark:text-zinc-400">Use Choose files above to attach multiple files to this existing ticket.</p>
</div> </div>
@else @else
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
@foreach ($ticket->attachments as $attachment) @foreach ($ticket->attachments as $attachment)
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank" class="group flex items-center justify-between gap-4 rounded-2xl border border-zinc-200 bg-white p-4 text-sm shadow-sm transition hover:-translate-y-0.5 hover:bg-zinc-50 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-950 dark:hover:bg-zinc-900"> <a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank" class="group flex items-center justify-between gap-4 rounded-xl border border-zinc-200 p-4 text-sm transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
<span class="min-w-0"> <span class="min-w-0">
<span class="block truncate font-black text-zinc-900 dark:text-zinc-100">{{ $attachment->original_name ?? $attachment->original_filename ?? $attachment->filename ?? $attachment->file_name ?? basename($attachment->path ?? $attachment->file_path ?? '') }}</span> <span class="block truncate font-bold text-zinc-900 dark:text-zinc-100">{{ $attachment->original_name ?? $attachment->original_filename ?? $attachment->filename ?? $attachment->file_name ?? basename($attachment->path ?? $attachment->file_path ?? '') }}</span>
<span class="block text-xs font-semibold text-zinc-500">{{ Number::fileSize($attachment->size ?? $attachment->size_bytes ?? 0) }}</span> <span class="block text-xs text-zinc-500">{{ Number::fileSize($attachment->size ?? $attachment->size_bytes ?? 0) }}</span>
</span> </span>
<span class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-sm transition group-hover:bg-blue-700"> <span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-zinc-100 text-zinc-700 transition group-hover:bg-blue-600 group-hover:text-white dark:bg-zinc-800 dark:text-zinc-200">
<i class="fa-solid fa-download" aria-hidden="true"></i> <i class="fa-solid fa-download" aria-hidden="true"></i>
</span> </span>
</a> </a>
@@ -414,7 +388,7 @@
</div> </div>
@endif @endif
</div> </div>
</section> </details>
<section class="space-y-4"> <section class="space-y-4">
<details class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900" data-helpdesk-conversation-section> <details class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900" data-helpdesk-conversation-section>
@@ -577,6 +551,50 @@
@endif @endif
@endcan @endcan
<details class="mt-5 overflow-hidden rounded-[1.5rem] border border-zinc-200 bg-white/80 shadow-sm dark:border-zinc-800 dark:bg-zinc-950/50">
<summary class="group flex cursor-pointer list-none items-center justify-between gap-4 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-6 py-5 text-white transition hover:from-slate-900 hover:via-blue-900 hover:to-slate-900 [&::-webkit-details-marker]:hidden">
<span class="flex min-w-0 items-center gap-4">
<span class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border border-blue-400/30 bg-blue-500/15 text-blue-200 shadow-inner">
<i class="fa-solid fa-paperclip text-sm" aria-hidden="true"></i>
</span>
<span class="min-w-0">
<span class="block text-xs font-black uppercase tracking-[0.28em] text-blue-200">Attachments</span>
<span class="block text-xl font-black tracking-tight text-white">Supporting files</span>
<span class="block truncate text-sm font-semibold text-zinc-300">Pin screenshots, logs and supporting documents.</span>
</span>
</span>
<span class="flex shrink-0 items-center gap-3">
<span class="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-black text-white" data-helpdesk-file-count>0 files</span>
<i class="fa-solid fa-chevron-down text-xs text-blue-200 transition group-open:rotate-180" aria-hidden="true"></i>
</span>
</summary>
<div class="border-t border-zinc-200 p-6 dark:border-zinc-800">
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-950/70">
<div class="flex min-h-[5.25rem] flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-4">
<span class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-zinc-300 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200">
<i class="fa-solid fa-file-arrow-up text-sm" aria-hidden="true"></i>
</span>
<span class="min-w-0">
<span class="block text-sm font-black text-zinc-900 dark:text-zinc-100">Attach supporting evidence</span>
<span class="block truncate text-xs font-semibold text-zinc-500 dark:text-zinc-400" data-helpdesk-file-label>No files attached</span>
</span>
</div>
<div class="flex shrink-0 items-center justify-center sm:min-w-[190px]">
<label data-helpdesk-file-button>
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Attach files</span>
<input name="attachments[]" type="file" multiple class="sr-only" data-helpdesk-file-input>
</label>
</div>
</div>
</div>
@error('attachments.*') <div class="mt-2 text-sm text-red-600">{{ $message }}</div> @enderror
</div>
</details>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button dusk="post-comment" type="submit" class="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70"> <button dusk="post-comment" type="submit" class="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-comments mr-2" aria-hidden="true"></i> <i class="fa-solid fa-comments mr-2" aria-hidden="true"></i>
@@ -705,14 +723,17 @@
}); });
} }
const fileInput = event.target.closest('[data-helpdesk-file-input], [data-helpdesk-supporting-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-supporting-files-form]') || fileInput.closest('details') || fileInput.closest('div');
const label = panel?.querySelector('[data-helpdesk-file-label], [data-helpdesk-supporting-file-label]'); const label = panel?.querySelector('[data-helpdesk-file-label]');
const badge = panel?.querySelector('[data-helpdesk-file-count]'); const badge = panel?.querySelector('[data-helpdesk-file-count]');
const count = fileInput.files?.length || 0; const files = Array.from(fileInput.files || []);
const text = count === 0 ? 'No files attached' : `${count} file${count === 1 ? '' : 's'} selected`; const count = files.length;
const text = count === 0
? 'No files selected'
: `${count} file${count === 1 ? '' : 's'} selected: ${files.map((file) => file.name).join(', ')}`;
if (label) { if (label) {
label.textContent = text; label.textContent = text;
+2
View File
@@ -7,6 +7,7 @@ use Modules\Helpdesk\Http\Controllers\AttachmentDownloadController;
use Modules\Helpdesk\Http\Controllers\HelpdeskController; use Modules\Helpdesk\Http\Controllers\HelpdeskController;
use Modules\Helpdesk\Http\Controllers\TicketControlsController; use Modules\Helpdesk\Http\Controllers\TicketControlsController;
use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController; use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController;
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketCommentStoreController; use Modules\Helpdesk\Http\Controllers\TicketCommentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketStoreController; use Modules\Helpdesk\Http\Controllers\TicketStoreController;
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
@@ -31,6 +32,7 @@ Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit');
Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update'); Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, 'updateAgent'])->name('tickets.agent.update');
Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update'); Route::post('/tickets/{ticket}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.update');
Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update'); Route::post('/tickets/{ticket}/priority', [TicketControlsController::class, 'updatePriority'])->name('tickets.priority.update');
Route::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store');
Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store'); Route::post('/tickets/{ticket}/comments', TicketCommentStoreController::class)->name('tickets.comments.store');
Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy'); Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy');
Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
+4 -6
View File
@@ -348,10 +348,8 @@ versions:
- version: 1.9.20 - version: 1.9.20
date: 2026-06-17 date: 2026-06-17
changes: changes:
- Moved existing-ticket attachment upload into the Supporting files card instead of the response form. - Removed the missing Helpdesk Vite manifest call from ticket edit to stop the build/helpdesk manifest server error.
- Added Livewire-backed multi-file uploads directly against existing tickets without requiring a new response. - Added direct multi-file Supporting files upload for existing tickets without requiring a reply/comment.
- Preserved normal response posting while removing the misplaced response attachment chooser from the ticket detail form. - Moved the ticket-level attachment button into the Supporting files card and restored a compact professional action style.
- Improved the Supporting files attachment button styling and selected-file status display. - Added Kanban drag/drop refresh dispatching, polling and stable Livewire keys so moved tickets refresh automatically.
- Added Kanban board re-render keys and moved-ticket browser events so drag/drop status moves refresh reliably.
- Added feature coverage for existing-ticket multiple supporting file uploads and Kanban moved-ticket dispatch.
- Kept module.json version in sync with changelog.yaml. - Kept module.json version in sync with changelog.yaml.