v1.5.0
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:
69
src/resources/views/livewire/admin/logs.blade.php
Normal file
69
src/resources/views/livewire/admin/logs.blade.php
Normal 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']) }} · {{ $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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user