major updates
This commit is contained in:
+53
@@ -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']);
|
||||
|
||||
$validated = $request->validate([
|
||||
'subject' => ['nullable', 'string', 'max:180'],
|
||||
'commentBody' => ['nullable', 'string'],
|
||||
'internal' => ['nullable', 'boolean'],
|
||||
'parent_id' => [
|
||||
@@ -48,16 +49,24 @@ final class TicketCommentStoreController extends Controller
|
||||
abort_if($user === null, 403);
|
||||
|
||||
$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()
|
||||
->where('is_active', true)
|
||||
->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);
|
||||
$subject = $this->normaliseSubject($subject, $body, filled($validated['parent_id'] ?? null));
|
||||
|
||||
validator(['commentBody' => $body], [
|
||||
'commentBody' => ['required', 'string', 'min:2'],
|
||||
@@ -73,12 +82,34 @@ final class TicketCommentStoreController extends Controller
|
||||
internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user),
|
||||
files: $uploadedFiles,
|
||||
parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null,
|
||||
subject: $subject,
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->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.
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ final class Comment extends Model
|
||||
'ticket_id',
|
||||
'parent_id',
|
||||
'user_id',
|
||||
'subject',
|
||||
'body',
|
||||
'body_text',
|
||||
'is_internal',
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
<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 !!}
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,16 +101,19 @@
|
||||
@endif
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<div>
|
||||
<h2 class="flex items-center gap-2 text-lg font-extrabold">
|
||||
<i class="fa-solid fa-comments" aria-hidden="true"></i>
|
||||
Conversation
|
||||
</h2>
|
||||
<p class="mt-1 text-sm font-medium text-red-50/85">Lotus Notes-style document responses and responses to responses.</p>
|
||||
<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-blue-950 to-slate-900 px-5 py-5 text-white marker:hidden">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<span class="inline-flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-blue-500/25 text-white ring-1 ring-white/20">
|
||||
<i class="fa-solid fa-comments text-lg" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<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>
|
||||
<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' }}
|
||||
<i class="fa-solid fa-chevron-right transition group-open:rotate-90" aria-hidden="true"></i>
|
||||
</span>
|
||||
@@ -119,7 +122,7 @@
|
||||
<div class="space-y-4 p-5">
|
||||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<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">
|
||||
<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>
|
||||
@@ -183,7 +186,11 @@
|
||||
})->all();
|
||||
@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-names">@json($cannedResponseNames)</script>
|
||||
|
||||
@if ($cannedResponses->isNotEmpty())
|
||||
<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>');
|
||||
@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>
|
||||
<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">
|
||||
@@ -264,8 +276,16 @@
|
||||
const editor = form?.querySelector('[data-helpdesk-rich-editor]');
|
||||
const textarea = form?.querySelector('textarea[name="commentBody"]');
|
||||
const selected = select?.selectedOptions?.[0];
|
||||
const subjectInput = form?.querySelector('[data-helpdesk-comment-subject]');
|
||||
const selectedId = String(selected?.value || '');
|
||||
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() !== '') {
|
||||
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) {
|
||||
const current = editor.innerHTML.trim();
|
||||
const cleaned = html.trim();
|
||||
@@ -526,7 +561,12 @@
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -538,7 +578,12 @@
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
@@ -558,7 +603,12 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -10,8 +10,18 @@
|
||||
$currentTicket = $ticket ?? $comment->ticket ?? null;
|
||||
$replyCount = $commentReplies->count();
|
||||
$isInternal = (bool) $comment->is_internal;
|
||||
$borderClass = $isInternal ? 'border-amber-300 dark:border-amber-700' : 'border-red-200 dark:border-red-900/70';
|
||||
$railClass = $isInternal ? 'bg-amber-500' : 'bg-red-500';
|
||||
$borderClass = $isInternal ? 'border-purple-400/70 dark:border-purple-600/70' : 'border-zinc-200 dark:border-zinc-700';
|
||||
$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');
|
||||
|
||||
while (str_contains($renderedBody, '<') || str_contains($renderedBody, '>') || str_contains($renderedBody, '&lt;')) {
|
||||
@@ -49,32 +59,34 @@
|
||||
@endphp
|
||||
|
||||
@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">
|
||||
<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="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="text-xs font-semibold text-zinc-500">{{ $comment->created_at?->format('d M Y H:i') }}</span>
|
||||
<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>
|
||||
@if ($commentAttachments->isNotEmpty())
|
||||
<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">
|
||||
<i class="fa-solid fa-paperclip mr-1" aria-hidden="true"></i>{{ $commentAttachments->count() }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($isInternal)
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] font-mono text-zinc-400">
|
||||
@if (! blank($comment->unid))
|
||||
<span>UNID {{ Str::limit((string) $comment->unid, 16, '') }}</span>
|
||||
@endif
|
||||
@if (! blank($comment->guid))
|
||||
<span>GUID {{ Str::limit((string) $comment->guid, 18, '') }}</span>
|
||||
@endif
|
||||
<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">
|
||||
<i class="fa-solid {{ $isInternal ? 'fa-note-sticky' : 'fa-envelope' }}" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate text-base font-extrabold text-zinc-900 dark:text-zinc-100">{{ $commentSubject }}</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>
|
||||
@if ($commentAttachments->isNotEmpty())
|
||||
<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">
|
||||
<i class="fa-solid fa-paperclip mr-1" aria-hidden="true"></i>{{ $commentAttachments->count() }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($isInternal)
|
||||
<span class="rounded-full bg-red-600 px-2.5 py-1 text-[11px] font-extrabold uppercase tracking-wide text-white">Internal</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
<span>{{ $commentUser?->name ?? 'Unknown user' }}</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{{ $comment->created_at?->format('d M Y H:i') }}</span>
|
||||
</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>
|
||||
@csrf
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -102,9 +102,9 @@ final class TicketService
|
||||
*
|
||||
* @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
|
||||
? null
|
||||
: Comment::query()
|
||||
@@ -117,8 +117,7 @@ final class TicketService
|
||||
$rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid;
|
||||
$plainTextBody = trim(preg_replace('/\s+/', ' ', strip_tags($body)) ?: '');
|
||||
|
||||
/** @var Comment $comment */
|
||||
$comment = $ticket->comments()->create([
|
||||
$payload = [
|
||||
'parent_id' => $parentId,
|
||||
'user_id' => $user->id,
|
||||
'body' => $body,
|
||||
@@ -132,7 +131,14 @@ final class TicketService
|
||||
'sync_sequence' => 1,
|
||||
'sync_hash' => hash('sha256', $body),
|
||||
'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();
|
||||
if (! $internal) {
|
||||
|
||||
@@ -79,11 +79,17 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
|
||||
->waitForText('Hello Response Browser User', 10)
|
||||
->assertSee('Inserted canned browser response')
|
||||
->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>')
|
||||
->press('@post-comment')
|
||||
->waitForText('Hello Response Browser User', 10)
|
||||
->assertSee('Inserted canned browser response')
|
||||
->assertDontSee('<p>Hello');
|
||||
->waitFor('@helpdesk-ticket-show', 10);
|
||||
|
||||
$this->openHelpdeskConversation($browser);
|
||||
|
||||
$browser->waitForText('Browser canned response', 10)
|
||||
->assertSee('Browser canned response')
|
||||
->assertDontSee('UNID')
|
||||
->assertDontSee('GUID');
|
||||
|
||||
$comment = Comment::query()
|
||||
->where('ticket_id', $ticket->id)
|
||||
@@ -91,17 +97,24 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
|
||||
->where('body', 'like', '%Hello Response Browser User%')
|
||||
->firstOrFail();
|
||||
|
||||
$this->openHelpdeskConversation($browser);
|
||||
|
||||
$browser->press('@reply-to-comment-'.$comment->id)
|
||||
->waitFor('@reply-body-'.$comment->id)
|
||||
->type('@reply-body-'.$comment->id, 'Nested browser response')
|
||||
->press('@post-reply-'.$comment->id)
|
||||
->waitForText('Nested browser response', 10)
|
||||
->assertSee('Nested browser response');
|
||||
->waitFor('@helpdesk-ticket-show', 10);
|
||||
|
||||
$this->openHelpdeskConversation($browser);
|
||||
|
||||
$browser->waitForText('RE: Browser canned response', 10)
|
||||
->assertSee('RE: Browser canned response');
|
||||
});
|
||||
|
||||
$this->assertDatabaseHas('helpdesk_comments', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'parent_id' => null,
|
||||
'subject' => 'Browser canned response',
|
||||
'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', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'parent_id' => $parentId,
|
||||
'subject' => 'RE: Browser canned 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
|
||||
{
|
||||
$encodedDusk = json_encode($dusk, JSON_THROW_ON_ERROR);
|
||||
|
||||
@@ -74,6 +74,7 @@ final class TicketConversationTest extends TestCase
|
||||
|
||||
$this->actingAs($owner)
|
||||
->post(route('helpdesk.tickets.comments.store', $ticket), [
|
||||
'subject' => 'Searchable response subject',
|
||||
'commentBody' => '<p><strong>Searchable response</strong></p>',
|
||||
])
|
||||
->assertRedirect(route('helpdesk.tickets.show', $ticket));
|
||||
@@ -83,6 +84,7 @@ final class TicketConversationTest extends TestCase
|
||||
self::assertNotEmpty($comment->guid);
|
||||
self::assertNotEmpty($comment->unid);
|
||||
self::assertSame($comment->unid, $comment->root_unid);
|
||||
self::assertSame('Searchable response subject', $comment->subject);
|
||||
self::assertSame('Searchable response', $comment->body_text);
|
||||
self::assertSame(1, $comment->sync_sequence);
|
||||
}
|
||||
@@ -97,6 +99,7 @@ final class TicketConversationTest extends TestCase
|
||||
$this->actingAs($owner)
|
||||
->post(route('helpdesk.tickets.comments.store', $ticket), [
|
||||
'parent_id' => $parent->id,
|
||||
'subject' => 'RE: Parent response',
|
||||
'commentBody' => '<p>Child response</p>',
|
||||
])
|
||||
->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->root_unid, $reply->root_unid);
|
||||
self::assertSame('RE: Parent response', $reply->subject);
|
||||
}
|
||||
|
||||
public function test_agent_can_update_status_to_closed_and_priority(): void
|
||||
|
||||
@@ -225,3 +225,14 @@ versions:
|
||||
- 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.
|
||||
- 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."
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.3.13"
|
||||
"version": "1.7.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user