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
{
/**
* 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
{
@@ -27,23 +27,18 @@ final class TicketAttachmentStoreController extends Controller
$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];
$uploadedFiles = is_array($uploadedFiles) ? array_values(array_filter($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.');
->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 $refreshNonce = 0;
public function mount(): void
{
$this->board_id = KanbanBoardModel::query()
@@ -56,13 +58,12 @@ final class KanbanBoard extends Component
Auth::user()
);
$this->skipRender();
$message = 'Ticket '.$ticket->reference.' moved from '.$oldStatusName.' to '.$status->name.'.';
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-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;
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
$this->skipRender();
$message = 'Ticket '.$fresh->reference.' moved from '.$oldBoardName.' to '.$board->name.'.';
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-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
{
$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.
$this->refreshBoard();
}
/**
@@ -155,6 +154,7 @@ final class KanbanBoard extends Component
'board' => $board,
'statuses' => $board?->statuses ?? collect(),
'ticketsByStatus' => $this->ticketsByStatus($board),
'refreshNonce' => $this->refreshNonce,
])->layout('layouts.app');
}
@@ -199,7 +199,7 @@
}
</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="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">
@@ -254,11 +254,11 @@
This board is being initialised with the default workflow statuses. Refresh the page if the columns do not appear immediately.
</div>
@else
<div class="flex gap-4 overflow-x-auto pb-4" data-helpdesk-kanban-board 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)
@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">
<div class="flex items-start justify-between gap-3">
<div>
@@ -276,11 +276,11 @@
>
@forelse ($tickets as $ticket)
<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"
draggable="true"
data-helpdesk-kanban-ticket
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 }}"
>
<div class="flex items-start justify-between gap-3">
@@ -410,8 +410,13 @@
if (!ticketId || !statusId) return;
$wire.moveTicket(ticketId, statusId).then(function () {
$wire.dispatch('helpdesk-kanban-refresh');
Promise.resolve($wire.moveTicket(ticketId, statusId)).then(function () {
setTimeout(function () {
if (typeof $wire.$refresh === 'function') {
$wire.$refresh();
}
initialiseHelpdeskKanban();
}, 100);
});
});
});
@@ -420,10 +425,6 @@
initialiseHelpdeskKanban();
document.addEventListener('livewire:navigated', initialiseHelpdeskKanban);
document.addEventListener('livewire:updated', initialiseHelpdeskKanban);
document.addEventListener('helpdesk-kanban-ticket-moved', function () {
$wire.dispatch('helpdesk-kanban-refresh');
setTimeout(initialiseHelpdeskKanban, 150);
});
</script>
@endscript
</div>
@@ -9,7 +9,7 @@
'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="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">
@@ -228,6 +228,59 @@
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 {
appearance: auto !important;
}
@@ -308,70 +361,57 @@
</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>
<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">
<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>
</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>
<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>
<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>
<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>
{{ $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>
</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>
@endif
@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
<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 selected</span>
</span>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0">
<p class="text-sm font-black uppercase tracking-[0.22em] text-blue-200">Add files</p>
<p class="mt-1 text-sm font-semibold text-slate-200" data-helpdesk-file-label>No files selected</p>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-center gap-3 sm:justify-end">
<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">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<label data-helpdesk-file-button class="hd-attachment-action">
<i class="fa-solid fa-paperclip" aria-hidden="true"></i>
<span>Choose files</span>
<input name="attachments[]" type="file" multiple class="sr-only" data-helpdesk-file-input>
</label>
<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">
<button type="submit" class="hd-attachment-action hd-attachment-action-upload">
<i class="fa-solid fa-upload" aria-hidden="true"></i>
Upload
<span>Upload</span>
</button>
</div>
</div>
@error('attachments') <div class="mt-3 text-sm font-semibold text-red-600">{{ $message }}</div> @enderror
@error('attachments.*') <div class="mt-3 text-sm font-semibold text-red-600">{{ $message }}</div> @enderror
@error('attachments')
<p class="mt-3 text-sm font-semibold text-red-300">{{ $message }}</p>
@enderror
@error('attachments.*')
<p class="mt-3 text-sm font-semibold text-red-300">{{ $message }}</p>
@enderror
</form>
@endcan
@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">
<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 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>
@else
<div class="grid gap-3 sm:grid-cols-2">
@foreach ($ticket->attachments as $attachment)
@@ -551,49 +591,6 @@
@endif
@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">
<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]');
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 badge = panel?.querySelector('[data-helpdesk-file-count]');
const files = Array.from(fileInput.files || []);
const count = files.length;
const text = count === 0
? 'No files selected'
: `${count} file${count === 1 ? '' : 's'} selected: ${files.map((file) => file.name).join(', ')}`;
const count = fileInput.files?.length || 0;
const text = count === 0 ? 'No files selected' : `${count} file${count === 1 ? '' : 's'} selected`;
if (label) {
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\TicketControlsController;
use Modules\Helpdesk\Http\Controllers\TicketCommentDeleteController;
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketCommentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketAttachmentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketStoreController;
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
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}/status', [TicketControlsController::class, 'updateStatus'])->name('tickets.status.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}/attachments', TicketAttachmentStoreController::class)->name('tickets.attachments.store');
Route::delete('/tickets/{ticket}/comments/{comment}', TicketCommentDeleteController::class)->name('tickets.comments.destroy');
Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
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);
}
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
{
Storage::fake('local');
@@ -56,9 +56,7 @@ final class KanbanBoardTest extends TestCase
Livewire::test(KanbanBoard::class)
->call('moveTicket', $ticket->id, $lookups['closed']->id)
->assertHasNoErrors()
->assertDispatched('helpdesk-kanban-ticket-moved')
->assertSee($ticket->reference);
->assertHasNoErrors();
$ticket->refresh();
@@ -138,4 +136,26 @@ final class KanbanBoardTest extends TestCase
->assertSee('Escalation 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
date: 2026-06-17
changes:
- Removed the missing Helpdesk Vite manifest call from ticket edit to stop the build/helpdesk manifest server error.
- Added direct multi-file Supporting files upload for existing tickets without requiring a reply/comment.
- Moved the ticket-level attachment button into the Supporting files card and restored a compact professional action style.
- Added Kanban drag/drop refresh dispatching, polling and stable Livewire keys so moved tickets refresh automatically.
- Added a dedicated existing-ticket supporting files upload endpoint that accepts and stores multiple attachments.
- Moved the supporting file picker and upload action into the Supporting files card and removed the disconnected visual flow.
- Standardised Choose files and Upload actions to the same professional size and button treatment.
- 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.
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.9.20"
"version": "1.9.21"
}