- Real-time updates (Laravel Reverb): live operator queue, live ticket
  chat/detail updates for operator and client, periodic fallback refresh
  with a visible countdown as a backstop for dropped websocket connections.
- SLA automation rules (Admin > Automatyzacja SLA): act on a ticket after
  N minutes of customer silence (change priority/status/team/assignee),
  evaluated every 15 minutes, reusing TicketService's own setters so
  automated changes get the same history/notification/broadcast a manual
  change would.
- New notification: every operator on a matching team gets notified when
  a new ticket lands in one of their subcategories.
- BookStack knowledge-base sidebar now also shown on the client's own
  ticket view (previously operator-only); suggestions everywhere now load
  in after first paint instead of blocking it.
- Client ticket view: shows assigned operator + team; page widened to
  match the operator's.
- Notification bell shows unread only; read notifications disappear
  instead of just dimming.
- Stats dashboard: sectioned layout, new breakdowns (by subcategory, CSAT
  by team/operator, top clients, client x subcategory cross-tab).
- Mobile: nav dropdowns (theme/notifications/profile) now expand full
  width instead of overflowing off-screen below 640px.
- Fixed two bugs that silently disabled all real-time updates (missing
  CSRF header on Echo's private-channel auth; a script-load-order race
  that could miss the livewire:init event) and the mariadb healthcheck
  (world-writable credentials file on this stack's NFS mount).
- Assorted test-suite fixes (roles virtual attribute needs the roles
  table seeded; a few missing seeds/wrong assertions found along the way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:43:05 +02:00
parent def7c70887
commit 0b06687ea1
64 changed files with 3613 additions and 168 deletions

View File

@@ -343,6 +343,22 @@ body {
.page-pad { padding: 16px !important; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
.nav-panel-label { display: none; }
/* Theme/notifications/profile dropdowns are anchored (position:absolute)
to their own small trigger button by default, which overflows off the
edge of narrow screens once their fixed width no longer fits between
the button and the viewport edge. Dropping the wrapper's own
positioning context makes .nav itself (already position:relative) the
containing block instead, so left/right:0 spans the whole navbar
width rather than the button's. */
.nav-dropdown-wrap { position: static !important; }
.nav-dropdown {
left: 0 !important;
right: 0 !important;
width: auto !important;
min-width: 0 !important;
margin-top: 8px !important;
}
.profile-menu-name { display: none; }
.main-col { min-width: 0; }
.aside-col { width: 100%; }

View File

@@ -1 +1,9 @@
//
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allow your team to quickly build robust real-time web applications.
*/
import './echo';

84
src/resources/js/echo.js Normal file
View File

@@ -0,0 +1,84 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
// Private channel subscriptions POST to /broadcasting/auth, which sits
// behind the app's normal CSRF middleware like any other POST route —
// without this header every private-channel auth request 419s silently
// (pusher-js swallows it as a subscription error), so nothing broadcast
// ever reaches the browser even though the socket connection itself works.
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
auth: {
headers: {
'X-CSRF-TOKEN': csrfToken,
},
},
});
/**
* Bridges Reverb broadcast events into plain Livewire events rather than
* using the `#[On('echo-private:...')]` attribute directly on components —
* this indirection is deliberately version-agnostic and easy to verify from
* the browser console regardless of Livewire's internals.
*
* This file is loaded via @vite as `type="module"`, which the HTML spec
* defers until after the document is parsed — meaning any plain
* (non-deferred) <script> earlier in the page, including Livewire's own
* bootstrap script from @livewireScripts, has ALREADY run by the time this
* executes. So `window.Livewire` is already available here; there's no
* reason to wait for the 'livewire:init' event. Waiting for it was actually
* a bug: Livewire dispatches that event synchronously as part of its own
* (earlier-running) script, so a listener registered this late permanently
* missed it — silently disabling this whole subscription, every time.
*/
if (window.currentUserId) {
window.Echo.private('operator.queue')
.listen('.TicketQueueChanged', (e) => {
if (e.actorId !== window.currentUserId) {
// Only ticketId is passed through — Livewire calls #[On] methods
// with the payload as named arguments, so keeping this to a
// single well-known key avoids every listener having to declare
// (and ignore) every field this event might ever carry.
Livewire.dispatch('queue-changed', { ticketId: e.ticketId });
}
})
.error((error) => console.error('operator.queue subscription error', error));
}
/**
* Subscribes to a single ticket's channel — called by the Blade view of
* whichever TicketShow component (operator or client) is currently mounted,
* since the channel name needs the ticket id that only the page knows.
*/
window.subscribeToTicketChannel = function (ticketId) {
window.Echo.private('ticket.' + ticketId)
.listen('.TicketMessagePosted', (e) => {
if (e.actorId !== window.currentUserId) {
Livewire.dispatch('ticket-message-posted', { ticketId: e.ticketId });
}
})
.listen('.TicketQueueChanged', (e) => {
if (e.actorId !== window.currentUserId) {
Livewire.dispatch('queue-changed', { ticketId: e.ticketId });
}
})
.error((error) => console.error('ticket.' + ticketId + ' subscription error', error));
};
// The @script block in ticket-show.blade.php calls subscribeToTicketChannel()
// as soon as Livewire processes that component — which can happen either
// before or after this deferred module has run, depending on exactly when
// Livewire gets to it. If it ran first, it queued the ticket id here instead
// of finding the function undefined; flush that queue now that we're ready.
(window.__pendingTicketChannelIds || []).forEach((id) => window.subscribeToTicketChannel(id));
window.__pendingTicketChannelIds = null;

View File

@@ -16,7 +16,7 @@
@endphp
@if ($user)
<div x-data="{ open: false }" @click.outside="open = false" style="position:relative;display:inline-block">
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block">
<button type="button" class="btn btn-secondary" @click="open = !open" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">account_circle</span>
<span class="profile-menu-name">{{ $user->name }}</span>
@@ -25,6 +25,7 @@
<div
x-show="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:200px;overflow:hidden;z-index:30"
>
@foreach ($areas as $area)

View File

@@ -12,6 +12,7 @@
}
}"
@click.outside="open = false"
class="nav-dropdown-wrap"
style="position:relative;display:inline-block"
>
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
@@ -20,6 +21,7 @@
<div
x-show="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"
>
<button type="button" class="theme-toggle-option" @click="apply('light')">

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? \App\Support\Settings::get('company_name') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ \App\Support\Settings::faviconUrl() }}">
@@ -70,6 +71,12 @@
.ql-editor hr { border: none; border-top: 1px solid var(--color-divider); margin: 10px 0; }
</style>
<script>
// Lets the Echo listeners in resources/js/echo.js tell "my own action
// echoed back" apart from "someone else changed this" without a
// roundtrip — broadcast event payloads carry the same actorId shape.
window.currentUserId = @json(auth()->id());
</script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
@livewireStyles

