Co nowego:
- Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log
  bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów
  i auto-odświeżaniem.
- Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez
  przypisanej kategorii/podkategorii.
- Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla
  nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto),
  łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po
  e-mailu, nowe flagi --assign-operators i --fix-closed-dates do
  donaprawiania wcześniejszych importów, dedykowany log
  storage/logs/hesk-import.log.
- Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i
  filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez
  podkategorii (np. z poczty IMAP) — teraz liczone poprawnie.
- Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z
  aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy
  operatora).
- Porządki w bazie: usunięte niewykorzystywane kolumny
  (users.remember_token, users.email_verified_at,
  email_templates.trigger_label); wartości pól dodatkowych, stan
  triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli
  tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries,
  ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są
  teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach
  pivot; tickets.source/ticket_messages.source walidowane względem znanego
  zestawu wartości.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:28:03 +02:00
parent 03c6ec7cae
commit 4b70b910a9
45 changed files with 2038 additions and 156 deletions

View File

@@ -16,10 +16,10 @@
style="position:relative;display:inline-block"
>
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
<span class="material-symbols-outlined" x-text="icon()"></span>
<span class="material-symbols-outlined" x-text="typeof icon === 'function' ? icon() : ''"></span>
</button>
<div
x-show="open"
x-show="typeof open !== 'undefined' && open"
x-cloak
class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"

View File

@@ -0,0 +1,69 @@
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<h3 style="margin:0">Logi</h3>
<label style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:400;color:var(--color-text)">
<input type="checkbox" wire:model.live="autoRefresh" style="position:static;opacity:1;width:auto;height:auto">
Odświeżaj automatycznie (5 s)
</label>
</div>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
Podgląd plików z <code>storage/logs/</code> co robi aplikacja i zaplanowane integracje (IMAP, automatyzacje, SLA). Widok tylko do odczytu, pokazuje ostatni fragment pliku.
</p>
@if ($autoRefresh)
<div wire:poll.5s="$refresh" style="display:none"></div>
@endif
@if ($this->files->isEmpty())
<p class="text-muted" style="font-size:13px">Brak plików logów w <code>storage/logs/</code>.</p>
@else
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
@foreach ($this->files as $file)
<button type="button" wire:click="selectFile('{{ $file['name'] }}')"
style="display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:6px 12px;border-radius:8px;cursor:pointer;font-size:12.5px;text-align:left;border:1px solid {{ $selectedFile === $file['name'] ? 'var(--color-accent)' : 'var(--color-divider)' }};background:{{ $selectedFile === $file['name'] ? 'color-mix(in srgb, var(--color-accent) 14%, transparent)' : 'transparent' }};color:{{ $selectedFile === $file['name'] ? 'var(--color-accent)' : 'var(--color-text)' }}">
<span style="font-weight:500">{{ $file['name'] }}</span>
<span class="text-muted" style="font-size:11px">{{ $this->formatBytes($file['size']) }} &middot; {{ $file['modified']->diffForHumans() }}</span>
</button>
@endforeach
</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px">
<select class="input" wire:model.live="levelFilter" style="width:auto">
<option value="">Wszystkie poziomy</option>
@foreach (\App\Livewire\Admin\Logs::availableLevels() as $level)
<option value="{{ $level }}">{{ $level }}</option>
@endforeach
</select>
<input class="input" type="search" wire:model.live.debounce.400ms="search" placeholder="Szukaj w treści..." style="width:220px">
<select class="input" wire:model.live="limit" style="width:auto">
<option value="100">ostatnie 100</option>
<option value="300">ostatnie 300</option>
<option value="1000">ostatnie 1000</option>
<option value="3000">ostatnie 3000</option>
</select>
<button type="button" class="btn btn-ghost" wire:click="$refresh">
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:-3px">refresh</span>
Odśwież
</button>
<span class="text-muted" style="font-size:12px;margin-left:auto">{{ $this->entries->count() }} wpisów</span>
</div>
<div wire:key="log-box-{{ $selectedFile }}-{{ $levelFilter }}-{{ $search }}-{{ $limit }}"
x-data x-init="$el.scrollTop = $el.scrollHeight"
style="height:65vh;overflow:auto;border:1px solid var(--color-divider);border-radius:8px;background:color-mix(in srgb, var(--color-text) 4%, var(--color-bg));padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5">
@forelse ($this->entries as $entry)
@php $badge = \App\Livewire\Admin\Logs::levelBadge($entry['level']); @endphp
<div style="display:flex;gap:8px;align-items:flex-start;padding:3px 0;border-bottom:1px solid color-mix(in srgb, var(--color-text) 6%, transparent)">
<span class="{{ $badge['class'] }}" style="flex:none;font-size:10px;padding:1px 6px;margin-top:2px;{{ $badge['style'] }}">{{ $entry['level'] ?? '?' }}</span>
<span style="white-space:pre-wrap;word-break:break-word;flex:1">{{ $entry['text'] }}</span>
</div>
@empty
<p class="text-muted" style="margin:0">Brak wpisów spełniających kryteria.</p>
@endforelse
</div>
@endif
</div>

