major updates

This commit is contained in:
Mark Gregory
2026-06-17 11:26:08 +01:00
parent 3b1fc48a3d
commit c4646d2f5c
10 changed files with 256 additions and 51 deletions
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
if (! Schema::hasColumn('helpdesk_comments', 'subject')) {
$table->string('subject', 180)->nullable()->after('user_id');
}
});
DB::table('helpdesk_comments')
->whereNull('subject')
->orWhere('subject', '')
->orderBy('id')
->select(['id', 'parent_id', 'body', 'body_text', 'is_internal'])
->chunkById(100, static function ($comments): void {
foreach ($comments as $comment) {
$plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) ($comment->body_text ?: $comment->body))) ?: '');
$subject = (bool) $comment->is_internal
? 'Internal note'
: ((int) ($comment->parent_id ?? 0) > 0 ? 'RE: ' : '').Str::limit($plain !== '' ? $plain : 'Response', 120, '');
DB::table('helpdesk_comments')
->where('id', $comment->id)
->update(['subject' => $subject]);
}
});
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
$table->index(['ticket_id', 'subject'], 'helpdesk_comments_ticket_subject_index');
});
}
public function down(): void
{
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
$table->dropIndex('helpdesk_comments_ticket_subject_index');
$table->dropColumn('subject');
}
});
}
};
@@ -32,6 +32,7 @@ final class TicketCommentStoreController extends Controller
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']); $allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'log', 'zip']);
$validated = $request->validate([ $validated = $request->validate([
'subject' => ['nullable', 'string', 'max:180'],
'commentBody' => ['nullable', 'string'], 'commentBody' => ['nullable', 'string'],
'internal' => ['nullable', 'boolean'], 'internal' => ['nullable', 'boolean'],
'parent_id' => [ 'parent_id' => [
@@ -48,16 +49,24 @@ final class TicketCommentStoreController extends Controller
abort_if($user === null, 403); abort_if($user === null, 403);
$body = trim((string) ($validated['commentBody'] ?? '')); $body = trim((string) ($validated['commentBody'] ?? ''));
$subject = trim((string) ($validated['subject'] ?? ''));
if ($body === '' && filled($validated['canned_response_id'] ?? null)) { if (filled($validated['canned_response_id'] ?? null)) {
$response = CannedResponse::query() $response = CannedResponse::query()
->where('is_active', true) ->where('is_active', true)
->findOrFail((int) $validated['canned_response_id']); ->findOrFail((int) $validated['canned_response_id']);
$body = trim($renderer->render($response->body, $ticket, $user)); if ($body === '') {
$body = trim($renderer->render($response->body, $ticket, $user));
}
if ($subject === '') {
$subject = (string) $response->name;
}
} }
$body = $this->normaliseHtmlBody($body); $body = $this->normaliseHtmlBody($body);
$subject = $this->normaliseSubject($subject, $body, filled($validated['parent_id'] ?? null));
validator(['commentBody' => $body], [ validator(['commentBody' => $body], [
'commentBody' => ['required', 'string', 'min:2'], 'commentBody' => ['required', 'string', 'min:2'],
@@ -73,12 +82,34 @@ final class TicketCommentStoreController extends Controller
internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user), internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user),
files: $uploadedFiles, files: $uploadedFiles,
parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null, parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null,
subject: $subject,
); );
return redirect() return redirect()
->route('helpdesk.tickets.show', $ticket) ->route('helpdesk.tickets.show', $ticket)
->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.'); ->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.');
} }
/**
* Derive a user-friendly Lotus Notes style response subject.
*/
private function normaliseSubject(string $subject, string $body, bool $isReply): string
{
$subject = trim(strip_tags(html_entity_decode($subject, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if ($subject !== '') {
return Str::limit($subject, 180, '');
}
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?: '');
if ($plain === '') {
return $isReply ? 'RE: Response' : 'Response';
}
return ($isReply ? 'RE: ' : '').Str::limit($plain, 120, '');
}
/** /**
* Keep supported formatting tags but strip scripts and unsafe inline handlers. * Keep supported formatting tags but strip scripts and unsafe inline handlers.
*/ */
+1
View File
@@ -18,6 +18,7 @@ final class Comment extends Model
'ticket_id', 'ticket_id',
'parent_id', 'parent_id',
'user_id', 'user_id',
'subject',
'body', 'body',
'body_text', 'body_text',
'is_internal', 'is_internal',
@@ -61,7 +61,7 @@
</div> </div>
<div class="p-6"> <div class="p-6">
<div class="prose max-w-none rounded-2xl border border-zinc-200 bg-zinc-50 p-5 text-zinc-900 dark:prose-invert dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"> <div class="prose max-w-none rounded-2xl border border-zinc-200 bg-zinc-50 p-6 text-zinc-900 shadow-inner dark:prose-invert dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 sm:min-h-40">
{!! $ticket->body !!} {!! $ticket->body !!}
</div> </div>
</div> </div>
@@ -101,16 +101,19 @@
@endif @endif
<section class="space-y-4"> <section class="space-y-4">
<details open class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <details class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900" data-helpdesk-conversation-section>
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 bg-gradient-to-r from-slate-950 via-slate-900 to-red-950 px-5 py-4 text-white marker:hidden"> <summary class="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-5 py-5 text-white marker:hidden">
<div> <div class="flex min-w-0 items-center gap-4">
<h2 class="flex items-center gap-2 text-lg font-extrabold"> <span class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-blue-500/25 text-white ring-1 ring-white/20">
<i class="fa-solid fa-comments" aria-hidden="true"></i> <i class="fa-solid fa-comments text-lg" aria-hidden="true"></i>
Conversation </span>
</h2> <div class="min-w-0">
<p class="mt-1 text-sm font-medium text-red-50/85">Lotus Notes-style document responses and responses to responses.</p> <p class="text-xs font-black uppercase tracking-[0.22em] text-blue-200">Conversation</p>
<h2 class="mt-1 text-xl font-extrabold tracking-tight">Document responses</h2>
<p class="mt-1 text-sm font-medium text-blue-50/85">Lotus Notes-style document responses and replies.</p>
</div>
</div> </div>
<span class="inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-extrabold ring-1 ring-white/20"> <span class="inline-flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-sm font-extrabold ring-1 ring-white/20">
{{ $visibleCommentCount }} item{{ $visibleCommentCount === 1 ? '' : 's' }} {{ $visibleCommentCount }} item{{ $visibleCommentCount === 1 ? '' : 's' }}
<i class="fa-solid fa-chevron-right transition group-open:rotate-90" aria-hidden="true"></i> <i class="fa-solid fa-chevron-right transition group-open:rotate-90" aria-hidden="true"></i>
</span> </span>
@@ -119,7 +122,7 @@
<div class="space-y-4 p-5"> <div class="space-y-4 p-5">
<div class="grid gap-3 md:grid-cols-[1fr_auto]"> <div class="grid gap-3 md:grid-cols-[1fr_auto]">
<label class="sr-only" for="helpdesk-comment-search">Search comments</label> <label class="sr-only" for="helpdesk-comment-search">Search comments</label>
<input id="helpdesk-comment-search" type="search" placeholder="Search comments, GUID, UNID, author or text..." class="block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-red-500 focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-helpdesk-comment-search> <input id="helpdesk-comment-search" type="search" placeholder="Search comments by subject, author, text or technical reference..." class="block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-red-500 focus:ring-red-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-helpdesk-comment-search>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<button type="button" class="inline-flex items-center justify-center rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700" data-helpdesk-expand-comments> <button type="button" class="inline-flex items-center justify-center rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-red-700" data-helpdesk-expand-comments>
<i class="fa-solid fa-up-right-and-down-left-from-center mr-2" aria-hidden="true"></i> <i class="fa-solid fa-up-right-and-down-left-from-center mr-2" aria-hidden="true"></i>
@@ -183,7 +186,11 @@
})->all(); })->all();
@endphp @endphp
@php
$cannedResponseNames = $cannedResponses->mapWithKeys(fn ($response) => [(string) $response->id => (string) $response->name])->all();
@endphp
<script type="application/json" id="helpdesk-canned-response-bodies">@json($cannedResponseBodies)</script> <script type="application/json" id="helpdesk-canned-response-bodies">@json($cannedResponseBodies)</script>
<script type="application/json" id="helpdesk-canned-response-names">@json($cannedResponseNames)</script>
@if ($cannedResponses->isNotEmpty()) @if ($cannedResponses->isNotEmpty())
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]"> <div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
@@ -213,6 +220,11 @@
$oldCommentBody = strip_tags($oldCommentBody, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>'); $oldCommentBody = strip_tags($oldCommentBody, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>');
@endphp @endphp
<div class="mb-4">
<label for="ticket-comment-subject" class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
<input id="ticket-comment-subject" name="subject" value="{{ old('subject') }}" placeholder="Response subject" class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-helpdesk-comment-subject>
</div>
<div> <div>
<label for="ticket-comment-body-editor" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Comment</label> <label for="ticket-comment-body-editor" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Comment</label>
<div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="ticket-comment-body-editor"> <div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="ticket-comment-body-editor">
@@ -264,8 +276,16 @@
const editor = form?.querySelector('[data-helpdesk-rich-editor]'); const editor = form?.querySelector('[data-helpdesk-rich-editor]');
const textarea = form?.querySelector('textarea[name="commentBody"]'); const textarea = form?.querySelector('textarea[name="commentBody"]');
const selected = select?.selectedOptions?.[0]; const selected = select?.selectedOptions?.[0];
const subjectInput = form?.querySelector('[data-helpdesk-comment-subject]');
const selectedId = String(selected?.value || '');
const cannedResponses = getCannedResponseBodies(); const cannedResponses = getCannedResponseBodies();
const body = normaliseCannedHtml(cannedResponses[String(selected?.value || '')] || selected?.dataset?.body || ''); const cannedResponseNames = getCannedResponseNames();
const body = normaliseCannedHtml(cannedResponses[selectedId] || selected?.dataset?.body || '');
if (subjectInput && selectedId !== '' && subjectInput.value.trim() === '') {
subjectInput.value = cannedResponseNames[selectedId] || selected?.textContent?.trim() || 'Response';
subjectInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (editor && textarea && body.trim() !== '') { if (editor && textarea && body.trim() !== '') {
insertHtmlAtEnd(editor, body); insertHtmlAtEnd(editor, body);
@@ -409,6 +429,21 @@
} }
} }
function getCannedResponseNames() {
const source = document.getElementById('helpdesk-canned-response-names');
if (! source) {
return {};
}
try {
return JSON.parse(source.textContent || '{}') || {};
} catch (error) {
console.error('Unable to read canned response names.', error);
return {};
}
}
function insertHtmlAtEnd(editor, html) { function insertHtmlAtEnd(editor, html) {
const current = editor.innerHTML.trim(); const current = editor.innerHTML.trim();
const cleaned = html.trim(); const cleaned = html.trim();
@@ -526,7 +561,12 @@
<aside class="space-y-6"> <aside class="space-y-6">
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket details</h2> <div class="mb-5 border-b border-blue-200 pb-3 dark:border-blue-900/70">
<h2 class="flex items-center gap-3 text-base font-extrabold text-zinc-900 dark:text-white">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-blue-600 text-white shadow-sm"><i class="fa-solid fa-file-lines" aria-hidden="true"></i></span>
Ticket details
</h2>
</div>
<dl class="space-y-3 text-sm"> <dl class="space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div> <div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div> <div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div>
@@ -538,7 +578,12 @@
</div> </div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">SLA metrics</h2> <div class="mb-5 border-b border-red-200 pb-3 dark:border-red-900/70">
<h2 class="flex items-center gap-3 text-base font-extrabold text-zinc-900 dark:text-white">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-red-600 text-white shadow-sm"><i class="fa-solid fa-clock" aria-hidden="true"></i></span>
SLA metrics
</h2>
</div>
<div class="grid gap-3 text-sm"> <div class="grid gap-3 text-sm">
<div class="rounded-xl border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' }} p-3"> <div class="rounded-xl border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' }} p-3">
<div class="text-xs font-semibold uppercase tracking-wide">First response</div> <div class="text-xs font-semibold uppercase tracking-wide">First response</div>
@@ -558,7 +603,12 @@
</div> </div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Requester profile</h2> <div class="mb-5 border-b border-emerald-200 pb-3 dark:border-emerald-900/70">
<h2 class="flex items-center gap-3 text-base font-extrabold text-zinc-900 dark:text-white">
<span class="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-600 text-white shadow-sm"><i class="fa-solid fa-user" aria-hidden="true"></i></span>
Requester profile
</h2>
</div>
<dl class="space-y-3 text-sm"> <dl class="space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Name</dt><dd>{{ $ticket->owner?->name ?? 'Unknown' }}</dd></div> <div class="flex justify-between gap-4"><dt class="text-zinc-500">Name</dt><dd>{{ $ticket->owner?->name ?? 'Unknown' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Email</dt><dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd></div> <div class="flex justify-between gap-4"><dt class="text-zinc-500">Email</dt><dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd></div>
@@ -10,8 +10,18 @@
$currentTicket = $ticket ?? $comment->ticket ?? null; $currentTicket = $ticket ?? $comment->ticket ?? null;
$replyCount = $commentReplies->count(); $replyCount = $commentReplies->count();
$isInternal = (bool) $comment->is_internal; $isInternal = (bool) $comment->is_internal;
$borderClass = $isInternal ? 'border-amber-300 dark:border-amber-700' : 'border-red-200 dark:border-red-900/70'; $borderClass = $isInternal ? 'border-purple-400/70 dark:border-purple-600/70' : 'border-zinc-200 dark:border-zinc-700';
$railClass = $isInternal ? 'bg-amber-500' : 'bg-red-500'; $railClass = $isInternal ? 'bg-purple-500' : ((int) $level % 3 === 0 ? 'bg-red-500' : (((int) $level % 3 === 1) ? 'bg-blue-500' : 'bg-emerald-500'));
$commentSubject = trim((string) ($comment->subject ?? ''));
if ($commentSubject === '') {
$plainSubject = trim((string) ($comment->body_text ?? strip_tags((string) $comment->body)));
$commentSubject = Str::limit($plainSubject !== '' ? $plainSubject : 'Response', 72);
}
if ($isInternal && ! Str::startsWith(Str::lower($commentSubject), 'internal')) {
$commentSubject = 'Internal note';
}
$renderedBody = html_entity_decode((string) $comment->body, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $renderedBody = html_entity_decode((string) $comment->body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
while (str_contains($renderedBody, '&lt;') || str_contains($renderedBody, '&gt;') || str_contains($renderedBody, '&amp;lt;')) { while (str_contains($renderedBody, '&lt;') || str_contains($renderedBody, '&gt;') || str_contains($renderedBody, '&amp;lt;')) {
@@ -49,32 +59,34 @@
@endphp @endphp
@if (! $isInternal || $canSeeInternalNotes) @if (! $isInternal || $canSeeInternalNotes)
<details class="group rounded-2xl border {{ $borderClass }} bg-white shadow-sm transition dark:bg-zinc-900" data-helpdesk-comment-card data-helpdesk-comment-text="{{ e(Str::lower(strip_tags((string) $comment->body).' '.($comment->body_text ?? '').' '.($commentUser?->name ?? '').' '.($comment->unid ?? '').' '.($comment->guid ?? ''))) }}" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem"> <details class="group rounded-2xl border {{ $borderClass }} bg-white shadow-sm transition dark:bg-zinc-900" data-helpdesk-comment-card data-helpdesk-comment-text="{{ e(Str::lower($commentSubject.' '.strip_tags((string) $comment->body).' '.($comment->body_text ?? '').' '.($commentUser?->name ?? '').' '.($comment->unid ?? '').' '.($comment->guid ?? ''))) }}" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
<summary class="flex cursor-pointer list-none items-start gap-3 rounded-2xl p-4 marker:hidden"> <summary class="flex cursor-pointer list-none items-start gap-3 rounded-2xl p-4 marker:hidden">
<span class="mt-1 inline-flex h-12 w-2 shrink-0 rounded-full {{ $railClass }}" aria-hidden="true"></span> <span class="mt-1 inline-flex h-12 w-2 shrink-0 rounded-full {{ $railClass }}" aria-hidden="true"></span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="font-bold text-zinc-900 dark:text-zinc-100">{{ $commentUser?->name ?? 'Unknown user' }}</span> <span class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-zinc-100 text-zinc-700 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700">
<span class="text-xs font-semibold text-zinc-500">{{ $comment->created_at?->format('d M Y H:i') }}</span> <i class="fa-solid {{ $isInternal ? 'fa-note-sticky' : 'fa-envelope' }}" aria-hidden="true"></i>
<span class="rounded-full bg-blue-100 px-2.5 py-1 text-[11px] font-extrabold text-blue-800 dark:bg-blue-950 dark:text-blue-100">{{ $replyCount }} response{{ $replyCount === 1 ? '' : 's' }}</span> </span>
@if ($commentAttachments->isNotEmpty()) <div class="min-w-0 flex-1">
<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-extrabold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-100"> <div class="flex flex-wrap items-center gap-2">
<i class="fa-solid fa-paperclip mr-1" aria-hidden="true"></i>{{ $commentAttachments->count() }} <span class="truncate text-base font-extrabold text-zinc-900 dark:text-zinc-100">{{ $commentSubject }}</span>
</span> <span class="rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] font-extrabold text-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">{{ $replyCount }} {{ $replyCount === 1 ? 'reply' : 'replies' }}</span>
@endif @if ($commentAttachments->isNotEmpty())
@if ($isInternal) <span class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-extrabold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-100">
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-extrabold text-amber-800 dark:bg-amber-950 dark:text-amber-100">MPBA internal</span> <i class="fa-solid fa-paperclip mr-1" aria-hidden="true"></i>{{ $commentAttachments->count() }}
@endif </span>
</div> @endif
@if ($isInternal)
<div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] font-mono text-zinc-400"> <span class="rounded-full bg-red-600 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wide text-white">Internal</span>
@if (! blank($comment->unid)) @endif
<span>UNID {{ Str::limit((string) $comment->unid, 16, '') }}</span> </div>
@endif <div class="mt-1 flex flex-wrap items-center gap-2 text-xs font-semibold text-zinc-500 dark:text-zinc-400">
@if (! blank($comment->guid)) <span>{{ $commentUser?->name ?? 'Unknown user' }}</span>
<span>GUID {{ Str::limit((string) $comment->guid, 18, '') }}</span> <span aria-hidden="true">•</span>
@endif <span>{{ $comment->created_at?->format('d M Y H:i') }}</span>
</div>
</div>
</div> </div>
</div> </div>
@@ -116,6 +128,10 @@
<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> <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>
@csrf @csrf
<input type="hidden" name="parent_id" value="{{ $comment->id }}"> <input type="hidden" name="parent_id" value="{{ $comment->id }}">
<div class="mb-3">
<label for="reply-subject-{{ $comment->id }}" class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
<input id="reply-subject-{{ $comment->id }}" name="subject" value="{{ old('subject', 'RE: '.$commentSubject) }}" class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm font-semibold text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
</div>
<label for="reply-body-{{ $comment->id }}" class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Response to response</label> <label for="reply-body-{{ $comment->id }}" class="mb-2 block text-sm font-bold text-zinc-700 dark:text-zinc-200">Response to response</label>
<div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="reply-body-{{ $comment->id }}"> <div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="reply-body-{{ $comment->id }}">
<button type="button" data-helpdesk-tag="strong" class="rounded-lg border px-2 py-1 text-xs font-black">B</button> <button type="button" data-helpdesk-tag="strong" class="rounded-lg border px-2 py-1 text-xs font-black">B</button>
+11 -5
View File
@@ -102,9 +102,9 @@ final class TicketService
* *
* @param array<int, UploadedFile> $files * @param array<int, UploadedFile> $files
*/ */
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null): Comment public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null, ?string $subject = null): Comment
{ {
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId): Comment { return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId, $subject): Comment {
$parent = $parentId === null $parent = $parentId === null
? null ? null
: Comment::query() : Comment::query()
@@ -117,8 +117,7 @@ final class TicketService
$rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid; $rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid;
$plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: ''); $plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: '');
/** @var Comment $comment */ $payload = [
$comment = $ticket->comments()->create([
'parent_id' => $parentId, 'parent_id' => $parentId,
'user_id' => $user->id, 'user_id' => $user->id,
'body' => $body, 'body' => $body,
@@ -132,7 +131,14 @@ final class TicketService
'sync_sequence' => 1, 'sync_sequence' => 1,
'sync_hash' => hash('sha256', $body), 'sync_hash' => hash('sha256', $body),
'source_system' => 'wms-helpdesk', 'source_system' => 'wms-helpdesk',
]); ];
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
$payload['subject'] = trim((string) ($subject ?: ($parentId === null ? 'Response' : 'RE: Response')));
}
/** @var Comment $comment */
$comment = $ticket->comments()->create($payload);
$ticket->forceFill(['last_activity_at' => now()])->save(); $ticket->forceFill(['last_activity_at' => now()])->save();
if (! $internal) { if (! $internal) {
@@ -79,11 +79,17 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
->waitForText('Hello Response Browser User', 10) ->waitForText('Hello Response Browser User', 10)
->assertSee('Inserted canned browser response') ->assertSee('Inserted canned browser response')
->assertDontSee('<p>Hello') ->assertDontSee('<p>Hello')
->assertInputValue('subject', 'Browser canned response')
->assertInputValue('commentBody', '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>') ->assertInputValue('commentBody', '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>')
->press('@post-comment') ->press('@post-comment')
->waitForText('Hello Response Browser User', 10) ->waitFor('@helpdesk-ticket-show', 10);
->assertSee('Inserted canned browser response')
->assertDontSee('<p>Hello'); $this->openHelpdeskConversation($browser);
$browser->waitForText('Browser canned response', 10)
->assertSee('Browser canned response')
->assertDontSee('UNID')
->assertDontSee('GUID');
$comment = Comment::query() $comment = Comment::query()
->where('ticket_id', $ticket->id) ->where('ticket_id', $ticket->id)
@@ -91,17 +97,24 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
->where('body', 'like', '%Hello Response Browser User%') ->where('body', 'like', '%Hello Response Browser User%')
->firstOrFail(); ->firstOrFail();
$this->openHelpdeskConversation($browser);
$browser->press('@reply-to-comment-'.$comment->id) $browser->press('@reply-to-comment-'.$comment->id)
->waitFor('@reply-body-'.$comment->id) ->waitFor('@reply-body-'.$comment->id)
->type('@reply-body-'.$comment->id, 'Nested browser response') ->type('@reply-body-'.$comment->id, 'Nested browser response')
->press('@post-reply-'.$comment->id) ->press('@post-reply-'.$comment->id)
->waitForText('Nested browser response', 10) ->waitFor('@helpdesk-ticket-show', 10);
->assertSee('Nested browser response');
$this->openHelpdeskConversation($browser);
$browser->waitForText('RE: Browser canned response', 10)
->assertSee('RE: Browser canned response');
}); });
$this->assertDatabaseHas('helpdesk_comments', [ $this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'parent_id' => null, 'parent_id' => null,
'subject' => 'Browser canned response',
'body' => '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>', 'body' => '<p>Hello Response Browser User</p><p>Inserted canned browser response for '.$ticket->reference.'</p>',
]); ]);
@@ -114,10 +127,30 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$this->assertDatabaseHas('helpdesk_comments', [ $this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'parent_id' => $parentId, 'parent_id' => $parentId,
'subject' => 'RE: Browser canned response',
'body' => 'Nested browser response', 'body' => 'Nested browser response',
]); ]);
} }
private function openHelpdeskConversation(Browser $browser): void
{
$browser->script(<<<JS
const conversation = document.querySelector('[data-helpdesk-conversation-section]');
if (conversation) {
conversation.open = true;
}
document.querySelectorAll('[data-helpdesk-comment-card]').forEach((card) => {
card.open = true;
});
const update = window.updateCommentToggleButtons;
if (typeof update === 'function') {
update();
}
JS);
$browser->pause(150);
}
private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void private function setHelpdeskEditor(Browser $browser, string $dusk, string $html): void
{ {
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR); $encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
@@ -74,6 +74,7 @@ final class TicketConversationTest extends TestCase
$this->actingAs($owner) $this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [ ->post(route('helpdesk.tickets.comments.store', $ticket), [
'subject' => 'Searchable response subject',
'commentBody' => '<p><strong>Searchable response</strong></p>', 'commentBody' => '<p><strong>Searchable response</strong></p>',
]) ])
->assertRedirect(route('helpdesk.tickets.show', $ticket)); ->assertRedirect(route('helpdesk.tickets.show', $ticket));
@@ -83,6 +84,7 @@ final class TicketConversationTest extends TestCase
self::assertNotEmpty($comment->guid); self::assertNotEmpty($comment->guid);
self::assertNotEmpty($comment->unid); self::assertNotEmpty($comment->unid);
self::assertSame($comment->unid, $comment->root_unid); self::assertSame($comment->unid, $comment->root_unid);
self::assertSame('Searchable response subject', $comment->subject);
self::assertSame('Searchable response', $comment->body_text); self::assertSame('Searchable response', $comment->body_text);
self::assertSame(1, $comment->sync_sequence); self::assertSame(1, $comment->sync_sequence);
} }
@@ -97,6 +99,7 @@ final class TicketConversationTest extends TestCase
$this->actingAs($owner) $this->actingAs($owner)
->post(route('helpdesk.tickets.comments.store', $ticket), [ ->post(route('helpdesk.tickets.comments.store', $ticket), [
'parent_id' => $parent->id, 'parent_id' => $parent->id,
'subject' => 'RE: Parent response',
'commentBody' => '<p>Child response</p>', 'commentBody' => '<p>Child response</p>',
]) ])
->assertRedirect(route('helpdesk.tickets.show', $ticket)); ->assertRedirect(route('helpdesk.tickets.show', $ticket));
@@ -105,6 +108,7 @@ final class TicketConversationTest extends TestCase
self::assertSame($parent->unid, $reply->parent_unid); self::assertSame($parent->unid, $reply->parent_unid);
self::assertSame($parent->root_unid, $reply->root_unid); self::assertSame($parent->root_unid, $reply->root_unid);
self::assertSame('RE: Parent response', $reply->subject);
} }
public function test_agent_can_update_status_to_closed_and_priority(): void public function test_agent_can_update_status_to_closed_and_priority(): void
+11
View File
@@ -225,3 +225,14 @@ versions:
- Updated TicketService comment creation to populate Lotus-style replication identity metadata for document responses and nested responses. - Updated TicketService comment creation to populate Lotus-style replication identity metadata for document responses and nested responses.
- Updated Helpdesk conversation tests to cover POST comment storage, nested reply identity metadata and searchable comment text. - Updated Helpdesk conversation tests to cover POST comment storage, nested reply identity metadata and searchable comment text.
- Kept module.json version in sync with changelog.yaml. - Kept module.json version in sync with changelog.yaml.
- version: 1.7.0
date: 2026-06-17
type: changed
summary: "Polished Lotus Notes-style ticket document and conversation headers, fully collapsed conversation default, subject-led comment cards, and hidden replication identifiers."
changes:
- "Conversation panel now starts fully collapsed and only reveals comment cards when opened."
- "Comment cards now show a professional subject header with author/date metadata and no visible GUID/UNID."
- "Comment subjects are stored for public responses, canned responses, and nested replies while GUID/UNID remain searchable."
- "Ticket details, SLA metrics, requester profile, ticket document, attachments, and conversation headers received consistent professional styling."
- "Browser and feature tests were updated for collapsed conversations, comment subjects, and hidden replication identifiers."
+1 -1
View File
@@ -22,5 +22,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
], ],
"files": [], "files": [],
"version": "1.3.13" "version": "1.7.0"
} }