View File

@@ -7,6 +7,7 @@ $tabGroups = [
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
],
'Zespół' => [
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
@@ -322,6 +323,39 @@ $tabGroups = [
@endif
@endif
{{-- ================= AUTOMATION RULES ================= --}}
@if ($tab === 'automation-rules')
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Automatyzacja SLA</h3>
<button class="btn btn-primary" wire:click="openAutomationRuleForm">+ Dodaj regułę</button>
</div>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Co 15 minut sprawdzane jest, czy zgłoszenie milczy (brak odpowiedzi klienta) dłużej niż próg reguły jeśli tak (i pasuje do opcjonalnego zawężenia), wykonywana jest wybrana akcja. Reguła nie powtarza się dla tego samego zgłoszenia, dopóki klient znów nie napisze albo zgłoszenie nie zostanie zamknięte i otwarte ponownie.</p>
@if ($this->automationRules->isNotEmpty())
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($this->automationRules as $rule)
<div class="card" style="padding:14px 16px;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div>
<span class="card-title">{{ $rule->label }}</span>
<span class="text-muted" style="font-size:12px;display:block">Brak odpowiedzi klienta &ge; {{ $rule->condition_minutes }} min. {{ match($rule->action_type) { 'change_priority' => 'zmień priorytet', 'change_status' => 'zmień status', 'change_team' => 'zmień zespół', 'change_assignee' => 'zmień przypisanie', default => $rule->action_type } }}</span>
</div>
<div style="display:flex;gap:6px;flex:none;align-items:center">
<label style="display:flex;align-items:center;gap:4px;font-size:12px">
<input type="checkbox" wire:click="toggleAutomationRuleEnabled({{ $rule->id }})" @checked($rule->enabled)>
Aktywna
</label>
<button class="btn btn-ghost" type="button" wire:click="editAutomationRule({{ $rule->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeAutomationRule({{ $rule->id }})">Usuń</button>
</div>
</div>
</div>
@endforeach
</div>
@else
<p class="text-muted" style="font-size:13px">Brak reguł automatyzacji. Dodaj pierwszą używając przycisku wyżej.</p>
@endif
@endif
{{-- ================= STATUSES ================= --}}
@if ($tab === 'statuses')
<h3 style="margin:0 0 6px">Statusy</h3>
@@ -803,6 +837,96 @@ $tabGroups = [
</div>
@endif
@if ($automationRuleFormOpen)
<div class="dialog-backdrop">
<form wire:submit="submitAutomationRule" class="dialog" style="max-width:520px">
<div class="dialog-title">{{ $automationRuleForm['id'] ? 'Edytuj regułę automatyzacji' : 'Nowa reguła automatyzacji' }}</div>
<div class="field"><label>Nazwa reguły</label><input class="input" wire:model="automationRuleForm.label" placeholder="np. Eskalacja przy braku odpowiedzi"></div>
@error('automationRuleForm.label') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field">
<label>Brak odpowiedzi klienta przez (minuty)</label>
<input class="input" type="number" min="1" wire:model="automationRuleForm.condition_minutes">
</div>
@error('automationRuleForm.condition_minutes') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field">
<label>Zawężenie (opcjonalne puste = dowolne)</label>
<select class="input" wire:model="automationRuleForm.scope_priority_key" style="margin-bottom:6px">
<option value="">Dowolny priorytet</option>
@foreach ($this->priorities as $p)
<option value="{{ $p->key }}">{{ $p->label }}</option>
@endforeach
</select>
<select class="input" wire:model="automationRuleForm.scope_subcategory_id" style="margin-bottom:6px">
<option value="">Dowolna kategoria</option>
@foreach ($this->subcategoriesForTeamForm as $s)
<option value="{{ $s['id'] }}">{{ $s['label'] }}</option>
@endforeach
</select>
<select class="input" wire:model="automationRuleForm.scope_team_id">
<option value="">Dowolny zespół</option>
@foreach ($this->teams as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
</div>
<div class="field">
<label>Akcja</label>
<select class="input" wire:model.live="automationRuleForm.action_type">
<option value="change_priority">Zmień priorytet</option>
<option value="change_status">Zmień status</option>
<option value="change_team">Zmień zespół</option>
<option value="change_assignee">Zmień przypisanie</option>
</select>
</div>
<div class="field">
<label>Nowa wartość</label>
@switch ($automationRuleForm['action_type'])
@case ('change_priority')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->priorities as $p)
<option value="{{ $p->key }}">{{ $p->label }}</option>
@endforeach
</select>
@break
@case ('change_status')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->statuses as $s)
<option value="{{ $s->key }}">{{ $s->label }}</option>
@endforeach
</select>
@break
@case ('change_team')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->teams as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
@break
@case ('change_assignee')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->operatorsForTeamForm as $o)
<option value="{{ $o['id'] }}">{{ $o['label'] }}</option>
@endforeach
</select>
@break
@endswitch
</div>
@error('automationRuleForm.action_value') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="dialog-actions">
<button class="btn btn-secondary" type="button" wire:click="closeAutomationRuleForm">Anuluj</button>
<button class="btn btn-primary" type="submit">{{ $automationRuleForm['id'] ? 'Zapisz' : 'Dodaj regułę' }}</button>
</div>
</form>
</div>
@endif
@if ($this->editingTemplate)
<div class="dialog-backdrop">
<div class="dialog" style="max-width:560px">

View File

@@ -60,7 +60,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -1,8 +1,24 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">&larr; Wróć do listy</a>
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
@@ -31,7 +47,7 @@
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($threadMessages as $m)
@php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@@ -94,12 +110,18 @@
<div style="font-size:14px;font-weight:500">{{ auth()->user()->name }}</div>
<div class="text-muted" style="font-size:13px">{{ auth()->user()->email }}</div>
</div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
</div>
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Status i priorytet</div>
<div style="display:flex;gap:6px">
<div style="display:flex;gap:6px;flex-wrap:wrap">
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</div>
<div style="font-size:13px" class="text-muted">Przypisany operator: <span style="color:var(--color-text)">{{ $ticket->assignee?->name ?? 'Nieprzypisane' }}</span></div>
<div style="font-size:13px" class="text-muted">Zespół: <span style="color:var(--color-text)">{{ $ticket->team?->name ?? 'Brak' }}</span></div>
@if (! $ticket->isClosed())
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
@elseif ($ticket->isClosed())
@@ -137,7 +159,7 @@
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Historia zmian</div>
@forelse ($ticket->histories as $h)
<div style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
<div wire:key="history-{{ $h->id }}" style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
@empty
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
@endforelse
@@ -169,4 +191,19 @@
</div>
</div>
@endif
@script
<script>
// Re-runs on every mount of this component, including after a
// wire:navigate to a different ticket, so the socket subscription
// always matches whichever ticket is currently on screen.
if (window.subscribeToTicketChannel) {
window.subscribeToTicketChannel({{ $ticket->id }});
} else {
// echo.js (a deferred module) hasn't run yet — queue the id so it
// subscribes as soon as it does, instead of silently doing nothing.
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
}
</script>
@endscript
</div>

View File

@@ -90,7 +90,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -1,4 +1,4 @@
<div x-data="{ open: false }" @click.outside="open = false" style="position:relative;display:inline-block" wire:poll.30s="$refresh">
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.30s="$refresh">
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
@if ($this->unreadCount)
@@ -9,6 +9,7 @@
<div
x-show="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);width:320px;max-height:420px;overflow-y:auto;z-index:30"
>
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--color-divider)">
@@ -20,11 +21,12 @@
@forelse ($this->notifications as $notification)
<a
wire:key="notification-{{ $notification->id }}"
href="{{ $notification->data['url'] ?? '#' }}"
wire:navigate
wire:click="markAsRead('{{ $notification->id }}')"
@click="open = false"
style="display:block;padding:10px 14px;text-decoration:none;color:var(--color-text);border-bottom:1px solid var(--color-divider);font-size:12.5px;{{ $notification->read_at ? 'opacity:0.6' : 'background:color-mix(in srgb, var(--color-accent) 6%, transparent)' }}"
style="display:block;padding:10px 14px;text-decoration:none;color:var(--color-text);border-bottom:1px solid var(--color-divider);font-size:12.5px;background:color-mix(in srgb, var(--color-accent) 6%, transparent)"
>
<div>{{ $notification->data['message'] ?? '' }}</div>
<div style="font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:2px">{{ $notification->created_at->diffForHumans() }}</div>