View File

@@ -22,6 +22,7 @@ $tabGroups = [
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
['key' => 'logs', 'label' => 'Logi', 'icon' => 'terminal'],
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
],
];
@@ -620,9 +621,12 @@ $tabGroups = [
<h4 style="margin:0">Sesja i strefa czasowa</h4>
<div class="field"><label>Czas wygaśnięcia sesji (minuty)</label><input class="input" type="number" wire:model="systemConfig.sessionLifetimeMinutes"></div>
<div class="field"
wire:key="admin-timezone-clock"
x-data="{
tz: @js($systemConfig['timezone']),
now: '',
tickHandle: null,
navigatingHandler: null,
tick() {
try {
this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date());
@@ -630,8 +634,13 @@ $tabGroups = [
this.now = '—';
}
},
}"
x-init="tick(); setInterval(() => tick(), 1000)">
init() {
this.tick();
this.tickHandle = setInterval(() => this.tick(), 1000);
this.navigatingHandler = () => clearInterval(this.tickHandle);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}">
<label>Strefa czasowa</label>
<select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()">
@foreach (\DateTimeZone::listIdentifiers() as $tzId)
@@ -640,7 +649,7 @@ $tabGroups = [
</select>
<div style="display:flex;gap:4px;font-size:12px">
<span class="text-muted">Aktualny czas:</span>
<strong x-text="now"></strong>
<strong x-text="typeof now !== 'undefined' ? now : ''"></strong>
</div>
</div>
</div>
@@ -983,6 +992,11 @@ $tabGroups = [
<livewire:admin.api-keys />
@endif
{{-- ================= LOGS ================= --}}
@if ($tab === 'logs')
<livewire:admin.logs />
@endif
@if ($tab === 'about')
<h3 style="margin:0 0 14px">O aplikacji</h3>
<div class="card" style="padding:18px;gap:10px;max-width:420px">

View File

@@ -95,7 +95,7 @@
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -13,15 +13,27 @@
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
@endphp
<div
wire:key="ticket-autorefresh-{{ $ticket->id }}"
class="btn btn-secondary"
style="cursor:pointer;gap:6px"
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
x-data="{
remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }},
tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshTicketData();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div>
</div>
@@ -89,7 +101,7 @@
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -92,7 +92,7 @@
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -6,12 +6,12 @@
class="side-rail"
style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))"
x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }"
x-effect="localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
:class="collapsed ? 'side-rail-collapsed' : ''"
x-effect="typeof collapsed !== 'undefined' && localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
:class="(typeof collapsed !== 'undefined' && collapsed) ? 'side-rail-collapsed' : ''"
>
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="collapsed ? 'Rozwiń panel' : 'Zwiń panel'">
<span class="material-symbols-outlined" style="font-size:18px" x-text="collapsed ? 'left_panel_open' : 'left_panel_close'"></span>
<span class="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span>
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="(typeof collapsed !== 'undefined' && collapsed) ? 'Rozwiń panel' : 'Zwiń panel'">
<span class="material-symbols-outlined" style="font-size:18px" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'left_panel_open' : 'left_panel_close'"></span>
<span class="side-rail-toggle-label" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'Przegląd' : 'Zwiń panel'"></span>
</button>
<button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))">
@@ -90,6 +90,7 @@
</select>
<select class="input" style="width:auto" wire:model.live="filterCategory">
<option value="all">Wszystkie kategorie</option>
<option value="none">Bez kategorii</option>
@foreach ($this->categories as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach
@@ -100,7 +101,7 @@
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
Zapisane widoki
</button>
<div x-show="open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
<div x-show="typeof open !== 'undefined' && open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
@forelse ($this->savedViews as $view)
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}">
<button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button>
@@ -116,7 +117,7 @@
<template x-if="! adding">
<button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
</template>
<div x-show="adding" style="display:flex;gap:6px;padding:4px 2px">
<div x-show="typeof adding !== 'undefined' && adding" style="display:flex;gap:6px;padding:4px 2px">
<input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false">
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
</div>
@@ -128,7 +129,7 @@
<span class="material-symbols-outlined" style="font-size:18px">view_column</span>
Kolumny
</button>
<div x-show="open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:180px">
<div x-show="typeof open !== 'undefined' && open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:180px">
@foreach ($columnDefs as $key => $label)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer">
<input type="checkbox" @checked(in_array($key, $visibleColumns)) wire:click="toggleColumn('{{ $key }}')">
@@ -147,15 +148,27 @@
$refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds'));
@endphp
<div
wire:key="queue-autorefresh"
class="btn btn-secondary"
style="cursor:pointer;gap:6px"
x-data="{ remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }} }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
x-data="{
remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }},
tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshQueue();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshQueue()"
title="Kolejka odświeża się automatycznie — kliknij, aby odświeżyć teraz"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div>
</div>

