v1.1.2
- 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:
@@ -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)
|
||||
|
||||
@@ -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')">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ≥ {{ $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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← 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">← 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 }} · {{ \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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← 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">← 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 }} · {{ \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 }} · {{ \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>
|
||||
|
||||
Reference in New Issue
Block a user