View File

@@ -66,7 +66,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -116,6 +116,21 @@
@endforeach
</div>
</div>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently (backgrounded tab, network blip) this is a periodic
fallback refresh, with a visible countdown so it's clear the queue
is still refreshing itself rather than just stuck. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 60, total: 60 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
title="Kolejka odświeża się automatycznie co minutę"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div class="table-wrap">
@@ -143,7 +158,7 @@
<tbody>
@foreach ($this->filteredTickets as $t)
@php $sla = $t->slaInfo(); @endphp
<tr>
<tr wire:key="ticket-{{ $t->id }}">
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
@if (in_array('number', $visibleColumns))
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>

View File

@@ -61,6 +61,8 @@
{{-- KPI tiles --}}
@php($kpis = $this->kpis)
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Podsumowanie</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:12px">
<div class="stat-tile">
<div class="stat-tile-label">Łącznie zgłoszeń</div>
@@ -96,84 +98,200 @@
<div class="stat-tile-meta">{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
{{-- By status --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
@php($max = max($this->byStatus->max('count'), 1))
@forelse ($this->byStatus as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
{{-- Rozkład zgłoszeń --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Rozkład zgłoszeń</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
@php($max = max($this->byStatus->max('count'), 1))
@forelse ($this->byStatus as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
@php($max = max($this->byPriority->max('count'), 1))
@forelse ($this->byPriority as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
@php($cats = $this->byCategory)
@php($max = max($cats->max('count') ?? 0, 1))
@forelse ($cats as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg podkategorii</div>
@php($subs = $this->bySubcategory)
@php($max = max($subs->max('count') ?? 0, 1))
@forelse ($subs as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
{{-- Obciążenie --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Obciążenie</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
@php($teamRows = $this->byTeam)
@php($max = max($teamRows->max('count') ?? 0, 1))
@forelse ($teamRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
@php($opRows = $this->byAssignee)
@php($max = max($opRows->max('count') ?? 0, 1))
@forelse ($opRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
{{-- Klienci --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Klienci</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Najaktywniejsi klienci (Top 10)</div>
@php($customerRows = $this->byCustomer)
@php($max = max($customerRows->max('count') ?? 0, 1))
@forelse ($customerRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
{{-- By priority --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
@php($max = max($this->byPriority->max('count'), 1))
@forelse ($this->byPriority as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
<div class="card-title" style="margin-bottom:4px">Klienci wg podkategorii</div>
<div class="card-meta" style="margin-bottom:12px">Top 10 klientów × top 5 podkategorii wg wolumenu w wybranym okresie; reszta zbiorczo w kolumnie „Inne”.</div>
@php($matrix = $this->customerSubcategoryMatrix)
@if (count($matrix['rows']))
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Klient</th>
@foreach ($matrix['columns'] as $col)
<th>{{ $col }}</th>
@endforeach
@if ($matrix['hasOther'])
<th>Inne</th>
@endif
<th>Razem</th>
</tr>
</thead>
<tbody>
@foreach ($matrix['rows'] as $row)
<tr>
<td style="white-space:nowrap">{{ $row['label'] }}</td>
@foreach ($row['cells'] as $cell)
<td>{{ $cell ?: '—' }}</td>
@endforeach
@if ($matrix['hasOther'])
<td>{{ $row['other'] ?: '—' }}</td>
@endif
<td style="font-weight:600">{{ $row['total'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@empty
@else
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
@endif
</div>
</div>
{{-- By category --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
@php($cats = $this->byCategory)
@php($max = max($cats->max('count') ?? 0, 1))
@forelse ($cats as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
{{-- Ocena obsługi (CSAT) --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Ocena obsługi (CSAT)</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">CSAT wg zespołu</div>
@php($csatTeamRows = $this->csatByTeam)
@forelse ($csatTeamRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
</div>
@empty
<div class="card-meta">Brak ocen w wybranym okresie.</div>
@endforelse
</div>
{{-- By team --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
@php($teamRows = $this->byTeam)
@php($max = max($teamRows->max('count') ?? 0, 1))
@forelse ($teamRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
{{-- By assignee --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
@php($opRows = $this->byAssignee)
@php($max = max($opRows->max('count') ?? 0, 1))
@forelse ($opRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">CSAT wg operatora</div>
@php($csatOpRows = $this->csatByAssignee)
@forelse ($csatOpRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
</div>
@empty
<div class="card-meta">Brak ocen w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
@@ -181,6 +299,8 @@
@php($trend = $this->trend)
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1))
@php($labelStep = max((int) ceil(count($trend) / 12), 1))
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Trend w czasie</h3>
<div class="card" style="padding:16px">
<div class="card-title">Trend zgłoszeń</div>
<div class="card-meta" style="margin-bottom:12px">Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).</div>
@@ -223,5 +343,6 @@
</div>
@endif
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,23 @@
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">&larr; Wróć do listy</a>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
@@ -67,7 +83,7 @@
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Notatki wewnętrzne</div>
@forelse ($this->internalMessages as $m)
<div style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
<div wire:key="note-{{ $m->id }}" style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}</div>
@if ($m->role === 'operator')
@@ -130,7 +146,7 @@
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($threadMessages as $m)
@php $mine = $m->role === 'operator'; @endphp
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@@ -286,7 +302,9 @@
</div>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</div>
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">SLA</div>
@@ -435,4 +453,19 @@
</div>
</div>
@endif
@script
<script>
// Re-runs on every mount of this component, including after a
// wire:navigate to a different ticket, so the socket subscription
// always matches whichever ticket is currently on screen.
if (window.subscribeToTicketChannel) {
window.subscribeToTicketChannel({{ $ticket->id }});
} else {
// echo.js (a deferred module) hasn't run yet — queue the id so it
// subscribes as soon as it does, instead of silently doing nothing.
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
}
</script>
@endscript
</div>