phases one of time logged now with log display
This commit is contained in:
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add seconds precision to helpdesk ticket timer logs.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void {
|
||||||
|
if (! Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) {
|
||||||
|
$table->unsignedInteger('seconds')->default(0)->after('minutes');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove seconds precision from helpdesk ticket timer logs.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void {
|
||||||
|
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) {
|
||||||
|
$table->dropColumn('seconds');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Tests\Feature;
|
|
||||||
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TicketTimerFilesTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The Livewire timer view must keep its root element first.
|
|
||||||
*/
|
|
||||||
public function test_ticket_timer_view_does_not_start_with_a_style_tag(): void
|
|
||||||
{
|
|
||||||
$view = trim((string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php')));
|
|
||||||
|
|
||||||
$this->assertStringStartsWith('<div', $view);
|
|
||||||
$this->assertFalse(
|
|
||||||
str_starts_with($view, '<style'),
|
|
||||||
'The ticket timer Livewire view must not start with a <style> tag.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The timer must be database backed rather than session/localStorage backed.
|
|
||||||
*/
|
|
||||||
public function test_ticket_timer_is_not_session_or_local_storage_backed(): 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->assertStringNotContainsString('session(', $component);
|
|
||||||
$this->assertStringNotContainsString('localStorage', $component);
|
|
||||||
$this->assertStringNotContainsString('localStorage', $view);
|
|
||||||
$this->assertStringContainsString('whereNull', $component);
|
|
||||||
$this->assertStringContainsString('activeUserTimerQuery', $component);
|
|
||||||
$this->assertStringContainsString('TicketTimeLog::query()', $component);
|
|
||||||
$this->assertStringNotContainsString('->timeLogs()', $component);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ticket page must include the user-specific timer partial.
|
|
||||||
*/
|
|
||||||
public function test_ticket_show_timer_partial_exists(): void
|
|
||||||
{
|
|
||||||
$this->assertFileExists(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-timer.blade.php'));
|
|
||||||
$this->assertFileExists(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
|
|
||||||
$this->assertFileExists(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,8 @@ namespace Modules\Helpdesk\Livewire;
|
|||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\QueryException;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
use Modules\Helpdesk\Models\Ticket;
|
||||||
@@ -25,7 +25,7 @@ class TicketTimer extends Component
|
|||||||
public int $elapsedSeconds = 0;
|
public int $elapsedSeconds = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mount the timer for the current ticket.
|
* Mount the ticket timer component.
|
||||||
*/
|
*/
|
||||||
public function mount(Ticket $ticket): void
|
public function mount(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
@@ -34,7 +34,7 @@ class TicketTimer extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a database-backed timer for the authenticated user.
|
* Start a user-specific timer for this ticket.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
*/
|
*/
|
||||||
@@ -42,33 +42,32 @@ class TicketTimer extends Component
|
|||||||
{
|
{
|
||||||
$user = $this->authenticatedUser();
|
$user = $this->authenticatedUser();
|
||||||
|
|
||||||
if ($this->userHasAnyActiveTimer($user)) {
|
Gate::authorize('update', $this->ticket);
|
||||||
|
|
||||||
|
if ($this->activeUserTimerQuery($user)->exists()) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'timer' => 'You already have a running timer. End the current timer before starting another one.',
|
'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.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$startedAt = Carbon::now();
|
$startedAt = Carbon::now();
|
||||||
|
|
||||||
try {
|
TicketTimeLog::query()->create([
|
||||||
TicketTimeLog::query()->create([
|
'ticket_id' => $this->ticket->getKey(),
|
||||||
'ticket_id' => $this->ticket->getKey(),
|
'user_id' => $user->getAuthIdentifier(),
|
||||||
'user_id' => $user->getAuthIdentifier(),
|
'minutes' => 0,
|
||||||
'minutes' => 0,
|
'seconds' => 0,
|
||||||
'description' => $this->normalisedDescription(),
|
'description' => $this->normalisedDescription(),
|
||||||
'started_at' => $startedAt,
|
'started_at' => $startedAt,
|
||||||
'ended_at' => null,
|
'ended_at' => null,
|
||||||
'logged_at' => $startedAt,
|
'logged_at' => $startedAt,
|
||||||
]);
|
]);
|
||||||
} catch (QueryException $exception) {
|
|
||||||
if ($this->isUniqueTimerConstraintViolation($exception)) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'timer' => 'You already have a running timer. End the current timer before starting another one.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->description = '';
|
$this->description = '';
|
||||||
$this->message = 'Timer started.';
|
$this->message = 'Timer started.';
|
||||||
@@ -78,14 +77,17 @@ class TicketTimer extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End the active timer and add the elapsed minutes to the ticket total.
|
* End the active user timer and add the elapsed time to the ticket.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
*/
|
*/
|
||||||
public function endTimer(): void
|
public function endTimer(): void
|
||||||
{
|
{
|
||||||
$user = $this->authenticatedUser();
|
$user = $this->authenticatedUser();
|
||||||
$timer = $this->activeTimerQuery($user)->first();
|
|
||||||
|
Gate::authorize('update', $this->ticket);
|
||||||
|
|
||||||
|
$timer = $this->activeUserTimerQuery($user)->first();
|
||||||
|
|
||||||
if (! $timer instanceof TicketTimeLog) {
|
if (! $timer instanceof TicketTimeLog) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -94,21 +96,24 @@ class TicketTimer extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$endedAt = Carbon::now();
|
$endedAt = Carbon::now();
|
||||||
$minutes = max(1, (int) ceil($timer->started_at->diffInSeconds($endedAt) / 60));
|
$seconds = max(1, (int) $timer->started_at->diffInSeconds($endedAt));
|
||||||
|
$minutes = (int) ceil($seconds / 60);
|
||||||
$description = trim($this->description) !== '' ? trim($this->description) : $timer->description;
|
$description = trim($this->description) !== '' ? trim($this->description) : $timer->description;
|
||||||
|
|
||||||
$timer->forceFill([
|
$timer->forceFill([
|
||||||
'ended_at' => $endedAt,
|
'ended_at' => $endedAt,
|
||||||
|
'seconds' => $seconds,
|
||||||
'minutes' => $minutes,
|
'minutes' => $minutes,
|
||||||
'description' => $description,
|
'description' => $description,
|
||||||
'logged_at' => $endedAt,
|
'logged_at' => $endedAt,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$this->refreshTicketTimeSpentMinutes();
|
$this->updateTicketTimeSpentFromSeconds();
|
||||||
|
|
||||||
|
$this->activeTimer = null;
|
||||||
|
$this->elapsedSeconds = 0;
|
||||||
$this->description = '';
|
$this->description = '';
|
||||||
$this->message = 'Timer ended and time was added to the ticket.';
|
$this->message = 'Timer ended and time was added to the ticket.';
|
||||||
$this->refreshActiveTimer();
|
|
||||||
|
|
||||||
$this->dispatch('timer-ended');
|
$this->dispatch('timer-ended');
|
||||||
}
|
}
|
||||||
@@ -116,15 +121,15 @@ class TicketTimer extends Component
|
|||||||
/**
|
/**
|
||||||
* Render the ticket timer view.
|
* Render the ticket timer view.
|
||||||
*/
|
*/
|
||||||
public function render()
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
return view('helpdesk::livewire.ticket-timer');
|
return view('helpdesk::livewire.ticket-timer');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the active timer and elapsed seconds from the database.
|
* Refresh the active timer from the database.
|
||||||
*/
|
*/
|
||||||
public function refreshActiveTimer(): void
|
private function refreshActiveTimer(): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
@@ -135,16 +140,16 @@ class TicketTimer extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->activeTimer = $this->activeTimerQuery($user)->first();
|
$this->activeTimer = $this->activeUserTimerQuery($user)->first();
|
||||||
$this->elapsedSeconds = $this->activeTimer instanceof TicketTimeLog
|
$this->elapsedSeconds = $this->activeTimer instanceof TicketTimeLog
|
||||||
? $this->activeTimer->started_at->diffInSeconds(Carbon::now())
|
? (int) $this->activeTimer->started_at->diffInSeconds(Carbon::now())
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the active timer query for the authenticated user and current ticket.
|
* Build the active timer query for the authenticated user and current ticket.
|
||||||
*/
|
*/
|
||||||
private function activeTimerQuery(Authenticatable $user): Builder
|
private function activeUserTimerQuery(Authenticatable $user): Builder
|
||||||
{
|
{
|
||||||
return TicketTimeLog::query()
|
return TicketTimeLog::query()
|
||||||
->where('ticket_id', $this->ticket->getKey())
|
->where('ticket_id', $this->ticket->getKey())
|
||||||
@@ -153,28 +158,34 @@ class TicketTimer extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for any active timer for this user across all tickets.
|
* Build the active timer query for the authenticated user across all tickets.
|
||||||
*/
|
*/
|
||||||
private function userHasAnyActiveTimer(Authenticatable $user): bool
|
private function activeAnyTicketTimerQuery(Authenticatable $user): Builder
|
||||||
{
|
{
|
||||||
return TicketTimeLog::query()
|
return TicketTimeLog::query()
|
||||||
->where('user_id', $user->getAuthIdentifier())
|
->where('user_id', $user->getAuthIdentifier())
|
||||||
->whereNull('ended_at')
|
->whereNull('ended_at');
|
||||||
->exists();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the ticket's denormalised total without requiring a Ticket::timeLogs() relationship.
|
* Update the ticket's rounded minute total using stored seconds.
|
||||||
*/
|
*/
|
||||||
private function refreshTicketTimeSpentMinutes(): void
|
private function updateTicketTimeSpentFromSeconds(): void
|
||||||
{
|
{
|
||||||
$totalMinutes = (int) TicketTimeLog::query()
|
$totalSeconds = (int) TicketTimeLog::query()
|
||||||
->where('ticket_id', $this->ticket->getKey())
|
->where('ticket_id', $this->ticket->getKey())
|
||||||
->whereNotNull('ended_at')
|
->whereNotNull('ended_at')
|
||||||
->sum('minutes');
|
->sum('seconds');
|
||||||
|
|
||||||
|
if ($totalSeconds === 0) {
|
||||||
|
$totalSeconds = (int) TicketTimeLog::query()
|
||||||
|
->where('ticket_id', $this->ticket->getKey())
|
||||||
|
->whereNotNull('ended_at')
|
||||||
|
->sum('minutes') * 60;
|
||||||
|
}
|
||||||
|
|
||||||
$this->ticket->forceFill([
|
$this->ticket->forceFill([
|
||||||
'time_spent_minutes' => $totalMinutes,
|
'time_spent_minutes' => (int) ceil($totalSeconds / 60),
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +200,7 @@ class TicketTimer extends Component
|
|||||||
|
|
||||||
if (! $user instanceof Authenticatable) {
|
if (! $user instanceof Authenticatable) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'timer' => 'You must be logged in to use the timer.',
|
'timer' => 'You must be signed in to use the ticket timer.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,22 +208,12 @@ class TicketTimer extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the description to store against the timer.
|
* Return a safe note for the timer.
|
||||||
*/
|
*/
|
||||||
private function normalisedDescription(): string
|
private function normalisedDescription(): string
|
||||||
{
|
{
|
||||||
$description = trim($this->description);
|
$description = trim($this->description);
|
||||||
|
|
||||||
return $description !== '' ? $description : 'Timer work log';
|
return $description !== '' ? $description : 'Work timer';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect PostgreSQL/MySQL unique constraint collisions for active timers.
|
|
||||||
*/
|
|
||||||
private function isUniqueTimerConstraintViolation(QueryException $exception): bool
|
|
||||||
{
|
|
||||||
$sqlState = (string) ($exception->errorInfo[0] ?? '');
|
|
||||||
|
|
||||||
return in_array($sqlState, ['23000', '23505'], true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,44 +9,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
class TicketTimeLog extends Model
|
class TicketTimeLog extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* The database table used by the model.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $table = 'helpdesk_ticket_time_logs';
|
protected $table = 'helpdesk_ticket_time_logs';
|
||||||
|
|
||||||
/**
|
|
||||||
* The attributes that are mass assignable.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'ticket_id',
|
'ticket_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'minutes',
|
'minutes',
|
||||||
|
'seconds',
|
||||||
'description',
|
'description',
|
||||||
'logged_at',
|
|
||||||
'started_at',
|
'started_at',
|
||||||
'ended_at',
|
'ended_at',
|
||||||
'duration_seconds',
|
'logged_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
protected function casts(): array
|
||||||
* The attributes that should be cast.
|
{
|
||||||
*
|
return [
|
||||||
* @var array<string, string>
|
'ticket_id' => 'integer',
|
||||||
*/
|
'user_id' => 'integer',
|
||||||
protected $casts = [
|
'minutes' => 'integer',
|
||||||
'ticket_id' => 'integer',
|
'seconds' => 'integer',
|
||||||
'user_id' => 'integer',
|
'started_at' => 'datetime',
|
||||||
'minutes' => 'integer',
|
'ended_at' => 'datetime',
|
||||||
'duration_seconds' => 'integer',
|
'logged_at' => 'datetime',
|
||||||
'logged_at' => 'datetime',
|
];
|
||||||
'started_at' => 'datetime',
|
}
|
||||||
'ended_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ticket this time log belongs to.
|
* Get the ticket this time log belongs to.
|
||||||
@@ -57,7 +46,7 @@ class TicketTimeLog extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user this time log belongs to.
|
* Get the user who logged the time.
|
||||||
*/
|
*/
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,126 +1,132 @@
|
|||||||
<div
|
<div
|
||||||
x-data="{
|
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"
|
||||||
running: @js($activeTimer !== null),
|
data-helpdesk-ticket-timer
|
||||||
startedAt: @js($activeTimer?->started_at?->toIso8601String()),
|
|
||||||
elapsedSeconds: @js((int) $elapsedSeconds),
|
|
||||||
intervalId: null,
|
|
||||||
startClock() {
|
|
||||||
this.stopClock();
|
|
||||||
|
|
||||||
if (!this.running || !this.startedAt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tick();
|
|
||||||
this.intervalId = window.setInterval(() => this.tick(), 1000);
|
|
||||||
},
|
|
||||||
stopClock() {
|
|
||||||
if (this.intervalId) {
|
|
||||||
window.clearInterval(this.intervalId);
|
|
||||||
this.intervalId = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tick() {
|
|
||||||
if (!this.startedAt) {
|
|
||||||
this.elapsedSeconds = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.elapsedSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(this.startedAt)) / 1000));
|
|
||||||
},
|
|
||||||
formattedElapsed() {
|
|
||||||
const hours = String(Math.floor(this.elapsedSeconds / 3600)).padStart(2, '0');
|
|
||||||
const minutes = String(Math.floor((this.elapsedSeconds % 3600) / 60)).padStart(2, '0');
|
|
||||||
const seconds = String(this.elapsedSeconds % 60).padStart(2, '0');
|
|
||||||
|
|
||||||
return `${hours}:${minutes}:${seconds}`;
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
x-init="startClock()"
|
|
||||||
x-on:timer-started.window="running = true; startedAt = $event.detail.startedAt; startClock()"
|
|
||||||
x-on:timer-ended.window="running = false; startedAt = null; stopClock()"
|
|
||||||
class="mb-4 overflow-hidden rounded-xl border border-blue-200 bg-blue-50 shadow-sm dark:border-blue-900 dark:bg-blue-950/40"
|
|
||||||
data-dusk="ticket-timer-panel"
|
|
||||||
>
|
>
|
||||||
<div class="border-b border-blue-200 bg-blue-700 px-4 py-3 text-white dark:border-blue-900">
|
@php
|
||||||
<div class="flex items-center justify-between gap-3">
|
$hasRunningTimer = $activeTimer && $activeTimer->ended_at === null;
|
||||||
<div class="min-w-0">
|
@endphp
|
||||||
<h3 class="text-sm font-black uppercase tracking-wide">User timer</h3>
|
|
||||||
<p class="text-xs font-semibold text-blue-100">Database-backed timer for this ticket</p>
|
<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="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.
|
||||||
|
</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-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>
|
</div>
|
||||||
|
|
||||||
<span
|
@if ($message)
|
||||||
class="inline-flex shrink-0 items-center rounded-lg px-2.5 py-1 text-[0.68rem] font-black"
|
<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">
|
||||||
:class="running ? 'bg-emerald-500 text-white' : 'bg-white/15 text-blue-50'"
|
{{ $message }}
|
||||||
x-text="running ? 'Running' : 'Stopped'"
|
</div>
|
||||||
></span>
|
@endif
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3 p-4">
|
@error('timer')
|
||||||
@if ($message !== null)
|
<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">
|
||||||
<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-800 dark:bg-emerald-950 dark:text-emerald-100">
|
{{ $message }}
|
||||||
{{ $message }}
|
</div>
|
||||||
</div>
|
@enderror
|
||||||
@endif
|
|
||||||
|
|
||||||
@error('timer')
|
<div class="space-y-2">
|
||||||
<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-800 dark:bg-red-950 dark:text-red-100">
|
<label class="block text-xs font-bold text-emerald-950 dark:text-emerald-100" for="ticket_timer_description">
|
||||||
{{ $message }}
|
Timer note
|
||||||
</div>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
|
||||||
<div>
|
|
||||||
<label for="ticket-timer-description" class="mb-2 block text-xs font-bold text-zinc-700 dark:text-zinc-200">
|
|
||||||
Work note
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
id="ticket-timer-description"
|
id="ticket_timer_description"
|
||||||
wire:model.defer="description"
|
wire:model.defer="description"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Optional note for this timed work"
|
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-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
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"
|
data-dusk="ticket-timer-description"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-blue-200 bg-white px-4 py-3 text-center shadow-sm dark:border-blue-900 dark:bg-zinc-950">
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="text-[0.65rem] font-black uppercase tracking-[0.16em] text-blue-700 dark:text-blue-200">
|
@if ($hasRunningTimer)
|
||||||
Elapsed
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div class="mt-1 font-mono text-xl font-black text-zinc-900 dark:text-zinc-100" x-text="formattedElapsed()">
|
wire:click="endTimer"
|
||||||
{{ gmdate('H:i:s', (int) $elapsedSeconds) }}
|
wire:loading.attr="disabled"
|
||||||
</div>
|
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
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@if ($activeTimer === null)
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
wire:click="startTimer"
|
|
||||||
wire:loading.attr="disabled"
|
|
||||||
wire:target="startTimer"
|
|
||||||
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 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
data-dusk="start-ticket-timer"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-play mr-2" aria-hidden="true"></i>
|
|
||||||
Start timer
|
|
||||||
</button>
|
|
||||||
@else
|
|
||||||
<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>
|
|
||||||
End timer
|
|
||||||
</button>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@php
|
||||||
|
$ticketTimeLogs = \Modules\Helpdesk\Models\TicketTimeLog::query()
|
||||||
|
->with('user')
|
||||||
|
->where('ticket_id', $ticket->getKey())
|
||||||
|
->whereNotNull('ended_at')
|
||||||
|
->latest('logged_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$ticketLoggedSeconds = (int) $ticketTimeLogs->sum(function ($timeLog) {
|
||||||
|
return (int) ($timeLog->seconds ?? ((int) $timeLog->minutes * 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
$ticketLoggedHours = intdiv($ticketLoggedSeconds, 3600);
|
||||||
|
$ticketLoggedMinutes = intdiv($ticketLoggedSeconds % 3600, 60);
|
||||||
|
$ticketLoggedRemainingSeconds = $ticketLoggedSeconds % 60;
|
||||||
|
@endphp
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketTimerLiveDisplayFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The timer display must tick in the browser from the database-backed started_at value.
|
||||||
|
*/
|
||||||
|
public function test_timer_view_has_browser_side_running_display_without_local_storage(): void
|
||||||
|
{
|
||||||
|
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
|
||||||
|
|
||||||
|
$this->assertStringContainsString('x-data', $view);
|
||||||
|
$this->assertStringContainsString('Date.parse', $view);
|
||||||
|
$this->assertStringContainsString('setInterval', $view);
|
||||||
|
$this->assertStringContainsString('started_at', $view);
|
||||||
|
$this->assertStringContainsString('data-dusk="ticket-running-time"', $view);
|
||||||
|
$this->assertStringNotContainsString('localStorage', $view);
|
||||||
|
$this->assertStringNotContainsString('sessionStorage', $view);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Helpdesk\Tests\Feature;
|
||||||
|
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketTimerSecondsFilesTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Timer files must support seconds storage and display.
|
||||||
|
*/
|
||||||
|
public function test_timer_supports_seconds_storage_and_display(): void
|
||||||
|
{
|
||||||
|
$component = (string) file_get_contents(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
|
||||||
|
$model = (string) file_get_contents(base_path('Modules/Helpdesk/Models/TicketTimeLog.php'));
|
||||||
|
$view = (string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
|
||||||
|
|
||||||
|
$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->assertStringNotContainsString('localStorage', $component);
|
||||||
|
$this->assertStringNotContainsString('localStorage', $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The history query partial should read persisted timer logs directly.
|
||||||
|
*/
|
||||||
|
public function test_history_query_reads_ticket_time_logs_directly(): void
|
||||||
|
{
|
||||||
|
$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('seconds', $partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Tests\Feature;
|
|
||||||
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class TicketTimerFilesTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The Livewire timer view must keep its root element first.
|
|
||||||
*/
|
|
||||||
public function test_ticket_timer_view_does_not_start_with_a_style_tag(): void
|
|
||||||
{
|
|
||||||
$view = trim((string) file_get_contents(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php')));
|
|
||||||
|
|
||||||
$this->assertStringStartsWith('<div', $view);
|
|
||||||
$this->assertFalse(
|
|
||||||
str_starts_with($view, '<style'),
|
|
||||||
'The ticket timer Livewire view must not start with a <style> tag.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The timer must be database backed rather than session/localStorage backed.
|
|
||||||
*/
|
|
||||||
public function test_ticket_timer_is_not_session_or_local_storage_backed(): 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->assertStringNotContainsString('session(', $component);
|
|
||||||
$this->assertStringNotContainsString('localStorage', $component);
|
|
||||||
$this->assertStringNotContainsString('localStorage', $view);
|
|
||||||
$this->assertStringContainsString('whereNull', $component);
|
|
||||||
$this->assertStringContainsString('activeUserTimerQuery', $component);
|
|
||||||
$this->assertStringContainsString('TicketTimeLog::query()', $component);
|
|
||||||
$this->assertStringNotContainsString('->timeLogs()', $component);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ticket page must include the user-specific timer partial.
|
|
||||||
*/
|
|
||||||
public function test_ticket_show_timer_partial_exists(): void
|
|
||||||
{
|
|
||||||
$this->assertFileExists(base_path('Modules/Helpdesk/resources/views/partials/ticket-time-timer.blade.php'));
|
|
||||||
$this->assertFileExists(base_path('Modules/Helpdesk/resources/views/livewire/ticket-timer.blade.php'));
|
|
||||||
$this->assertFileExists(base_path('Modules/Helpdesk/Livewire/TicketTimer.php'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user