View File

@@ -25,15 +25,27 @@
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
@endphp
<div
wire:key="ticket-autorefresh-{{ $ticket->id }}"
class="btn btn-secondary"
style="cursor:pointer;gap:6px"
x-data="{ remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }} }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
x-data="{
remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }},
tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshTicketData();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
<span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div>
</div>
@@ -140,7 +152,7 @@
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label>
@@ -216,7 +228,7 @@
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
:style="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && dragging) ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label>
@@ -235,7 +247,7 @@
<button class="btn btn-primary" type="button" style="border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid var(--color-bg);padding:0 6px" @click="open = !open">
<span class="material-symbols-outlined" style="font-size:16px">expand_more</span>
</button>
<div x-show="open" x-cloak style="position:absolute;bottom:100%;right:0;margin-bottom:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:10">
<div x-show="typeof open !== 'undefined' && open" x-cloak style="position:absolute;bottom:100%;right:0;margin-bottom:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:10">
@forelse ($this->replyQuickActions as $qa)
<button type="button" class="theme-toggle-option" @click="open = false" wire:click="sendAndTransition({{ $qa->id }})">{{ $qa->label }}</button>
@empty
@@ -439,6 +451,7 @@
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">MONITOR CZASU PRACY</div>
<div
wire:key="ticket-timer-{{ $ticket->id }}"
style="display:flex;flex-direction:column;gap:6px"
x-data="{
seconds: {{ $ticket->timerElapsedSeconds() }},
@@ -464,16 +477,43 @@
// The timer only tracks time this ticket is actually open in
// the browser: stop it server-side the moment the operator
// navigates elsewhere in the app (wire:navigate — a normal
// Livewire call, since the document itself isn't unloading)…
// SPA transition, since the document itself isn't unloading)…
//
// Alpine has no automatic on-element-removed hook (a
// `destroy(){}` method here — as this used to have — is never
// called by anything), so wire:navigate leaving this ticket
// page previously left `tick`'s setInterval running forever in
// the background against a stale scope, eventually throwing
// 'format is not defined' or similar once Alpine's reactivity
// for it was in a half-torn-down state. Explicitly tearing
// everything down here (the one hook that's actually invoked,
// on navigation away) fixes that at the source.
//
// This deliberately does NOT use $wire.call('stopTimer') —
// that used to be here, but firing a Livewire action from
// inside the livewire:navigating handler makes Livewire process
// that action's response (and morph the DOM for it) while the
// SPA navigation to the next page is already underway, which
// corrupts unrelated Alpine components elsewhere on the page
// (confirmed via a browser console capture: 'Illegal invocation'
// errors on the shared topbar's dropdowns, traced back to this
// exact handler in the stack). sendBeacon — the same mechanism
// pagehideHandler already uses below, for the same underlying
// reason — is a plain HTTP POST that never touches Livewire's
// component/morph pipeline, so it can't race with navigation.
this.navigatingHandler = () => {
if (this.running) $wire.call('stopTimer');
if (this.running) {
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
}
this.teardown();
};
document.addEventListener('livewire:navigating', this.navigatingHandler);
// …or closes/reloads the tab. Regular fetch/Livewire calls
// can get cut off mid-flight during an actual page unload, so
// this uses sendBeacon against a plain endpoint instead, which
// browsers guarantee to deliver.
// browsers guarantee to deliver. No teardown() needed here —
// the whole JS realm is going away with the page.
this.pagehideHandler = () => {
if (this.running) {
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
@@ -481,7 +521,7 @@
};
window.addEventListener('pagehide', this.pagehideHandler);
},
destroy() {
teardown() {
clearInterval(this.tick);
document.removeEventListener('livewire:navigating', this.navigatingHandler);
window.removeEventListener('pagehide', this.pagehideHandler);
@@ -500,7 +540,12 @@
</div>
@else
<div style="font-size:12px;display:flex;align-items:center;gap:6px">
<span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span>
{{-- Guarded call: even with the interval/listener cleanup above, a
refreshTicketData() response already in flight when the operator
navigates away can still land afterward and get morphed against
a scope that's mid-teardown typeof-guarding here means that
race can no longer throw, whatever exactly triggers it. --}}
<span>Czas w zgłoszeniu: <strong x-text="typeof format === 'function' ? format() : ''"></strong></span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
</div>
@if ($ticket->isClosed())