phases one of time logged now with log display

This commit is contained in:
Mark Gregory
2026-06-22 14:22:58 +01:00
parent bc213e0c6e
commit 0159a638f6
6 changed files with 195 additions and 175 deletions
+38 -67
View File
@@ -5,8 +5,8 @@ namespace Modules\Helpdesk\Livewire;
use Carbon\Carbon;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
use Modules\Helpdesk\Models\Ticket;
@@ -25,7 +25,7 @@ class TicketTimer extends Component
public int $elapsedSeconds = 0;
/**
* Mount the ticket timer component.
* Mount the ticket timer and load any active database-backed timer.
*/
public function mount(Ticket $ticket): void
{
@@ -34,7 +34,7 @@ class TicketTimer extends Component
}
/**
* Start a user-specific timer for this ticket.
* Start a database-backed timer for the current authenticated user.
*
* @throws \Illuminate\Validation\ValidationException
*/
@@ -42,51 +42,47 @@ class TicketTimer extends Component
{
$user = $this->authenticatedUser();
Gate::authorize('update', $this->ticket);
if ($this->activeUserTimerQuery($user)->exists()) {
throw ValidationException::withMessages([
'timer' => 'You already have a running timer. End it before starting another one.',
]);
}
if ($this->activeAnyTicketTimerQuery($user)->exists()) {
throw ValidationException::withMessages([
'timer' => 'You already have a running timer on another ticket. End it before starting another one.',
'timer' => 'You already have a running timer.',
]);
}
$startedAt = Carbon::now();
TicketTimeLog::query()->create([
'ticket_id' => $this->ticket->getKey(),
'user_id' => $user->getAuthIdentifier(),
'minutes' => 0,
'seconds' => 0,
'description' => $this->normalisedDescription(),
'started_at' => $startedAt,
'ended_at' => null,
'logged_at' => $startedAt,
]);
try {
$timer = TicketTimeLog::query()->create([
'ticket_id' => $this->ticket->getKey(),
'user_id' => $user->getAuthIdentifier(),
'minutes' => 0,
'seconds' => 0,
'description' => $this->normalisedDescription(),
'started_at' => $startedAt,
'ended_at' => null,
'logged_at' => $startedAt,
]);
} catch (QueryException $exception) {
throw ValidationException::withMessages([
'timer' => 'You already have a running timer.',
]);
}
$this->activeTimer = $timer->fresh();
$this->elapsedSeconds = 0;
$this->description = '';
$this->message = 'Timer started.';
$this->refreshActiveTimer();
$this->dispatch('timer-started');
}
/**
* End the active user timer and add the elapsed time to the ticket.
* End the current user's active timer and persist seconds/minutes.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function endTimer(): void
{
$user = $this->authenticatedUser();
Gate::authorize('update', $this->ticket);
$timer = $this->activeUserTimerQuery($user)->first();
if (! $timer instanceof TicketTimeLog) {
@@ -108,7 +104,14 @@ class TicketTimer extends Component
'logged_at' => $endedAt,
])->save();
$this->updateTicketTimeSpentFromSeconds();
$totalSeconds = (int) TicketTimeLog::query()
->where('ticket_id', $this->ticket->getKey())
->whereNotNull('ended_at')
->sum('seconds');
$this->ticket->forceFill([
'time_spent_minutes' => (int) ceil($totalSeconds / 60),
])->save();
$this->activeTimer = null;
$this->elapsedSeconds = 0;
@@ -119,15 +122,15 @@ class TicketTimer extends Component
}
/**
* Render the ticket timer view.
* Render the timer view.
*/
public function render(): mixed
public function render()
{
return view('helpdesk::livewire.ticket-timer');
}
/**
* Refresh the active timer from the database.
* Refresh the active timer state from the database.
*/
private function refreshActiveTimer(): void
{
@@ -142,7 +145,7 @@ class TicketTimer extends Component
$this->activeTimer = $this->activeUserTimerQuery($user)->first();
$this->elapsedSeconds = $this->activeTimer instanceof TicketTimeLog
? (int) $this->activeTimer->started_at->diffInSeconds(Carbon::now())
? $this->activeTimer->started_at->diffInSeconds(Carbon::now())
: 0;
}
@@ -157,38 +160,6 @@ class TicketTimer extends Component
->whereNull('ended_at');
}
/**
* Build the active timer query for the authenticated user across all tickets.
*/
private function activeAnyTicketTimerQuery(Authenticatable $user): Builder
{
return TicketTimeLog::query()
->where('user_id', $user->getAuthIdentifier())
->whereNull('ended_at');
}
/**
* Update the ticket's rounded minute total using stored seconds.
*/
private function updateTicketTimeSpentFromSeconds(): void
{
$totalSeconds = (int) TicketTimeLog::query()
->where('ticket_id', $this->ticket->getKey())
->whereNotNull('ended_at')
->sum('seconds');
if ($totalSeconds === 0) {
$totalSeconds = (int) TicketTimeLog::query()
->where('ticket_id', $this->ticket->getKey())
->whereNotNull('ended_at')
->sum('minutes') * 60;
}
$this->ticket->forceFill([
'time_spent_minutes' => (int) ceil($totalSeconds / 60),
])->save();
}
/**
* Return the authenticated user or fail validation cleanly.
*
@@ -200,7 +171,7 @@ class TicketTimer extends Component
if (! $user instanceof Authenticatable) {
throw ValidationException::withMessages([
'timer' => 'You must be signed in to use the ticket timer.',
'timer' => 'You must be logged in to use the timer.',
]);
}
@@ -208,12 +179,12 @@ class TicketTimer extends Component
}
/**
* Return a safe note for the timer.
* Return the safe timer description.
*/
private function normalisedDescription(): string
{
$description = trim($this->description);
return $description !== '' ? $description : 'Work timer';
return $description !== '' ? $description : 'Timer entry';
}
}
@@ -1,5 +1,5 @@
@php
use Illuminate\Support\Str;
use Illuminate\Support\Str;use Modules\Helpdesk\Models\TicketTimeLog;
$currentUser = auth()->user();
$currentCompany = $currentUser?->company;
@@ -8,7 +8,11 @@
Str::lower((string) ($currentCompany?->slug ?? '')) === 'mpba-limited' ||
Str::contains(Str::lower((string) ($currentCompany?->name ?? '')), 'mpba');
$visibleCommentCount = $ticket->comments->count();
$ticketTimeLogs = collect($ticket->timeLogs ?? []);
$ticketTimeLogs = TicketTimeLog::query()
->with('user')
->where('ticket_id', $ticket->getKey())
->orderByDesc('started_at')
->get();
@endphp
<div data-dusk="helpdesk-ticket-show" dusk="helpdesk-ticket-show" class="hd-helpdesk-surface space-y-4">
@@ -33,14 +37,14 @@
<div class="flex flex-wrap gap-2">
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate
class="inline-flex items-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
class="inline-flex items-center 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-arrow-left mr-2" aria-hidden="true"></i>
Back
</a>
@can('update', $ticket)
<a href="{{ route('helpdesk.tickets.edit', $ticket) }}" wire:navigate
class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-700">
class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-emerald-700">
<i class="fa-solid fa-pen-to-square mr-2" aria-hidden="true"></i>
Edit
</a>
@@ -119,29 +123,31 @@
<div class="space-y-3 p-4">
@can('update', $ticket)
<form method="POST" action="{{ route('helpdesk.tickets.attachments.store', $ticket) }}"
enctype="multipart/form-data"
class="rounded-xl border border-blue-500/25 bg-slate-950/70 p-3 shadow-sm"
data-helpdesk-ticket-attachment-form>
enctype="multipart/form-data"
class="rounded-xl border border-blue-500/25 bg-slate-950/70 p-3 shadow-sm"
data-helpdesk-ticket-attachment-form>
@csrf
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0">
<p class="text-xs font-black uppercase tracking-[0.18em] text-blue-200">Add files</p>
<p class="mt-1 truncate text-xs font-semibold text-slate-200" data-helpdesk-file-label>
<p class="text-xs font-black uppercase tracking-[0.18em] text-blue-200">Add
files</p>
<p class="mt-1 truncate text-xs font-semibold text-slate-200"
data-helpdesk-file-label>
No files selected</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<label data-helpdesk-file-button
class="hd-attachment-action !rounded-lg !px-3 !py-2 !text-xs">
class="hd-attachment-action !rounded-lg !px-3 !py-2 !text-xs">
<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>
data-helpdesk-file-input>
</label>
<button type="submit"
class="hd-attachment-action hd-attachment-action-upload !rounded-lg !px-3 !py-2 !text-xs">
class="hd-attachment-action hd-attachment-action-upload !rounded-lg !px-3 !py-2 !text-xs">
<i class="fa-solid fa-upload" aria-hidden="true"></i>
<span>Upload</span>
</button>
@@ -149,11 +155,11 @@
</div>
@error('attachments')
<p class="mt-2 text-xs font-semibold text-red-300">{{ $message }}</p>
<p class="mt-2 text-xs font-semibold text-red-300">{{ $message }}</p>
@enderror
@error('attachments.*')
<p class="mt-2 text-xs font-semibold text-red-300">{{ $message }}</p>
<p class="mt-2 text-xs font-semibold text-red-300">{{ $message }}</p>
@enderror
</form>
@endcan
@@ -167,7 +173,7 @@
<div class="grid gap-2 sm:grid-cols-2">
@foreach ($ticket->attachments as $attachment)
<a href="{{ route('helpdesk.attachments.download', $attachment->id) }}" target="_blank"
class="group/file flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-xs transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
class="group/file flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-xs transition hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
<span class="min-w-0">
<span class="block truncate font-bold text-zinc-900 dark:text-zinc-100">
{{ $attachment->original_name ?? ($attachment->original_filename ?? ($attachment->filename ?? ($attachment->file_name ?? basename($attachment->path ?? ($attachment->file_path ?? ''))))) }}
@@ -211,7 +217,7 @@
class="inline-flex shrink-0 items-center gap-2 rounded-xl bg-white/15 px-3 py-1.5 text-xs 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>
aria-hidden="true"></i>
</span>
</summary>
@@ -219,23 +225,23 @@
<div class="grid gap-2 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..."
class="block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs 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>
class="block w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs 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-lg bg-red-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-red-700"
data-helpdesk-expand-comments>
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-2 text-xs 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>
aria-hidden="true"></i>
Expand all
</button>
<button type="button"
class="hidden items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-400 dark:focus:ring-blue-400 dark:focus:ring-offset-zinc-900"
data-helpdesk-collapse-comments>
class="hidden items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-bold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-400 dark:focus:ring-blue-400 dark:focus:ring-offset-zinc-900"
data-helpdesk-collapse-comments>
<i class="fa-solid fa-down-left-and-up-right-to-center mr-2"
aria-hidden="true"></i>
aria-hidden="true"></i>
Collapse all
</button>
</div>
@@ -259,9 +265,9 @@
</section>
<form method="POST" action="{{ route('helpdesk.tickets.comments.store', $ticket) }}"
enctype="multipart/form-data"
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
data-helpdesk-comment-form>
enctype="multipart/form-data"
class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
data-helpdesk-comment-form>
@csrf
<div class="bg-gradient-to-r from-slate-950 via-blue-950 to-slate-900 px-5 py-5 text-white sm:px-6">
@@ -356,18 +362,20 @@
);
@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>
<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="grid gap-3 md:grid-cols-[1fr_auto]">
<div>
<label for="ticket-canned-response"
class="mb-2 block text-xs font-bold text-zinc-700 dark:text-zinc-200">Canned
class="mb-2 block text-xs font-bold text-zinc-700 dark:text-zinc-200">Canned
response</label>
<select id="ticket-canned-response" name="canned_response_id"
class="hd-select block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 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-canned-response-select>
class="hd-select block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 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-canned-response-select>
<option value="">Choose a saved response...</option>
@foreach ($cannedResponses as $response)
<option value="{{ $response->id }}">{{ $response->name }}</option>
@@ -377,8 +385,8 @@
<div class="flex items-end">
<button type="button" dusk="insert-canned-response"
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 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"
data-insert-canned-response>
class="inline-flex min-h-10 items-center justify-center rounded-lg bg-blue-600 px-4 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"
data-insert-canned-response>
<i class="fa-solid fa-copy mr-2" aria-hidden="true"></i>
Insert response
</button>
@@ -388,41 +396,48 @@
<div>
<label for="ticket-comment-subject"
class="mb-2 block text-xs font-bold text-zinc-700 dark:text-zinc-200">Subject</label>
class="mb-2 block text-xs 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 min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 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>
placeholder="Response subject"
class="block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 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-xs font-bold text-zinc-700 dark:text-zinc-200">Comment</label>
class="mb-2 block text-xs font-bold 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">
data-helpdesk-html-toolbar="ticket-comment-body-editor">
<button type="button" data-helpdesk-command="bold"
class="rounded-lg border px-2 py-1 text-xs font-black">B</button>
class="rounded-lg border px-2 py-1 text-xs font-black">B
</button>
<button type="button" data-helpdesk-command="italic"
class="rounded-lg border px-2 py-1 text-xs italic">I</button>
class="rounded-lg border px-2 py-1 text-xs italic">I
</button>
<button type="button" data-helpdesk-command="underline"
class="rounded-lg border px-2 py-1 text-xs underline">U</button>
class="rounded-lg border px-2 py-1 text-xs underline">U
</button>
<button type="button" data-helpdesk-command="insertUnorderedList"
class="rounded-lg border px-2 py-1 text-xs">List</button>
class="rounded-lg border px-2 py-1 text-xs">List
</button>
<button type="button" data-helpdesk-block="blockquote"
class="rounded-lg border px-2 py-1 text-xs">Quote</button>
class="rounded-lg border px-2 py-1 text-xs">Quote
</button>
<button type="button" data-helpdesk-block="code"
class="rounded-lg border px-2 py-1 text-xs">Code</button>
class="rounded-lg border px-2 py-1 text-xs">Code
</button>
</div>
<div id="ticket-comment-body-editor" dusk="ticket-comment-editor" contenteditable="true"
role="textbox" aria-multiline="true" data-helpdesk-rich-editor
data-helpdesk-sync-target="ticket-comment-body"
class="prose block min-h-[12rem] w-full max-w-none rounded-xl border border-zinc-300 bg-white p-4 text-sm leading-6 text-zinc-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:prose-invert">
role="textbox" aria-multiline="true" data-helpdesk-rich-editor
data-helpdesk-sync-target="ticket-comment-body"
class="prose block min-h-[12rem] w-full max-w-none rounded-xl border border-zinc-300 bg-white p-4 text-sm leading-6 text-zinc-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:prose-invert">
{!! $oldCommentBody !!}</div>
<textarea dusk="comment-body" data-dusk="comment-body" id="ticket-comment-body" name="commentBody" required
class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea>
<textarea dusk="comment-body" data-dusk="comment-body" id="ticket-comment-body"
name="commentBody" required
class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea>
</div>
@can('update', $ticket)
@@ -430,27 +445,28 @@
class="rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-950">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200">Attach files to
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200">Attach files
to
this reply</label>
<p class="mt-1 truncate text-xs font-semibold text-zinc-500 dark:text-zinc-400"
data-helpdesk-file-label>No files selected</p>
data-helpdesk-file-label>No files selected</p>
</div>
<label data-helpdesk-file-button
class="inline-flex cursor-pointer 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-within:outline-none focus-within:ring-2 focus-within:ring-blue-400/70">
class="inline-flex cursor-pointer 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-within:outline-none focus-within:ring-2 focus-within:ring-blue-400/70">
<i class="fa-solid fa-paperclip mr-2 text-xs" aria-hidden="true"></i>
<span>Choose files</span>
<input name="comment_attachments[]" type="file" multiple class="sr-only"
data-helpdesk-file-input>
data-helpdesk-file-input>
</label>
</div>
@error('comment_attachments')
<p class="mt-2 text-xs font-semibold text-red-600 dark:text-red-400">{{ $message }}</p>
<p class="mt-2 text-xs font-semibold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
@error('comment_attachments.*')
<p class="mt-2 text-xs font-semibold text-red-600 dark:text-red-400">{{ $message }}</p>
<p class="mt-2 text-xs font-semibold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
@@ -458,7 +474,7 @@
<label
class="mt-3 flex items-center gap-2 text-xs font-semibold text-zinc-700 dark:text-zinc-200">
<input type="checkbox" name="internal" value="1" @checked(old('internal'))
class="rounded border-zinc-300">
class="rounded border-zinc-300">
MPBA internal note only
</label>
@endif
@@ -466,7 +482,7 @@
<div class="flex justify-end">
<button dusk="post-comment" data-dusk="post-comment" type="submit"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-comments mr-2" aria-hidden="true"></i>
Post reply
</button>
@@ -475,7 +491,7 @@
</form>
<script>
document.addEventListener('click', function(event) {
document.addEventListener('click', function (event) {
const insertButton = event.target.closest('[data-insert-canned-response]');
if (insertButton) {
@@ -566,7 +582,7 @@
}
});
document.addEventListener('submit', function(event) {
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-helpdesk-comment-form]');
if (!form) {
@@ -578,7 +594,7 @@
});
});
document.addEventListener('input', function(event) {
document.addEventListener('input', function (event) {
const richEditor = event.target.closest('[data-helpdesk-rich-editor]');
if (richEditor) {
@@ -766,7 +782,7 @@
return span.innerHTML;
}
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-helpdesk-rich-editor]').forEach((editor) => syncRichEditor(editor));
updateCommentToggleButtons();
});
@@ -883,8 +899,8 @@
</div>
<button type="button"
onclick="document.getElementById('ticket-time-log-modal').showModal()"
class="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-emerald-600 px-2 py-1.5 text-[0.65rem] font-black text-white shadow-sm hover:bg-emerald-700">
onclick="document.getElementById('ticket-time-log-modal').showModal()"
class="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-emerald-600 px-2 py-1.5 text-[0.65rem] font-black text-white shadow-sm hover:bg-emerald-700">
<i class="fa-solid fa-clock-rotate-left mr-2" aria-hidden="true"></i>
View log
</button>
@@ -927,7 +943,7 @@
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($ticket->tags as $tag)
<span class="rounded-full px-2 py-1 text-xs font-semibold text-white"
style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
@endforeach
</div>
@endif
@@ -936,7 +952,9 @@
@if ($ticket->checklistItems->isNotEmpty())
<div
class="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
@php($completedChecks = $ticket->checklistItems->where('is_completed', true)->count())
@php
$completedChecks = $ticket->checklistItems->where('is_completed', true)->count();
@endphp
<div class="-mx-4 -mt-4 mb-4 rounded-t-xl bg-amber-600 px-4 py-4 text-white">
<div class="flex items-center justify-between gap-3">
@@ -960,7 +978,7 @@
<label
class="flex items-start gap-2 rounded-lg border border-zinc-200 p-2 text-xs dark:border-zinc-700">
<input type="checkbox" wire:click="toggleChecklistItem({{ $item->id }})"
@checked($item->is_completed) class="mt-1 rounded border-zinc-300">
@checked($item->is_completed) class="mt-1 rounded border-zinc-300">
<span
class="{{ $item->is_completed ? 'text-zinc-500 line-through' : 'text-zinc-800 dark:text-zinc-100' }}">{{ $item->title }}</span>
</label>
@@ -987,7 +1005,7 @@
<div class="space-y-2">
@forelse ($ticket->gitCommits as $commit)
<a href="{{ $commit->url ?: '#' }}" target="_blank"
class="block rounded-lg border border-zinc-100 p-2 dark:border-zinc-800">
class="block rounded-lg border border-zinc-100 p-2 dark:border-zinc-800">
<div class="font-mono text-[0.68rem] text-zinc-500">{{ $commit->short_hash }} ·
{{ $commit->repository?->name }}</div>
<div class="mt-1 text-xs font-medium text-zinc-900 dark:text-zinc-100">
@@ -1013,7 +1031,8 @@
</span>
<div class="min-w-0">
<h2 class="text-lg font-black uppercase tracking-wide">Ticket controls</h2>
<p class="text-xs font-semibold text-indigo-100">Log time, assignment, status and priority
<p class="text-xs font-semibold text-indigo-100">Log time, assignment, status and
priority
</p>
</div>
</div>
@@ -1029,50 +1048,52 @@
'ticket' => $ticket,
])
<form method="POST" action="{{ route('helpdesk.tickets.time-logs.store', $ticket) }}"
class="mb-4 space-y-2">
class="mb-4 space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="ticket_time_minutes">Log time</label>
for="ticket_time_minutes">Log time</label>
<div class="grid grid-cols-[1fr_auto] gap-2">
<input id="ticket_time_minutes" name="minutes" type="number" min="1" max="1440"
placeholder="Minutes"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-minutes">
placeholder="Minutes"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-minutes">
<button type="submit" dusk="log-ticket-time" data-dusk="log-ticket-time"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-clock mr-2" aria-hidden="true"></i>
<span>Log</span>
</button>
</div>
<textarea name="description" rows="2" placeholder="Optional work note"
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-description"></textarea>
class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-time-description"></textarea>
@error('minutes')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
@error('description')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</form>
<form method="POST" action="{{ route('helpdesk.tickets.agent.update', $ticket) }}"
class="mb-4 space-y-2">
class="mb-4 space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="show_agent_id">Assigned agent</label>
for="show_agent_id">Assigned agent</label>
<select id="show_agent_id" name="agent_id"
class="hd-select block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-show-agent">
class="hd-select block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-show-agent">
<option value="" @selected($agent_id === null)>Unassigned</option>
@forelse ($agents as $agent)
<option value="{{ $agent->id }}" @selected((int) $agent_id === (int) $agent->id)>{{ $agent->name }} ·
<option
value="{{ $agent->id }}" @selected((int) $agent_id === (int) $agent->id)>{{ $agent->name }}
·
{{ $agent->email }}</option>
@empty
<option value="" disabled>No users available for assignment</option>
@@ -1080,63 +1101,65 @@
</select>
@error('agent_id')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<button type="submit" dusk="update-agent" data-dusk="update-agent"
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
class="inline-flex items-center justify-center rounded-lg bg-emerald-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-400/70">
<i class="fa-solid fa-user-check mr-2" aria-hidden="true"></i>
<span>Assign agent</span>
</button>
</form>
<form method="POST" action="{{ route('helpdesk.tickets.status.update', $ticket) }}"
class="mb-4 space-y-2">
class="mb-4 space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="show_status_id">Status</label>
for="show_status_id">Status</label>
<select id="show_status_id" name="status_id"
class="hd-select block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs 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-dusk="ticket-show-status">
class="hd-select block min-h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-xs 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-dusk="ticket-show-status">
@foreach ($statuses as $status)
<option value="{{ $status->id }}" @selected((int) $status_id === (int) $status->id)>{{ $status->name }}
<option
value="{{ $status->id }}" @selected((int) $status_id === (int) $status->id)>{{ $status->name }}
</option>
@endforeach
</select>
@error('status_id')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<button type="submit" dusk="update-status"
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-400/70">
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-xs font-black 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-circle-check mr-2" aria-hidden="true"></i>
<span>Update status</span>
</button>
</form>
<form method="POST" action="{{ route('helpdesk.tickets.priority.update', $ticket) }}"
class="space-y-2">
class="space-y-2">
@csrf
<label class="block text-xs font-bold text-zinc-700 dark:text-zinc-200"
for="show_priority_id">Priority</label>
for="show_priority_id">Priority</label>
<select id="show_priority_id" name="priority_id"
class="hd-select block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-amber-500 focus:ring-amber-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-show-priority">
class="hd-select block w-full rounded-lg border border-zinc-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-amber-500 focus:ring-amber-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-show-priority">
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}" @selected((int) $priority_id === (int) $priority->id)>{{ $priority->name }}
<option
value="{{ $priority->id }}" @selected((int) $priority_id === (int) $priority->id)>{{ $priority->name }}
</option>
@endforeach
</select>
@error('priority_id')
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
<p class="text-xs font-bold text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
<button type="submit" dusk="update-priority"
class="inline-flex items-center justify-center rounded-lg bg-amber-500 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-300/70">
class="inline-flex items-center justify-center rounded-lg bg-amber-500 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-300/70">
<i class="fa-solid fa-flag mr-2" aria-hidden="true"></i>
<span>Update priority</span>
</button>
@@ -1147,7 +1170,7 @@
</div>
<dialog id="ticket-time-log-modal"
class="w-full max-w-2xl rounded-2xl border border-zinc-200 bg-white p-0 shadow-2xl backdrop:bg-slate-950/70 dark:border-zinc-700 dark:bg-zinc-900">
class="w-full max-w-2xl rounded-2xl border border-zinc-200 bg-white p-0 shadow-2xl backdrop:bg-slate-950/70 dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-r from-emerald-800 via-emerald-700 to-teal-700 px-5 py-4 text-white">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
@@ -1158,7 +1181,7 @@
</div>
<button type="button" onclick="document.getElementById('ticket-time-log-modal').close()"
class="shrink-0 rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white hover:bg-white/25">
class="shrink-0 rounded-lg bg-white/15 px-3 py-2 text-xs font-black text-white hover:bg-white/25">
Close
</button>
</div>
@@ -1181,9 +1204,11 @@
{{ $timeLog->logged_at?->format('d M Y H:i') ?? $timeLog->created_at?->format('d M Y H:i') }}
</p>
</div>
@php
$timeLogSeconds = (int) ($timeLog->seconds ?? ((int) $timeLog->minutes * 60));
@endphp
<span class="shrink-0 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-black text-white">
{{ $timeLog->minutes }}m
{{ gmdate('H:i:s', $timeLogSeconds) }}
</span>
</div>
</div>
@@ -55,6 +55,7 @@
}
}"
x-init="start()"
x-on:timer-started.window="start()"
x-on:timer-ended.window="stop(); elapsedSeconds = 0"
x-on:livewire:navigating.window="stop()"
class="rounded-xl bg-emerald-700 px-3 py-2 text-right text-white shadow-sm"
@@ -1,4 +1,3 @@
@php use Modules\Helpdesk\Livewire\TicketTimer; @endphp
@can('update', $ticket)
@livewire(TicketTimer::class, ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
@livewire('Modules\\Helpdesk\\Livewire\\TicketTimer', ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
@endcan
@@ -1 +0,0 @@
@livewire(\Modules\Helpdesk\Livewire\TicketTimer::class, ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
@@ -0,0 +1,25 @@
<?php
namespace Modules\Helpdesk\Tests\Feature;
use Tests\TestCase;
class TicketTimerStartDisplayFilesTest extends TestCase
{
/**
* The component must hydrate the active timer immediately after start so the running card displays.
*/
public function test_start_timer_refreshes_running_display_state(): void
{
$component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
$this->assertStringContainsString('$this->activeTimer = $timer->fresh();', $component);
$this->assertStringContainsString('$this->elapsedSeconds = 0;', $component);
$this->assertStringContainsString("dispatch('timer-started')", $component);
$this->assertStringContainsString('$hasRunningTimer = $activeTimer && $activeTimer->ended_at === null;', $view);
$this->assertStringContainsString('data-helpdesk-running-timer', $view);
$this->assertStringContainsString('x-on:timer-started.window', $view);
$this->assertStringNotContainsString('wire:ignore', $view);
}
}