ui updates for ticket layout

This commit is contained in:
Mark Gregory
2026-06-17 12:49:37 +01:00
parent 2828e617ed
commit 0b4c821187
11 changed files with 274 additions and 62 deletions
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
if (! Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
$table->foreignId('deleted_by')->nullable()->after('tombstoned_at')->constrained('users')->nullOnDelete();
}
if (! Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
$table->string('delete_reason', 255)->nullable()->after('deleted_by');
}
});
}
public function down(): void
{
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
$table->dropConstrainedForeignId('deleted_by');
}
if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
$table->dropColumn('delete_reason');
}
});
}
};
@@ -0,0 +1,75 @@
<?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\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Ticket;
final class TicketCommentDeleteController extends Controller
{
/**
* Tombstone a ticket response. Only MPBA/platform super administrators may delete responses.
*/
public function __invoke(Request $request, Ticket $ticket, Comment $comment): RedirectResponse
{
$user = $request->user();
abort_unless($user !== null && method_exists($user, 'isMpbaPlatformAdmin') && $user->isMpbaPlatformAdmin(), 403);
abort_unless((int) $comment->ticket_id === (int) $ticket->id, 404);
DB::transaction(function () use ($comment, $ticket, $user): void {
$updates = [];
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
$updates['tombstoned_at'] = now();
}
if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
$updates['deleted_by'] = (int) $user->id;
}
if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
$updates['delete_reason'] = 'Deleted by MPBA platform administrator.';
}
if (Schema::hasColumn('helpdesk_comments', 'sync_sequence')) {
$updates['sync_sequence'] = ((int) ($comment->sync_sequence ?? 0)) + 1;
}
if (Schema::hasColumn('helpdesk_comments', 'sync_hash')) {
$updates['sync_hash'] = hash('sha256', implode('|', [
(string) ($comment->guid ?? $comment->id),
'deleted',
now()->toISOString(),
]));
}
if (Schema::hasColumn('helpdesk_comments', 'updated_at')) {
$updates['updated_at'] = now();
}
if ($updates !== []) {
DB::table('helpdesk_comments')
->where('id', $comment->id)
->update($updates);
} else {
$comment->delete();
}
if (Schema::hasColumn('helpdesk_tickets', 'last_activity_at')) {
$ticket->forceFill(['last_activity_at' => now()])->save();
}
});
return redirect()
->route('helpdesk.tickets.show', $ticket)
->with('ticket-comment-deleted', 'Response deleted.');
}
}
+3
View File
@@ -303,6 +303,9 @@ final class TicketShow extends Component
$comments = Comment::query()
->where('ticket_id', $ticket->id)
->when(Schema::hasColumn('helpdesk_comments', 'tombstoned_at'), static function ($query): void {
$query->whereNull('tombstoned_at');
})
->when(! $this->canSeeInternalNotes(), static function ($query): void {
$query->where('is_internal', false);
})
+9
View File
@@ -35,6 +35,8 @@ final class Comment extends Model
'replicated_at',
'synced_at',
'tombstoned_at',
'deleted_by',
'delete_reason',
];
/** @var array<string, string> */
@@ -44,6 +46,7 @@ final class Comment extends Model
'replicated_at' => 'datetime',
'synced_at' => 'datetime',
'tombstoned_at' => 'datetime',
'deleted_by' => 'integer',
];
/** @return BelongsTo<Ticket, Comment> */
@@ -64,6 +67,12 @@ final class Comment extends Model
return $this->hasMany(self::class, 'parent_id')->oldest();
}
/** @return BelongsTo<User, Comment> */
public function deleter(): BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by');
}
/** @return BelongsTo<User, Comment> */
public function user(): BelongsTo
{
@@ -3,11 +3,6 @@
'resources/css/app.css',
'resources/js/app.js',
])
@vite([
'Resources/assets/css/app.css',
'Resources/assets/js/app.js',
], 'build/helpdesk')
@include('helpdesk::partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Create ticket',
@@ -181,34 +181,31 @@
.hd-helpdesk-surface [data-helpdesk-file-button] {
display: inline-flex !important;
width: auto !important;
min-width: 144px !important;
height: 42px !important;
align-items: center !important;
justify-content: center !important;
align-self: center !important;
gap: 9px !important;
border-radius: 12px !important;
border: 1px solid rgba(34, 197, 94, 0.55) !important;
background: linear-gradient(135deg, #16a34a, #22c55e) !important;
gap: 10px !important;
min-width: 168px !important;
height: 48px !important;
padding: 0 22px !important;
border: 1px solid rgba(134, 239, 172, 0.70) !important;
border-radius: 14px !important;
background: linear-gradient(135deg, #15803d 0%, #16a34a 55%, #22c55e 100%) !important;
color: #ffffff !important;
box-shadow: 0 10px 22px rgba(22, 163, 74, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.18) !important;
font-size: 0.88rem !important;
font-weight: 800 !important;
box-shadow: 0 14px 28px rgba(22, 163, 74, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.20) !important;
font-size: 0.92rem !important;
font-weight: 900 !important;
line-height: 1 !important;
letter-spacing: 0.005em !important;
padding: 0 16px !important;
text-align: center !important;
white-space: nowrap !important;
vertical-align: middle !important;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease, transform 150ms ease, box-shadow 150ms ease !important;
cursor: pointer !important;
transition: transform 150ms ease, box-shadow 150ms ease, background 150ms ease !important;
}
.hd-helpdesk-surface [data-helpdesk-file-button]:hover {
transform: translateY(-1px) !important;
border-color: rgba(134, 239, 172, 0.85) !important;
background: linear-gradient(135deg, #15803d, #16a34a) !important;
background: linear-gradient(135deg, #166534 0%, #15803d 55%, #16a34a 100%) !important;
color: #ffffff !important;
box-shadow: 0 12px 26px rgba(22, 163, 74, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.20) !important;
box-shadow: 0 16px 32px rgba(22, 163, 74, 0.36), inset 0 1px 0 rgba(255, 255, 255, 0.22) !important;
}
.hd-helpdesk-surface [data-helpdesk-file-button] i,
@@ -217,15 +214,18 @@
align-items: center !important;
justify-content: center !important;
line-height: 1 !important;
margin: 0 !important;
padding: 0 !important;
vertical-align: middle !important;
}
.hd-helpdesk-surface [data-helpdesk-file-button] i {
height: 18px !important;
width: 18px !important;
border-radius: 6px !important;
background: rgba(255, 255, 255, 0.16) !important;
width: 22px !important;
height: 22px !important;
border-radius: 8px !important;
background: rgba(255, 255, 255, 0.18) !important;
color: #ffffff !important;
font-size: 0.74rem !important;
font-size: 0.8rem !important;
}
.hd-helpdesk-surface .hd-select {
@@ -505,45 +505,44 @@
@endif
@endcan
<details class="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-50/80 shadow-sm dark:border-zinc-800 dark:bg-zinc-950/40">
<summary class="group flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 transition hover:bg-zinc-100/80 dark:hover:bg-zinc-900/70 [&::-webkit-details-marker]:hidden">
<span class="flex min-w-0 items-center gap-3">
<span class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/70 dark:bg-blue-950/50 dark:text-blue-300">
<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.22em] text-blue-700 dark:text-blue-300">Attachments</span>
<span class="block truncate text-sm font-semibold text-zinc-600 dark:text-zinc-400">Pin screenshots, logs and supporting documents.</span>
<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-zinc-200 bg-white px-3 py-1 text-xs font-bold text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200" data-helpdesk-file-count>0 files</span>
<i class="fa-solid fa-chevron-down text-xs text-zinc-500 transition group-open:rotate-180" aria-hidden="true"></i>
<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 px-5 py-5 text-sm dark:border-zinc-800">
<div class="rounded-2xl border border-zinc-200 bg-white/70 p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-950/45">
<div data-helpdesk-attachment-picker-row>
<div class="flex min-w-0 items-center gap-3">
<span class="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/70 dark:bg-blue-950/55 dark:text-blue-300">
<i class="fa-solid fa-paperclip text-sm" aria-hidden="true"></i>
<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-bold text-zinc-900 dark:text-zinc-100">Attach supporting files</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-500 dark:text-zinc-400" data-helpdesk-file-label>No files attached</span>
</span>
</div>
<label
data-helpdesk-file-button
class="cursor-pointer"
>
<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 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
@@ -118,11 +118,22 @@
@endif
@if ($currentTicket !== null)
<div class="mt-4 flex justify-end">
<div class="mt-4 flex flex-wrap justify-end gap-2">
<button type="button" dusk="reply-to-comment-{{ $comment->id }}" data-helpdesk-reply-toggle="{{ $replyFormId }}" class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
<i class="fa-solid fa-reply mr-2" aria-hidden="true"></i>
Respond
</button>
@if (auth()->user()?->isMpbaPlatformAdmin())
<form method="POST" action="{{ route('helpdesk.tickets.comments.destroy', [$currentTicket, $comment]) }}" onsubmit="return confirm('Delete this response? This will remove it from the visible conversation but keep the replication identity/audit trail.');">
@csrf
@method('DELETE')
<button type="submit" dusk="delete-comment-{{ $comment->id }}" class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400/70">
<i class="fa-solid fa-trash-can mr-2" aria-hidden="true"></i>
Delete
</button>
</form>
@endif
</div>
<form id="{{ $replyFormId }}" method="POST" action="{{ route('helpdesk.tickets.comments.store', $currentTicket) }}" enctype="multipart/form-data" class="mt-4 hidden rounded-xl border border-red-200 bg-red-50/60 p-4 dark:border-red-900 dark:bg-red-950/20" data-helpdesk-comment-form>
@@ -151,14 +162,33 @@
@endif
@endcan
<div class="mt-4 rounded-xl border border-dashed border-blue-300 p-3 dark:border-blue-800">
<label class="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
<i class="fa-solid fa-thumbtack" aria-hidden="true"></i>
Pin attachments
<input name="attachments[]" type="file" multiple class="sr-only" data-helpdesk-file-input>
</label>
<span class="ml-2 text-xs font-semibold text-zinc-500" data-helpdesk-file-label>No files pinned</span>
</div>
<details class="mt-4 overflow-hidden rounded-xl 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-3 bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-4 py-3 text-white [&::-webkit-details-marker]:hidden">
<span class="flex min-w-0 items-center gap-3">
<span class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-blue-400/30 bg-blue-500/15 text-blue-200">
<i class="fa-solid fa-paperclip text-xs" aria-hidden="true"></i>
</span>
<span class="min-w-0">
<span class="block text-[10px] font-black uppercase tracking-[0.22em] text-blue-200">Attachments</span>
<span class="block truncate text-xs font-semibold text-zinc-300">Pin response files.</span>
</span>
</span>
<span class="flex shrink-0 items-center gap-2">
<span class="rounded-full border border-white/10 bg-white/10 px-2.5 py-1 text-[10px] font-black text-white" data-helpdesk-file-count>0 files</span>
<i class="fa-solid fa-chevron-down text-[10px] text-blue-200 transition group-open:rotate-180" aria-hidden="true"></i>
</span>
</summary>
<div class="p-4">
<div class="flex min-h-[4.5rem] flex-col gap-3 rounded-xl border border-zinc-200 bg-zinc-50/80 p-4 dark:border-zinc-800 dark:bg-zinc-950/70 sm:flex-row sm:items-center sm:justify-between">
<span class="text-xs font-semibold text-zinc-500 dark:text-zinc-400" data-helpdesk-file-label>No files attached</span>
<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>
</details>
<div class="mt-4 flex justify-end gap-2">
<button type="button" data-helpdesk-reply-toggle="{{ $replyFormId }}" class="inline-flex items-center justify-center rounded-lg bg-slate-600 px-3 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400/70">Cancel</button>
+2
View File
@@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route;
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\TicketCommentStoreController;
use Modules\Helpdesk\Http\Controllers\TicketStoreController;
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
@@ -31,6 +32,7 @@ Route::post('/tickets/{ticket}/agent', [TicketControlsController::class, '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}/comments', TicketCommentStoreController::class)->name('tickets.comments.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)
->whereNumber('attachment')
@@ -5,9 +5,13 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\Company;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
@@ -111,6 +115,54 @@ final class TicketConversationTest extends TestCase
self::assertSame('RE: Parent response', $reply->subject);
}
public function test_post_endpoint_accepts_multiple_comment_attachments(): void
{
Storage::fake((string) config('helpdesk.disk', 'public'));
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [
'subject' => 'Attachments attached',
'commentBody' => '<p>Files attached.</p>',
'attachments' => [
UploadedFile::fake()->create('first-log.txt', 8, 'text/plain'),
UploadedFile::fake()->create('second-log.txt', 8, 'text/plain'),
],
])
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$comment = Comment::query()->where('ticket_id', $ticket->id)->firstOrFail();
self::assertSame(2, Attachment::query()->where('comment_id', $comment->id)->count());
}
public function test_mpba_platform_admin_can_delete_comment(): void
{
$fixtures = $this->seedLookupData();
$company = Company::query()->create([
'name' => 'MPBA Limited',
'slug' => 'mpba-limited',
'status' => 'active',
'timezone' => 'Europe/London',
'is_system_company' => true,
]);
$admin = $this->user(['company_id' => $company->id, 'is_platform_admin' => true]);
$owner = $this->user(['company_id' => $company->id]);
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$comment = app(\Modules\Helpdesk\Services\TicketService::class)->comment($ticket, $owner, '<p>Delete me</p>');
$this->actingAs($admin)
->delete(route('helpdesk.tickets.comments.destroy', [$ticket, $comment]))
->assertRedirect(route('helpdesk.tickets.show', $ticket));
$comment->refresh();
self::assertNotNull($comment->tombstoned_at);
}
public function test_agent_can_update_status_to_closed_and_priority(): void
{
Mail::fake();
+11
View File
@@ -333,3 +333,14 @@ versions:
date: 2026-06-17
changes:
- Centred the attachment chooser content vertically and changed the attachment action to a professional green button treatment.
- version: 1.9.19
date: 2026-06-17
changes:
- Moved response attachment selection into the professional Supporting files card with a centred green attachment action.
- Preserved multiple file uploads for comments, responses, and response-to-response replies.
- Added MPBA platform administrator response deletion using replication-safe tombstoning rather than destructive removal.
- Added deletion audit fields for helpdesk comments.
- Removed direct Helpdesk Vite manifest loading from ticket create when the module build manifest is not present.
- Updated Helpdesk conversation feature coverage for multiple attachments and super-admin response deletion.
- 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.18"
"version": "1.9.19"
}