ui updates for ticket layout now with multipel attachments

This commit is contained in:
Mark Gregory
2026-06-17 13:43:44 +01:00
parent a3ac516138
commit 47a67e83dc
15 changed files with 172 additions and 404 deletions
@@ -1,122 +0,0 @@
# Helpdesk Git Webhook Tracking
The Helpdesk module can ingest signed Git push webhooks and automatically link commits to tickets when commit messages, branch names, or commit URLs contain a ticket reference such as `HD-2026-00012`.
## 1. Register the repository
Open the Helpdesk settings page:
```text
/helpdesk/settings
```
In the **Git** section, create a repository with:
| Field | Value |
| --- | --- |
| Provider | `github`, `gitlab`, or `git` |
| Remote URL | Your repository remote URL |
| Default branch | Usually `develop` or `main` |
| Webhook secret | A strong random secret, for example `openssl rand -hex 32` |
After saving, the repository table shows the webhook endpoint:
```text
/api/helpdesk/git/webhook/{repository_id}
```
## 2. Ticket reference convention
Use the Helpdesk ticket reference in branch names and commit messages:
```bash
git checkout -b feature/HD-2026-00012-canned-responses
git commit -m "HD-2026-00012 Add canned response macro rendering"
```
The parser recognises this format:
```text
HD-YYYY-00000
```
The first matching reference is linked to the stored commit when the ticket exists and matches the repository company/project scope.
## 3. GitHub setup
In GitHub, open the repository settings and add a webhook:
| GitHub field | Value |
| --- | --- |
| Payload URL | `https://your-app.test/api/helpdesk/git/webhook/{repository_id}` |
| Content type | `application/json` |
| Secret | Same value stored in Helpdesk |
| Events | Push events |
The module validates `X-Hub-Signature-256` using HMAC SHA-256 and rejects unsigned or invalid requests.
## 4. GitLab setup
In GitLab, open **Settings > Webhooks** and add:
| GitLab field | Value |
| --- | --- |
| URL | `https://your-app.test/api/helpdesk/git/webhook/{repository_id}` |
| Secret token | Same value stored in Helpdesk |
| Trigger | Push events |
The module validates `X-Gitlab-Token` and rejects invalid requests.
## 5. Generic Git provider setup
For a simple internal provider, send a JSON push payload to the same endpoint with:
```http
X-Helpdesk-Token: your-secret
X-Helpdesk-Event: push
```
The payload should include:
```json
{
"ref": "refs/heads/develop",
"commits": [
{
"id": "full-commit-hash",
"message": "HD-2026-00012 Fix ticket workflow",
"url": "https://example.test/commit/full-commit-hash",
"timestamp": "2026-06-15T10:00:00+00:00",
"author": {
"name": "Support Agent",
"email": "agent@example.com"
}
}
]
}
```
## 6. Audit trail
Every accepted, ignored, or rejected webhook delivery is recorded in:
```text
helpdesk_git_webhook_logs
```
Stored data includes provider, event, delivery id, status, message, commit count, linked count, ignored count, payload, and received timestamp.
## 7. Verification commands
Run the focused feature test after installing the updated module:
```bash
php artisan test Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php
```
Then run the full Helpdesk test set:
```bash
php artisan test Modules/Helpdesk/Tests/Feature Modules/Helpdesk/Tests/Unit
php artisan dusk Modules/Helpdesk/Tests/Browser
```
-99
View File
@@ -1,99 +0,0 @@
# Helpdesk Module Install
## 1. Copy the module
Copy `Modules/Helpdesk` into your Laravel 13 project.
## 2. Register the provider
If your module loader reads `module.json`, no manual provider entry is needed. Otherwise add this provider to `bootstrap/providers.php`:
```php
Modules\Helpdesk\Providers\HelpdeskServiceProvider::class,
```
## 3. Add the helpdesk disk
Add this to `config/filesystems.php` disks:
```php
'helpdesk' => [
'driver' => 'local',
'root' => storage_path('app/public/helpdesk'),
'url' => env('APP_URL') . '/storage/helpdesk',
'visibility' => 'public',
],
```
Then run:
```bash
php artisan storage:link
```
## 4. Add relations to `App\Models\User`
```php
use Modules\Helpdesk\Concerns\HasHelpdeskTickets;
class User extends Authenticatable
{
use HasHelpdeskTickets;
}
```
Add casts if you want strict booleans:
```php
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'helpdesk_agent' => 'boolean',
'helpdesk_admin' => 'boolean',
];
}
```
## 5. Run migrations and seeders
```bash
php artisan migrate
php artisan db:seed --class="Modules\\Helpdesk\\database\\seeders\\HelpdeskDatabaseSeeder"
```
## 6. Add dashboard widget
Place this where you want it on your dashboard:
```blade
@include('helpdesk::dashboard.card')
```
or directly:
```blade
<livewire:helpdesk.dashboard-widget />
```
## 7. Navigation link
```blade
<flux:navlist.item icon="lifebuoy" :href="route('helpdesk.tickets.index')" :current="request()->routeIs('helpdesk.*')" wire:navigate>
Helpdesk
</flux:navlist.item>
```
## 8. Tests
```bash
php artisan test Modules/Helpdesk/tests/Feature
php artisan dusk Modules/Helpdesk/tests/Browser
```
## 9. Git webhook tracking
The module supports signed Git push webhooks for repository commit tracking. Register repositories in `/helpdesk/settings`, copy the generated webhook endpoint from the Git table, and configure GitHub/GitLab push webhooks with the same secret stored on the repository.
See `Docs/GIT_WEBHOOK_TRACKING.md` for provider-specific setup, ticket reference conventions, and verification commands.
@@ -1,17 +0,0 @@
# Helpdesk defaults and tags update
After copying this module update into the application, run:
```bash
php artisan migrate
php artisan optimize:clear
php artisan view:clear
php artisan livewire:discover
php artisan octane:reload
```
If Octane is not running, skip `octane:reload`.
The Helpdesk settings page now includes a Defaults tab for the default company. Statuses, priorities, categories, teams and projects have an `is_default` flag. Ticket creation reads those defaults in `TicketCreate::mount()` and `TicketService` reads the default status before falling back to the configured `new` status slug.
Tickets support tags on create/edit through `tag_ids`, synced to `helpdesk_ticket_tag`.
@@ -1,16 +0,0 @@
# WMS Support Menu Patch
Add this block to the main application sidebar inside `<nav class="wms-sidebar-nav">`, preferably after the Operations group and before Integration & Reporting.
```blade
@includeIf('helpdesk::partials.sidebar-support-menu')
```
The partial checks that the Helpdesk module is enabled through `nwidart/laravel-modules` when the Module facade exists, and also checks that the Helpdesk routes are registered before showing links.
After adding the include, run:
```bash
artisan optimize:clear
artisan octane:reload
```
@@ -15,7 +15,7 @@ use Modules\Helpdesk\Services\AttachmentService;
final class TicketAttachmentStoreController extends Controller final class TicketAttachmentStoreController extends Controller
{ {
/** /**
* Store one or more ticket-level supporting files for an existing ticket. * Store one or more supporting files directly against an existing ticket.
*/ */
public function __invoke(Request $request, Ticket $ticket, AttachmentService $attachments): RedirectResponse public function __invoke(Request $request, Ticket $ticket, AttachmentService $attachments): RedirectResponse
{ {
@@ -27,23 +27,18 @@ final class TicketAttachmentStoreController extends Controller
$request->validate([ $request->validate([
'attachments' => ['required', 'array', 'min:1'], 'attachments' => ['required', 'array', 'min:1'],
'attachments.*' => ['required', 'file', 'max:'.$maxUploadKilobytes, 'mimes:'.implode(',', $allowedMimes)], '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(); $user = Auth::user();
abort_if($user === null, 403); abort_if($user === null, 403);
$uploadedFiles = $request->file('attachments', []); $uploadedFiles = $request->file('attachments', []);
$uploadedFiles = is_array($uploadedFiles) ? array_values($uploadedFiles) : [$uploadedFiles]; $uploadedFiles = is_array($uploadedFiles) ? array_values(array_filter($uploadedFiles)) : [$uploadedFiles];
$attachments->storeMany($ticket, $uploadedFiles, (int) $user->id); $attachments->storeMany($ticket, $uploadedFiles, (int) $user->id);
$ticket->forceFill(['last_activity_at' => now()])->save();
return redirect() return redirect()
->route('helpdesk.tickets.show', $ticket) ->route('helpdesk.tickets.show', $ticket)
->with('ticket-attachments-added', count($uploadedFiles).' supporting file'.(count($uploadedFiles) === 1 ? '' : 's').' attached.'); ->with('ticket-attachments-added', count($uploadedFiles).' supporting file'.(count($uploadedFiles) === 1 ? '' : 's').' uploaded.');
} }
} }
+19 -19
View File
@@ -19,6 +19,8 @@ final class KanbanBoard extends Component
{ {
public ?int $board_id = null; public ?int $board_id = null;
public int $refreshNonce = 0;
public function mount(): void public function mount(): void
{ {
$this->board_id = KanbanBoardModel::query() $this->board_id = KanbanBoardModel::query()
@@ -56,13 +58,12 @@ final class KanbanBoard extends Component
Auth::user() Auth::user()
); );
$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->refreshBoard();
$this->dispatch('helpdesk-kanban-refresh'); $this->dispatch('helpdesk-kanban-refresh');
$this->dispatch('helpdesk-kanban-ticket-moved', ticketId: $ticket->id, statusId: $status->id); $this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message);
} }
/** /**
@@ -97,13 +98,21 @@ 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->refreshBoard();
$this->dispatch('helpdesk-kanban-refresh'); $this->dispatch('helpdesk-kanban-refresh');
$this->dispatch('helpdesk-kanban-ticket-moved', ticketId: $fresh->id, boardId: $board->id); $this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message);
}
/**
* Force the board to fetch fresh database rows after moves or polling.
*/
#[On('helpdesk-kanban-refresh')]
public function refreshBoard(): void
{
$this->refreshNonce++;
} }
/** /**
@@ -112,17 +121,7 @@ final class KanbanBoard extends Component
public function updatedBoardId(int|string|null $value): void public function updatedBoardId(int|string|null $value): void
{ {
$this->board_id = is_numeric($value) ? (int) $value : null; $this->board_id = is_numeric($value) ? (int) $value : null;
} $this->refreshBoard();
/**
* 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.
} }
/** /**
@@ -155,6 +154,7 @@ final class KanbanBoard extends Component
'board' => $board, 'board' => $board,
'statuses' => $board?->statuses ?? collect(), 'statuses' => $board?->statuses ?? collect(),
'ticketsByStatus' => $this->ticketsByStatus($board), 'ticketsByStatus' => $this->ticketsByStatus($board),
'refreshNonce' => $this->refreshNonce,
])->layout('layouts.app'); ])->layout('layouts.app');
} }
@@ -199,7 +199,7 @@
} }
</style> </style>
<div class="hd-helpdesk-surface mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board" wire:poll.15s="refreshBoard"> <div class="hd-helpdesk-surface mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board" wire:poll.visible.5s="refreshBoard" wire:key="helpdesk-kanban-root-{{ $board_id ?? 0 }}-{{ $refreshNonce }}">
<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 }}-{{ now()->timestamp }}"> <div class="flex gap-4 overflow-x-auto pb-4" data-helpdesk-kanban-board wire:key="helpdesk-kanban-columns-{{ $board?->id ?? 0 }}-{{ $refreshNonce }}">
@foreach ($statuses as $status) @foreach ($statuses as $status)
@php($tickets = $ticketsByStatus[$status->id] ?? collect()) @php($tickets = $ticketsByStatus[$status->id] ?? collect())
<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 }}"> <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="kanban-column-{{ $board?->id ?? 0 }}-{{ $status->id }}-{{ $refreshNonce }}">
<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,11 +276,11 @@
> >
@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 }}"
wire:key="kanban-ticket-{{ $ticket->id }}-{{ $ticket->status_id }}-{{ $ticket->board_id }}-{{ optional($ticket->updated_at)->timestamp }}"
dusk="kanban-ticket-{{ $ticket->id }}" dusk="kanban-ticket-{{ $ticket->id }}"
> >
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
@@ -410,8 +410,13 @@
if (!ticketId || !statusId) return; if (!ticketId || !statusId) return;
$wire.moveTicket(ticketId, statusId).then(function () { Promise.resolve($wire.moveTicket(ticketId, statusId)).then(function () {
$wire.dispatch('helpdesk-kanban-refresh'); setTimeout(function () {
if (typeof $wire.$refresh === 'function') {
$wire.$refresh();
}
initialiseHelpdeskKanban();
}, 100);
}); });
}); });
}); });
@@ -420,10 +425,6 @@
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-ticket-moved', function () {
$wire.dispatch('helpdesk-kanban-refresh');
setTimeout(initialiseHelpdeskKanban, 150);
});
</script> </script>
@endscript @endscript
</div> </div>
@@ -9,7 +9,7 @@
'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" enctype="multipart/form-data" class="space-y-6"> <form wire:submit.prevent="save" 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">
@@ -228,6 +228,59 @@
font-size: 0.8rem !important; font-size: 0.8rem !important;
} }
.hd-helpdesk-surface .hd-attachment-action {
display: inline-flex !important;
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;
padding: 0 22px !important;
border: 1px solid rgba(134, 239, 172, 0.65) !important;
border-radius: 18px !important;
background: linear-gradient(135deg, #15803d 0%, #16a34a 55%, #22c55e 100%) !important;
color: #ffffff !important;
box-shadow: 0 16px 34px rgba(22, 163, 74, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.20) !important;
font-size: 1rem !important;
font-weight: 900 !important;
line-height: 1 !important;
text-align: center !important;
white-space: nowrap !important;
cursor: pointer !important;
appearance: none !important;
-webkit-appearance: none !important;
}
.hd-helpdesk-surface .hd-attachment-action:hover {
transform: translateY(-1px) !important;
background: linear-gradient(135deg, #166534 0%, #15803d 55%, #16a34a 100%) !important;
color: #ffffff !important;
}
.hd-helpdesk-surface .hd-attachment-action-upload {
border-color: rgba(45, 212, 191, 0.60) !important;
background: linear-gradient(135deg, #047857 0%, #059669 55%, #0d9488 100%) !important;
box-shadow: 0 16px 34px rgba(5, 150, 105, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
}
.hd-helpdesk-surface .hd-attachment-action-upload:hover {
background: linear-gradient(135deg, #065f46 0%, #047857 55%, #0f766e 100%) !important;
}
.hd-helpdesk-surface .hd-attachment-action i {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 24px !important;
height: 24px !important;
border-radius: 9px !important;
background: rgba(255, 255, 255, 0.18) !important;
color: #ffffff !important;
font-size: 0.9rem !important;
}
.hd-helpdesk-surface .hd-select { .hd-helpdesk-surface .hd-select {
appearance: auto !important; appearance: auto !important;
} }
@@ -308,70 +361,57 @@
</article> </article>
<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> <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>
<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"> <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">
<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> </span>
<div class="min-w-0 space-y-2"> <div class="min-w-0 space-y-2">
<p class="text-xs font-black uppercase tracking-[0.22em] text-blue-200">Ticket attachments</p> <p class="text-xs font-black uppercase tracking-[0.22em] text-blue-200">Attachments</p>
<h2 class="text-2xl font-extrabold tracking-tight sm:text-3xl">Supporting files</h2> <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> <p class="text-base font-medium leading-7 text-blue-50/85">Pin screenshots, logs and supporting documents directly to this ticket.</p>
</div> </div>
</div> </div>
<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"> <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">
<i class="fa-solid fa-paperclip text-sm" aria-hidden="true"></i> <i class="fa-solid fa-paperclip text-sm" aria-hidden="true"></i>
{{ $ticket->attachments->count() }} file{{ $ticket->attachments->count() === 1 ? '' : 's' }} {{ $ticket->attachments->count() }} file{{ $ticket->attachments->count() === 1 ? '' : 's' }}
<i class="fa-solid fa-chevron-right transition" aria-hidden="true"></i> <i class="fa-solid fa-chevron-right transition group-open:rotate-90" aria-hidden="true"></i>
</span> </span>
</summary> </summary>
<div class="space-y-5 p-6"> <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>
@endif
@can('update', $ticket) @can('update', $ticket)
<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> <form method="POST" action="{{ route('helpdesk.tickets.attachments.store', $ticket) }}" enctype="multipart/form-data" class="rounded-2xl border border-blue-500/25 bg-slate-950/70 p-5 shadow-sm" data-helpdesk-ticket-attachment-form>
@csrf @csrf
<div class="flex min-h-[5.25rem] flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 items-center gap-4"> <div class="min-w-0">
<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"> <p class="text-sm font-black uppercase tracking-[0.22em] text-blue-200">Add files</p>
<i class="fa-solid fa-file-arrow-up text-sm" aria-hidden="true"></i> <p class="mt-1 text-sm font-semibold text-slate-200" data-helpdesk-file-label>No files selected</p>
</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 selected</span>
</span>
</div> </div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex shrink-0 flex-wrap items-center justify-center gap-3 sm:justify-end"> <label data-helpdesk-file-button class="hd-attachment-action">
<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 name="attachments[]" type="file" multiple class="sr-only" data-helpdesk-file-input> <input name="attachments[]" type="file" multiple class="sr-only" data-helpdesk-file-input>
</label> </label>
<button type="submit" class="hd-attachment-action hd-attachment-action-upload">
<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">
<i class="fa-solid fa-upload" aria-hidden="true"></i> <i class="fa-solid fa-upload" aria-hidden="true"></i>
Upload <span>Upload</span>
</button> </button>
</div> </div>
</div> </div>
@error('attachments')
@error('attachments') <div class="mt-3 text-sm font-semibold text-red-600">{{ $message }}</div> @enderror <p class="mt-3 text-sm font-semibold text-red-300">{{ $message }}</p>
@error('attachments.*') <div class="mt-3 text-sm font-semibold text-red-600">{{ $message }}</div> @enderror @enderror
@error('attachments.*')
<p class="mt-3 text-sm font-semibold text-red-300">{{ $message }}</p>
@enderror
</form> </form>
@endcan @endcan
@if ($ticket->attachments->isEmpty()) @if ($ticket->attachments->isEmpty())
<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"> <div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm font-semibold text-zinc-500 dark:border-zinc-700">No supporting files have been attached yet.</div>
<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>
@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)
@@ -551,49 +591,6 @@
@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">
@@ -726,22 +723,15 @@
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('[data-helpdesk-supporting-files-form]') || fileInput.closest('details') || fileInput.closest('div'); const panel = fileInput.closest('details') || fileInput.closest('div');
const label = panel?.querySelector('[data-helpdesk-file-label]'); const label = panel?.querySelector('[data-helpdesk-file-label]');
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 selected' : `${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;
} }
if (badge) {
badge.textContent = `${count} file${count === 1 ? '' : 's'}`;
}
} }
}); });
+2 -2
View File
@@ -7,8 +7,8 @@ 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\TicketAttachmentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketStoreController; use Modules\Helpdesk\Http\Controllers\TicketStoreController;
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController; use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
use Modules\Helpdesk\Livewire\KanbanBoard; use Modules\Helpdesk\Livewire\KanbanBoard;
@@ -32,8 +32,8 @@ 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::post('/tickets/{ticket}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.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');
Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class) Route::get('/attachments/{attachment}/download', AttachmentDownloadController::class)
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Seeders;
use Illuminate\Database\Seeder;
use Modules\Helpdesk\Database\Seeders\HelpdeskDatabaseSeeder as DatabaseHelpdeskDatabaseSeeder;
final class HelpdeskDatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call(DatabaseHelpdeskDatabaseSeeder::class);
}
}
@@ -46,6 +46,36 @@ final class GitAndAttachmentTest extends TestCase
self::assertSame($ticket->id, $repository->commits()->firstOrFail()->ticket_id); self::assertSame($ticket->id, $repository->commits()->firstOrFail()->ticket_id);
} }
public function test_existing_ticket_supporting_files_route_stores_multiple_files(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$user = $this->admin();
$ticket = $this->ticket(['owner' => $user]);
$this->actingAs($user)
->post(route('helpdesk.tickets.attachments.store', $ticket), [
'attachments' => [
UploadedFile::fake()->create('first-log.txt', 4, 'text/plain'),
UploadedFile::fake()->create('second-proof.pdf', 8, 'application/pdf'),
],
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => 'first-log.txt',
]);
$this->assertDatabaseHas('helpdesk_attachments', [
'ticket_id' => $ticket->id,
'comment_id' => null,
'original_name' => 'second-proof.pdf',
]);
}
public function test_attachment_service_stores_and_deletes_ticket_files(): void public function test_attachment_service_stores_and_deletes_ticket_files(): void
{ {
Storage::fake('local'); Storage::fake('local');
@@ -56,9 +56,7 @@ final class KanbanBoardTest extends TestCase
Livewire::test(KanbanBoard::class) Livewire::test(KanbanBoard::class)
->call('moveTicket', $ticket->id, $lookups['closed']->id) ->call('moveTicket', $ticket->id, $lookups['closed']->id)
->assertHasNoErrors() ->assertHasNoErrors();
->assertDispatched('helpdesk-kanban-ticket-moved')
->assertSee($ticket->reference);
$ticket->refresh(); $ticket->refresh();
@@ -138,4 +136,26 @@ final class KanbanBoardTest extends TestCase
->assertSee('Escalation board ticket') ->assertSee('Escalation board ticket')
->assertDontSee('Default board ticket'); ->assertDontSee('Default board ticket');
} }
/** @test */
public function kanban_board_increments_refresh_nonce_after_ticket_move(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
'board_id' => $lookups['board']->id,
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->assertSet('refreshNonce', 0)
->call('moveTicket', $ticket->id, $lookups['closed']->id)
->assertSet('refreshNonce', 1)
->assertHasNoErrors();
}
} }
+6 -4
View File
@@ -348,8 +348,10 @@ versions:
- version: 1.9.20 - version: 1.9.20
date: 2026-06-17 date: 2026-06-17
changes: changes:
- Removed the missing Helpdesk Vite manifest call from ticket edit to stop the build/helpdesk manifest server error. - Added a dedicated existing-ticket supporting files upload endpoint that accepts and stores multiple attachments.
- Added direct multi-file Supporting files upload for existing tickets without requiring a reply/comment. - Moved the supporting file picker and upload action into the Supporting files card and removed the disconnected visual flow.
- Moved the ticket-level attachment button into the Supporting files card and restored a compact professional action style. - Standardised Choose files and Upload actions to the same professional size and button treatment.
- Added Kanban drag/drop refresh dispatching, polling and stable Livewire keys so moved tickets refresh automatically. - Kept the Supporting files card visible even when a ticket has no attachments.
- 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. - Kept module.json version in sync with changelog.yaml.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
], ],
"files": [], "files": [],
"version": "1.9.20" "version": "1.9.21"
} }