phases one of time logged now with log display

This commit is contained in:
Mark Gregory
2026-06-22 14:03:35 +01:00
parent fcd60471be
commit bc213e0c6e
9 changed files with 307 additions and 290 deletions
@@ -1,126 +1,132 @@
<div
x-data="{
running: @js($activeTimer !== null),
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"
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"
data-helpdesk-ticket-timer
>
<div class="border-b border-blue-200 bg-blue-700 px-4 py-3 text-white dark:border-blue-900">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<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>
@php
$hasRunningTimer = $activeTimer && $activeTimer->ended_at === 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="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>
<span
class="inline-flex shrink-0 items-center rounded-lg px-2.5 py-1 text-[0.68rem] font-black"
:class="running ? 'bg-emerald-500 text-white' : 'bg-white/15 text-blue-50'"
x-text="running ? 'Running' : 'Stopped'"
></span>
</div>
</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 }}
</div>
@endif
<div class="space-y-3 p-4">
@if ($message !== null)
<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 }}
</div>
@endif
@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
@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-800 dark:bg-red-950 dark:text-red-100">
{{ $message }}
</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
<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"
id="ticket_timer_description"
wire:model.defer="description"
rows="2"
placeholder="Optional note for this timed work"
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"
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>
</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="text-[0.65rem] font-black uppercase tracking-[0.16em] text-blue-700 dark:text-blue-200">
Elapsed
</div>
<div class="mt-1 font-mono text-xl font-black text-zinc-900 dark:text-zinc-100" x-text="formattedElapsed()">
{{ gmdate('H:i:s', (int) $elapsedSeconds) }}
</div>
<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
</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>
@@ -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