core timer fixes
This commit is contained in:
@@ -16,6 +16,7 @@ use Modules\Helpdesk\Livewire\SettingsManager;
|
||||
use Modules\Helpdesk\Livewire\TicketCreate;
|
||||
use Modules\Helpdesk\Livewire\TicketIndex;
|
||||
use Modules\Helpdesk\Livewire\TicketShow;
|
||||
use Modules\Helpdesk\Livewire\TicketTimer;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Policies\TicketPolicy;
|
||||
use Modules\Helpdesk\Support\ModuleInfo;
|
||||
@@ -69,6 +70,7 @@ final class HelpdeskServiceProvider extends ServiceProvider
|
||||
Livewire::component('helpdesk.ticket-index', TicketIndex::class);
|
||||
Livewire::component('helpdesk.ticket-create', TicketCreate::class);
|
||||
Livewire::component('helpdesk.ticket-show', TicketShow::class);
|
||||
Livewire::component('helpdesk.ticket-timer', TicketTimer::class);
|
||||
Livewire::component('helpdesk.dashboard-widget', HelpdeskDashboardWidget::class);
|
||||
Livewire::component('helpdesk.kanban-board', KanbanBoard::class);
|
||||
Livewire::component('helpdesk.settings-manager', SettingsManager::class);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
'Raise a new support request with priority, product/service, project, category, team and full details.',
|
||||
])
|
||||
|
||||
<form wire:submit.prevent="save" enctype="multipart/form-data" class="helpdesk-ticket-form space-y-6" data-helpdesk-ticket-form>
|
||||
<form wire:submit.prevent="save" enctype="multipart/form-data" class="helpdesk-ticket-form space-y-6">
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
|
||||
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2>
|
||||
@@ -330,16 +330,13 @@
|
||||
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
data-dusk="ticket-create-submit"
|
||||
dusk="ticket-create-submit"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="save">
|
||||
data-dusk="ticket-create-submit" dusk="ticket-create-submit" wire:loading.attr="disabled">
|
||||
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
|
||||
<span wire:loading.remove wire:target="save">Create Ticket</span>
|
||||
<span wire:loading wire:target="save">Creating...</span>
|
||||
<span wire:loading.remove>Create Ticket</span>
|
||||
<span wire:loading>Creating...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,50 +360,47 @@
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@script
|
||||
<script>
|
||||
const initialiseHelpdeskTicketCreate = function () {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.querySelector('.helpdesk-ticket-form');
|
||||
const editor = document.querySelector('[data-helpdesk-details-editor]');
|
||||
const bodyField = document.getElementById('body');
|
||||
|
||||
if (! form || ! editor || ! bodyField || form.dataset.editorReady === 'true') {
|
||||
if (!form || !editor || !bodyField) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.dataset.editorReady = 'true';
|
||||
|
||||
const syncBody = function () {
|
||||
const value = editor.innerHTML.trim();
|
||||
|
||||
bodyField.value = value;
|
||||
bodyField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
bodyField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
@this.set('body', value, false);
|
||||
const syncBody = function() {
|
||||
bodyField.value = editor.innerHTML.trim();
|
||||
bodyField.dispatchEvent(new Event('input', {
|
||||
bubbles: true
|
||||
}));
|
||||
bodyField.dispatchEvent(new Event('change', {
|
||||
bubbles: true
|
||||
}));
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-helpdesk-editor-command]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
document.querySelectorAll('[data-helpdesk-editor-command]').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
editor.focus();
|
||||
document.execCommand(button.dataset.helpdeskEditorCommand, false, null);
|
||||
syncBody();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-helpdesk-editor-format]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
document.querySelectorAll('[data-helpdesk-editor-format]').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
editor.focus();
|
||||
document.execCommand('formatBlock', false, button.dataset.helpdeskEditorFormat);
|
||||
syncBody();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-helpdesk-editor-link]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
document.querySelectorAll('[data-helpdesk-editor-link]').forEach(function(button) {
|
||||
button.addEventListener('click', function() {
|
||||
const url = window.prompt('Enter link URL');
|
||||
|
||||
if (! url) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,15 +410,8 @@
|
||||
});
|
||||
});
|
||||
|
||||
editor.addEventListener('input', syncBody);
|
||||
editor.addEventListener('keyup', syncBody);
|
||||
editor.addEventListener('blur', syncBody);
|
||||
form.addEventListener('submit', syncBody, true);
|
||||
};
|
||||
|
||||
initialiseHelpdeskTicketCreate();
|
||||
|
||||
document.addEventListener('livewire:navigated', initialiseHelpdeskTicketCreate);
|
||||
form.addEventListener('submit', syncBody);
|
||||
});
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
||||
@@ -1,133 +1,151 @@
|
||||
<div
|
||||
class="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 p-3 shadow-sm dark:border-emerald-900 dark:bg-emerald-950/40"
|
||||
class="mb-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-950"
|
||||
data-helpdesk-ticket-timer
|
||||
>
|
||||
@php
|
||||
$hasRunningTimer = $activeTimer && $activeTimer->ended_at === null;
|
||||
$startedAtIso = $hasRunningTimer ? $activeTimer->started_at?->toIso8601String() : null;
|
||||
$startedAtDisplay = $hasRunningTimer ? $activeTimer->started_at?->format('d M Y H:i') : null;
|
||||
@endphp
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-600 text-white shadow-sm">
|
||||
<i class="fa-solid fa-stopwatch text-sm" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-blue-600 text-white shadow-sm">
|
||||
<i class="fa-solid fa-stopwatch text-sm" aria-hidden="true"></i>
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-black text-emerald-950 dark:text-emerald-100">Work timer</h3>
|
||||
<p class="mt-1 text-xs font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
Start a user-specific timer. It remains active if the browser is closed.
|
||||
<h3 class="text-sm font-black text-zinc-950 dark:text-zinc-100">
|
||||
Ticket timer
|
||||
</h3>
|
||||
|
||||
<p class="mt-1 text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||
Record focused work against this ticket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($hasRunningTimer)
|
||||
<div
|
||||
x-data="{
|
||||
startedAt: Date.parse('{{ $activeTimer->started_at?->toIso8601String() }}'),
|
||||
elapsedSeconds: {{ max(0, (int) $elapsedSeconds) }},
|
||||
intervalId: null,
|
||||
formatted() {
|
||||
const total = Math.max(0, Number(this.elapsedSeconds || 0));
|
||||
const hours = String(Math.floor(total / 3600)).padStart(2, '0');
|
||||
const minutes = String(Math.floor((total % 3600) / 60)).padStart(2, '0');
|
||||
const seconds = String(total % 60).padStart(2, '0');
|
||||
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
},
|
||||
tick() {
|
||||
if (!Number.isFinite(this.startedAt)) {
|
||||
this.elapsedSeconds = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
this.elapsedSeconds = Math.floor((Date.now() - this.startedAt) / 1000);
|
||||
},
|
||||
start() {
|
||||
this.stop();
|
||||
this.tick();
|
||||
this.intervalId = window.setInterval(() => this.tick(), 1000);
|
||||
},
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
window.clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
}"
|
||||
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"
|
||||
data-helpdesk-running-timer
|
||||
>
|
||||
<div class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-emerald-100">Running</div>
|
||||
<div class="font-mono text-lg font-black leading-tight" x-text="formatted()" data-dusk="ticket-running-time">00:00:00</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-xl bg-white px-3 py-2 text-right text-emerald-900 shadow-sm ring-1 ring-emerald-200 dark:bg-zinc-950 dark:text-emerald-100 dark:ring-emerald-900">
|
||||
<div class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-emerald-700 dark:text-emerald-300">Stopped</div>
|
||||
<div class="font-mono text-lg font-black leading-tight" data-dusk="ticket-running-time">00:00:00</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($message)
|
||||
<div class="rounded-lg border border-emerald-300 bg-white px-3 py-2 text-xs font-bold text-emerald-900 dark:border-emerald-800 dark:bg-zinc-950 dark:text-emerald-100">
|
||||
{{ $message }}
|
||||
@if ($hasRunningTimer)
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-right shadow-sm dark:border-emerald-900 dark:bg-emerald-950/40">
|
||||
<div class="flex items-center justify-end gap-2 text-[0.65rem] font-black uppercase tracking-[0.16em] text-emerald-700 dark:text-emerald-300">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
Running
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-1 font-mono text-2xl font-black leading-none text-emerald-950 dark:text-emerald-100"
|
||||
data-helpdesk-live-elapsed
|
||||
data-helpdesk-started-at="{{ $startedAtIso }}"
|
||||
data-dusk="ticket-running-time"
|
||||
>
|
||||
{{ gmdate('H:i:s', max(0, (int) $elapsedSeconds)) }}
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-[0.68rem] font-bold text-emerald-700 dark:text-emerald-300">
|
||||
Started {{ $startedAtDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@error('timer')
|
||||
<div class="rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-xs font-bold text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-100">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@enderror
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-bold text-emerald-950 dark:text-emerald-100" for="ticket_timer_description">
|
||||
Timer note
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="ticket_timer_description"
|
||||
wire:model.defer="description"
|
||||
rows="2"
|
||||
placeholder="Optional work note"
|
||||
class="block w-full rounded-lg border border-emerald-300 bg-white p-2 text-xs font-semibold text-zinc-900 shadow-sm focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
data-dusk="ticket-timer-description"
|
||||
></textarea>
|
||||
@if ($message)
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if ($hasRunningTimer)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="endTimer"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="endTimer"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-2 text-xs font-black text-white shadow-sm transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
data-dusk="end-ticket-timer"
|
||||
>
|
||||
<i class="fa-solid fa-stop mr-2" aria-hidden="true"></i>
|
||||
<span wire:loading.remove wire:target="endTimer">End timer</span>
|
||||
<span wire:loading wire:target="endTimer">Ending...</span>
|
||||
</button>
|
||||
@else
|
||||
<button
|
||||
type="button"
|
||||
wire:click="startTimer"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="startTimer"
|
||||
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 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
data-dusk="start-ticket-timer"
|
||||
>
|
||||
<i class="fa-solid fa-play mr-2" aria-hidden="true"></i>
|
||||
<span wire:loading.remove wire:target="startTimer">Start timer</span>
|
||||
<span wire:loading wire:target="startTimer">Starting...</span>
|
||||
</button>
|
||||
@endif
|
||||
@error('timer')
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-bold text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-100">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@enderror
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="block text-xs font-bold text-zinc-800 dark:text-zinc-100" for="ticket_timer_description">
|
||||
Timer note
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="ticket_timer_description"
|
||||
wire:model.defer="description"
|
||||
rows="2"
|
||||
placeholder="Optional work note"
|
||||
class="block w-full rounded-lg 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-900 dark:text-zinc-100"
|
||||
data-dusk="ticket-timer-description"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
{{ $hasRunningTimer ? 'End the timer to add the elapsed time to the ticket.' : 'Start a timer when work begins. Only one active timer is allowed.' }}
|
||||
</p>
|
||||
|
||||
@if ($hasRunningTimer)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="endTimer"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="endTimer"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-4 py-2.5 text-sm font-black text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:ring-offset-zinc-950"
|
||||
data-dusk="end-ticket-timer"
|
||||
>
|
||||
<i class="fa-solid fa-stop mr-2" aria-hidden="true"></i>
|
||||
<span wire:loading.remove wire:target="endTimer">End timer</span>
|
||||
<span wire:loading wire:target="endTimer">Ending...</span>
|
||||
</button>
|
||||
@else
|
||||
<button
|
||||
type="button"
|
||||
wire:click="startTimer"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="startTimer"
|
||||
class="inline-flex min-w-[9.5rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-blue-600 px-4 py-2 text-sm font-black text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:focus:ring-offset-zinc-950"
|
||||
data-dusk="start-ticket-timer"
|
||||
>
|
||||
<i class="fa-solid fa-play mr-2 text-xs" aria-hidden="true"></i>
|
||||
<span wire:loading.remove wire:target="startTimer">Start timer</span>
|
||||
<span wire:loading wire:target="startTimer">Starting...</span>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
if (window.helpdeskTicketTimerDisplayBooted === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.helpdeskTicketTimerDisplayBooted = true;
|
||||
|
||||
const formatElapsed = (totalSeconds) => {
|
||||
const seconds = Math.max(0, Number.parseInt(totalSeconds, 10) || 0);
|
||||
const hours = String(Math.floor(seconds / 3600)).padStart(2, '0');
|
||||
const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
|
||||
const remainingSeconds = String(seconds % 60).padStart(2, '0');
|
||||
|
||||
return `${hours}:${minutes}:${remainingSeconds}`;
|
||||
};
|
||||
|
||||
const updateTimers = () => {
|
||||
document.querySelectorAll('[data-helpdesk-live-elapsed][data-helpdesk-started-at]').forEach((timer) => {
|
||||
const startedAt = Date.parse(timer.getAttribute('data-helpdesk-started-at') || '');
|
||||
|
||||
if (!Number.isFinite(startedAt)) {
|
||||
timer.textContent = '00:00:00';
|
||||
return;
|
||||
}
|
||||
|
||||
timer.textContent = formatElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
||||
});
|
||||
};
|
||||
|
||||
updateTimers();
|
||||
window.setInterval(updateTimers, 1000);
|
||||
document.addEventListener('livewire:navigated', updateTimers);
|
||||
document.addEventListener('livewire:update', updateTimers);
|
||||
document.addEventListener('livewire:updated', updateTimers);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,3 @@
|
||||
@php
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Helpdesk\Models\TicketTimeLog;
|
||||
|
||||
$ticketTimeLogTable = (new TicketTimeLog())->getTable();
|
||||
|
||||
$hasEndedAtColumn = Schema::hasColumn($ticketTimeLogTable, 'ended_at');
|
||||
$hasStoppedAtColumn = Schema::hasColumn($ticketTimeLogTable, 'stopped_at');
|
||||
$hasFinishedAtColumn = Schema::hasColumn($ticketTimeLogTable, 'finished_at');
|
||||
$hasUserIdColumn = Schema::hasColumn($ticketTimeLogTable, 'user_id');
|
||||
|
||||
$runningTimerQuery = TicketTimeLog::query()
|
||||
->where('ticket_id', $ticket->getKey())
|
||||
->whereNotNull('started_at')
|
||||
->orderByDesc('started_at');
|
||||
|
||||
if ($hasUserIdColumn) {
|
||||
$runningTimerQuery->where('user_id', auth()->id());
|
||||
}
|
||||
|
||||
if ($hasEndedAtColumn) {
|
||||
$runningTimerQuery->whereNull('ended_at');
|
||||
}
|
||||
|
||||
if ($hasStoppedAtColumn) {
|
||||
$runningTimerQuery->whereNull('stopped_at');
|
||||
}
|
||||
|
||||
if ($hasFinishedAtColumn) {
|
||||
$runningTimerQuery->whereNull('finished_at');
|
||||
}
|
||||
|
||||
$runningTimer = ($hasEndedAtColumn || $hasStoppedAtColumn || $hasFinishedAtColumn)
|
||||
? $runningTimerQuery->first()
|
||||
: null;
|
||||
|
||||
$runningTimerStartedAt = $runningTimer?->started_at;
|
||||
$runningTimerElapsedSeconds = $runningTimerStartedAt
|
||||
? max(0, now()->diffInSeconds($runningTimerStartedAt))
|
||||
: 0;
|
||||
$runningTimerElapsedDisplay = gmdate('H:i:s', $runningTimerElapsedSeconds);
|
||||
$hasRunningTimer = $runningTimerStartedAt !== null;
|
||||
@endphp
|
||||
|
||||
@can('update', $ticket)
|
||||
<details
|
||||
class="group rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||
@@ -60,23 +16,6 @@
|
||||
<p class="text-xs font-semibold text-indigo-100">
|
||||
Log time, assignment, status and priority
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="{{ $hasRunningTimer ? 'inline-flex' : 'hidden' }} mt-2 w-fit items-center gap-2 rounded-xl bg-emerald-400/20 px-3 py-1.5 text-xs font-black text-emerald-50 ring-1 ring-emerald-200/40"
|
||||
data-helpdesk-running-summary-badge
|
||||
data-helpdesk-started-at="{{ $runningTimerStartedAt?->toIso8601String() }}"
|
||||
data-dusk="ticket-running-timer-summary">
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-200 opacity-75"></span>
|
||||
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-300"></span>
|
||||
</span>
|
||||
|
||||
<span>Running</span>
|
||||
|
||||
<span class="font-mono" data-helpdesk-running-summary-elapsed data-dusk="ticket-running-elapsed-summary">
|
||||
{{ $runningTimerElapsedDisplay }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,41 +32,9 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hasRunningTimer)
|
||||
<div
|
||||
class="mb-4 rounded-xl border border-emerald-300 bg-emerald-50 p-3 text-emerald-950 shadow-sm dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100"
|
||||
data-helpdesk-running-timer>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[0.68rem] font-black uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300">
|
||||
Timer running
|
||||
</p>
|
||||
|
||||
<p class="mt-1 text-xs font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
Started {{ $runningTimerStartedAt->format('d M Y H:i:s') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="font-mono text-2xl font-black leading-none text-emerald-900 dark:text-emerald-100"
|
||||
data-helpdesk-panel-elapsed
|
||||
data-dusk="ticket-running-elapsed-time">
|
||||
{{ $runningTimerElapsedDisplay }}
|
||||
</p>
|
||||
|
||||
<p class="mt-1 text-[0.65rem] font-bold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">
|
||||
Elapsed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div data-helpdesk-timer-source>
|
||||
@include('helpdesk::partials.ticket-time-timer', [
|
||||
'ticket' => $ticket,
|
||||
])
|
||||
</div>
|
||||
@include('helpdesk::partials.ticket-time-timer', [
|
||||
'ticket' => $ticket,
|
||||
])
|
||||
|
||||
<form method="POST" action="{{ route('helpdesk.tickets.time-logs.store', $ticket) }}"
|
||||
class="mb-4 space-y-2">
|
||||
@@ -247,102 +154,4 @@
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.querySelector('[data-helpdesk-ticket-controls]');
|
||||
|
||||
if (!root || root.dataset.helpdeskElapsedBadgeBound === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.helpdeskElapsedBadgeBound = 'true';
|
||||
|
||||
const source = root.querySelector('[data-helpdesk-timer-source]');
|
||||
const badge = root.querySelector('[data-helpdesk-running-summary-badge]');
|
||||
const summaryElapsed = root.querySelector('[data-helpdesk-running-summary-elapsed]');
|
||||
const panelElapsed = root.querySelector('[data-helpdesk-panel-elapsed]');
|
||||
|
||||
if (!source || !badge || !summaryElapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let interval = null;
|
||||
let startedAt = badge.dataset.helpdeskStartedAt
|
||||
? Date.parse(badge.dataset.helpdeskStartedAt)
|
||||
: null;
|
||||
|
||||
const formatElapsed = (seconds) => {
|
||||
const date = new Date(Math.max(0, seconds) * 1000);
|
||||
|
||||
return date.toISOString().substring(11, 19);
|
||||
};
|
||||
|
||||
const updateElapsed = () => {
|
||||
if (!startedAt) {
|
||||
startedAt = Date.now();
|
||||
}
|
||||
|
||||
const display = formatElapsed(Math.floor((Date.now() - startedAt) / 1000));
|
||||
|
||||
summaryElapsed.textContent = display;
|
||||
|
||||
if (panelElapsed) {
|
||||
panelElapsed.textContent = display;
|
||||
}
|
||||
};
|
||||
|
||||
const showBadge = () => {
|
||||
badge.classList.remove('hidden');
|
||||
badge.classList.add('inline-flex');
|
||||
|
||||
if (!startedAt) {
|
||||
startedAt = Date.now();
|
||||
}
|
||||
|
||||
updateElapsed();
|
||||
|
||||
if (interval) {
|
||||
return;
|
||||
}
|
||||
|
||||
interval = window.setInterval(updateElapsed, 1000);
|
||||
};
|
||||
|
||||
const hideBadge = () => {
|
||||
badge.classList.add('hidden');
|
||||
badge.classList.remove('inline-flex');
|
||||
summaryElapsed.textContent = '00:00:00';
|
||||
startedAt = null;
|
||||
|
||||
if (interval) {
|
||||
window.clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const sourceShowsRunning = () => {
|
||||
const text = source.textContent.trim().toLowerCase();
|
||||
|
||||
return text.includes('running') || text.includes('timer started');
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
if (sourceShowsRunning()) {
|
||||
showBadge();
|
||||
return;
|
||||
}
|
||||
|
||||
hideBadge();
|
||||
};
|
||||
|
||||
new MutationObserver(sync).observe(source, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
@endcan
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@can('update', $ticket)
|
||||
@livewire('Modules\\Helpdesk\\Livewire\\TicketTimer', ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
|
||||
@livewire('helpdesk.ticket-timer', ['ticket' => $ticket], key('helpdesk-ticket-timer-'.$ticket->getKey()))
|
||||
@endcan
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Helpdesk\Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketTimerCleanUiFilesTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Timer markup should live in one loaded component without duplicated control-panel timer displays.
|
||||
*/
|
||||
public function test_ticket_controls_do_not_duplicate_timer_display_logic(): void
|
||||
{
|
||||
$controls = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-controls.blade.php'));
|
||||
$timer = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
|
||||
$provider = (string) file_get_contents(base_path('Modules/Helpdesk/Providers/HelpdeskServiceProvider.php'));
|
||||
|
||||
$this->assertStringContainsString("@include('helpdesk::partials.ticket-time-timer'", $controls);
|
||||
$this->assertStringNotContainsString('data-helpdesk-running-summary-badge', $controls);
|
||||
$this->assertStringNotContainsString('data-helpdesk-panel-elapsed', $controls);
|
||||
$this->assertStringNotContainsString('MutationObserver', $controls);
|
||||
$this->assertStringContainsString('data-helpdesk-live-elapsed', $timer);
|
||||
$this->assertStringContainsString("Livewire::component('helpdesk.ticket-timer'", $provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* The module must not contain accidental duplicate Blade folders or macOS artefacts.
|
||||
*/
|
||||
public function test_helpdesk_module_does_not_contain_known_rubbish_files(): void
|
||||
{
|
||||
$this->assertDirectoryDoesNotExist(base_path('Modules/Helpdesk/resources/views/views'));
|
||||
$this->assertFileDoesNotExist(base_path('Modules/Helpdesk/.DS_Store'));
|
||||
$this->assertFileDoesNotExist(base_path('Modules/Helpdesk/resources/.DS_Store'));
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,10 @@ class TicketTimerLiveDisplayFilesTest extends TestCase
|
||||
{
|
||||
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
|
||||
|
||||
$this->assertStringContainsString('x-data', $view);
|
||||
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
|
||||
$this->assertStringContainsString('Date.parse', $view);
|
||||
$this->assertStringContainsString('setInterval', $view);
|
||||
$this->assertStringContainsString('started_at', $view);
|
||||
$this->assertStringContainsString('data-helpdesk-started-at', $view);
|
||||
$this->assertStringContainsString('data-dusk="ticket-running-time"', $view);
|
||||
$this->assertStringNotContainsString('localStorage', $view);
|
||||
$this->assertStringNotContainsString('sessionStorage', $view);
|
||||
|
||||
@@ -18,9 +18,9 @@ class TicketTimerSecondsFilesTest extends TestCase
|
||||
$this->assertStringContainsString("'seconds'", $component);
|
||||
$this->assertStringContainsString("'seconds'", $model);
|
||||
$this->assertStringContainsString('diffInSeconds', $component);
|
||||
$this->assertStringContainsString('sum(\'seconds\')', $component);
|
||||
$this->assertStringContainsString('HH:MM:SS', 'HH:MM:SS');
|
||||
$this->assertStringContainsString('formatted()', $view);
|
||||
$this->assertStringContainsString('sum('seconds')', $component);
|
||||
$this->assertStringContainsString('formatElapsed', $view);
|
||||
$this->assertStringContainsString('data-helpdesk-live-elapsed', $view);
|
||||
$this->assertStringNotContainsString('localStorage', $component);
|
||||
$this->assertStringNotContainsString('localStorage', $view);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class TicketTimerSecondsFilesTest extends TestCase
|
||||
$partial = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-log-history-query.blade.php'));
|
||||
|
||||
$this->assertStringContainsString('TicketTimeLog::query()', $partial);
|
||||
$this->assertStringContainsString('whereNotNull(\'ended_at\')', $partial);
|
||||
$this->assertStringContainsString('whereNotNull('ended_at')', $partial);
|
||||
$this->assertStringContainsString('seconds', $partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ class TicketTimerStartDisplayFilesTest extends TestCase
|
||||
{
|
||||
$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'));
|
||||
$partial = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-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->assertStringContainsString('data-helpdesk-live-elapsed', $view);
|
||||
$this->assertStringContainsString("@livewire('helpdesk.ticket-timer'", $partial);
|
||||
$this->assertStringNotContainsString('wire:ignore', $view);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,29 @@
|
||||
- Added persistent started_at, ended_at, and duration_seconds fields to existing helpdesk time logs where available.
|
||||
- Added a reusable Blade include for placing the timer above the existing manual time log form.
|
||||
- Added regression tests for module path, Livewire view root safety, and non-session timer state.
|
||||
- version: 1.9.30
|
||||
date: 2026-06-23
|
||||
type: bugfix
|
||||
summary: Clean up Helpdesk timer UI state and remove duplicate timer display code.
|
||||
changes:
|
||||
- Replaced the oversized timer display with a compact professional ticket timer panel.
|
||||
- Hid the elapsed timer before start and after end so it only displays while a timer is active.
|
||||
- Added an inline loaded browser timer updater in the Livewire view instead of an unused standalone JavaScript file.
|
||||
- Removed duplicate running timer summary and MutationObserver logic from ticket controls.
|
||||
- Registered the timer Livewire component with the helpdesk.ticket-timer alias.
|
||||
- Removed duplicate Resources/views/views timer files and macOS artefacts from the clean module.
|
||||
- Added file-level regression coverage for the timer UI cleanup.
|
||||
- version: 1.9.31
|
||||
date: 2026-06-23
|
||||
type: bugfix
|
||||
summary: Reduce Helpdesk start timer button size.
|
||||
changes:
|
||||
- Made the inactive-state Start timer button more compact while preserving accessible focus and loading states.
|
||||
- Kept the timer display hidden before start and after end, with no new standalone JavaScript files.
|
||||
- version: 1.9.32
|
||||
date: 2026-06-23
|
||||
type: bugfix
|
||||
summary: Widen Helpdesk start timer button so the label fits on one line.
|
||||
changes:
|
||||
- Updated the inactive Start timer action with a minimum width and no-wrap text so the label does not split.
|
||||
- Preserved the clean inline timer display behaviour with no unused standalone JavaScript file.
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
|
||||
],
|
||||
"files": [],
|
||||
"version": "1.9.29"
|
||||
"version": "1.9.32"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user