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:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\EmailTemplate;
|
||||
@@ -100,6 +101,15 @@ class Panel extends Component
|
||||
|
||||
public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => ''];
|
||||
|
||||
// ---- automation rules ----
|
||||
public bool $automationRuleFormOpen = false;
|
||||
|
||||
public array $automationRuleForm = [
|
||||
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
|
||||
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
|
||||
'action_type' => 'change_priority', 'action_value' => '',
|
||||
];
|
||||
|
||||
// ---- templates ----
|
||||
public ?int $editingTemplateId = null;
|
||||
|
||||
@@ -854,6 +864,96 @@ class Panel extends Component
|
||||
$this->requestDelete('response-template', $id, 'Szablon odpowiedzi zostanie usunięty z listy dostępnej operatorom.');
|
||||
}
|
||||
|
||||
// ===================== AUTOMATION RULES =====================
|
||||
|
||||
#[Computed]
|
||||
public function automationRules()
|
||||
{
|
||||
return AutomationRule::query()->orderBy('id')->get();
|
||||
}
|
||||
|
||||
public function openAutomationRuleForm(): void
|
||||
{
|
||||
$this->automationRuleForm = [
|
||||
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
|
||||
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
|
||||
'action_type' => 'change_priority', 'action_value' => '',
|
||||
];
|
||||
$this->automationRuleFormOpen = true;
|
||||
}
|
||||
|
||||
public function editAutomationRule(int $id): void
|
||||
{
|
||||
$rule = AutomationRule::query()->findOrFail($id);
|
||||
|
||||
$this->automationRuleForm = [
|
||||
'id' => $rule->id,
|
||||
'label' => $rule->label,
|
||||
'enabled' => $rule->enabled,
|
||||
'condition_minutes' => $rule->condition_minutes,
|
||||
'scope_priority_key' => $rule->scope_priority_key ?? '',
|
||||
'scope_subcategory_id' => $rule->scope_subcategory_id ?? '',
|
||||
'scope_team_id' => $rule->scope_team_id ?? '',
|
||||
'action_type' => $rule->action_type,
|
||||
'action_value' => $rule->action_value,
|
||||
];
|
||||
$this->automationRuleFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeAutomationRuleForm(): void
|
||||
{
|
||||
$this->automationRuleFormOpen = false;
|
||||
}
|
||||
|
||||
// Switching action_type invalidates whichever action_value was picked for
|
||||
// the previous type (e.g. a priority key isn't a valid team id).
|
||||
public function updatedAutomationRuleFormActionType(): void
|
||||
{
|
||||
$this->automationRuleForm['action_value'] = '';
|
||||
}
|
||||
|
||||
public function submitAutomationRule(): void
|
||||
{
|
||||
$this->validate([
|
||||
'automationRuleForm.label' => 'required|string|max:255',
|
||||
'automationRuleForm.condition_minutes' => 'required|integer|min:1',
|
||||
'automationRuleForm.action_type' => 'required|in:'.implode(',', AutomationRule::ACTION_TYPES),
|
||||
'automationRuleForm.action_value' => 'required|string',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'label' => $this->automationRuleForm['label'],
|
||||
'enabled' => (bool) $this->automationRuleForm['enabled'],
|
||||
'condition_minutes' => (int) $this->automationRuleForm['condition_minutes'],
|
||||
'scope_priority_key' => $this->automationRuleForm['scope_priority_key'] ?: null,
|
||||
'scope_subcategory_id' => $this->automationRuleForm['scope_subcategory_id'] ?: null,
|
||||
'scope_team_id' => $this->automationRuleForm['scope_team_id'] ?: null,
|
||||
'action_type' => $this->automationRuleForm['action_type'],
|
||||
'action_value' => $this->automationRuleForm['action_value'],
|
||||
];
|
||||
|
||||
if ($this->automationRuleForm['id']) {
|
||||
AutomationRule::query()->find($this->automationRuleForm['id'])?->update($data);
|
||||
} else {
|
||||
AutomationRule::query()->create($data);
|
||||
}
|
||||
|
||||
$this->automationRuleFormOpen = false;
|
||||
unset($this->automationRules);
|
||||
}
|
||||
|
||||
public function toggleAutomationRuleEnabled(int $id): void
|
||||
{
|
||||
$rule = AutomationRule::query()->find($id);
|
||||
$rule?->update(['enabled' => ! $rule->enabled]);
|
||||
unset($this->automationRules);
|
||||
}
|
||||
|
||||
public function removeAutomationRule(int $id): void
|
||||
{
|
||||
$this->requestDelete('automation-rule', $id, 'Reguła automatyzacji zostanie usunięta.');
|
||||
}
|
||||
|
||||
// ===================== STATUSES / PRIORITIES =====================
|
||||
|
||||
#[Computed]
|
||||
@@ -1491,10 +1591,11 @@ class Panel extends Component
|
||||
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
|
||||
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
|
||||
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
|
||||
'automation-rule' => AutomationRule::query()->find($this->pendingDeleteId)?->delete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings);
|
||||
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings, $this->automationRules);
|
||||
|
||||
$this->cancelPendingDelete();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ class NewTicket extends Component
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
@@ -67,6 +77,10 @@ class NewTicket extends Component
|
||||
#[Computed]
|
||||
public function suggestedArticles(): array
|
||||
{
|
||||
if (! $this->suggestedArticlesLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
|
||||
@@ -4,10 +4,12 @@ namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
@@ -31,6 +33,16 @@ class TicketShow extends Component
|
||||
|
||||
public string $csatComment = '';
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the ticket page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->customer_id === Auth::id(), 403);
|
||||
@@ -44,12 +56,76 @@ class TicketShow extends Component
|
||||
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketMessagePosted broadcast on this ticket's own
|
||||
* channel (see resources/js/echo.js) — an operator's reply appears
|
||||
* without the client refreshing, the "live chat" effect. Ignores events
|
||||
* for any other ticket id since Livewire dispatches are page-wide.
|
||||
*/
|
||||
#[On('ticket-message-posted')]
|
||||
public function onTicketMessagePosted(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->ticketMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketQueueChanged broadcast on this ticket's own
|
||||
* channel — lets the client see a status/priority/team/assignee change
|
||||
* (and the resulting history entry) made by an operator, or by an
|
||||
* automation rule firing in the background, without refreshing.
|
||||
*/
|
||||
#[On('queue-changed')]
|
||||
public function onQueueChanged(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic fallback refresh (see the countdown badge in the blade view)
|
||||
* — broadcasting is best-effort, a dropped websocket connection
|
||||
* shouldn't mean the thread/ticket silently stops updating.
|
||||
*/
|
||||
public function refreshTicketData(): void
|
||||
{
|
||||
unset($this->ticketMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function otherTickets()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Same allow-listed shelves (CONTEXT_CREATION) and category/subcategory
|
||||
* query the ticket-creation wizard used, so the client sees the same
|
||||
* suggestions here as they did while writing the ticket.
|
||||
*
|
||||
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||
*/
|
||||
#[Computed]
|
||||
public function suggestedArticles(): array
|
||||
{
|
||||
if (! $this->suggestedArticlesLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subcategory = $this->ticket->subcategory;
|
||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
|
||||
@@ -36,6 +36,16 @@ class Landing extends Component
|
||||
|
||||
public ?int $submittedTicketId = null;
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
@@ -85,7 +95,7 @@ class Landing extends Component
|
||||
#[Computed]
|
||||
public function suggestedArticles(): array
|
||||
{
|
||||
if (! Settings::bool('bookstack_show_to_guests')) {
|
||||
if (! $this->suggestedArticlesLoaded || ! Settings::bool('bookstack_show_to_guests')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,16 @@ use Livewire\Component;
|
||||
|
||||
class NotificationBell extends Component
|
||||
{
|
||||
/**
|
||||
* Only unread — once a notification is read (clicked through, or via
|
||||
* "mark all as read"), it disappears from the bell rather than staying
|
||||
* listed dimmed. The full history still lives in the notifications
|
||||
* table for anyone querying it directly, just not surfaced here.
|
||||
*/
|
||||
#[Computed]
|
||||
public function notifications()
|
||||
{
|
||||
return Auth::user()->notifications()->latest()->limit(20)->get();
|
||||
return Auth::user()->unreadNotifications()->latest()->limit(20)->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
|
||||
@@ -33,6 +33,16 @@ class NewTicket extends Component
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function clients()
|
||||
{
|
||||
@@ -76,6 +86,10 @@ class NewTicket extends Component
|
||||
#[Computed]
|
||||
public function suggestedArticles(): array
|
||||
{
|
||||
if (! $this->suggestedArticlesLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Events\TicketQueueChanged;
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
@@ -11,6 +12,7 @@ use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -65,6 +67,33 @@ class Queue extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketQueueChanged broadcast via resources/js/echo.js —
|
||||
* see that file for why this is a plain Livewire event rather than an
|
||||
* `#[On('echo-private:...')]` attribute. Re-queries through the same
|
||||
* visibleToOperator()-scoped computed property a manual refresh would
|
||||
* use, so a ticket that just became invisible to this operator (closed,
|
||||
* reassigned away, moved to another team) simply won't come back, and
|
||||
* its id is pruned from the current selection so a stale checkbox
|
||||
* doesn't linger for a row that's no longer on screen.
|
||||
*/
|
||||
#[On('queue-changed')]
|
||||
public function onQueueChanged(int $ticketId = 0): void
|
||||
{
|
||||
$this->refreshQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic fallback refresh (see the countdown badge next to "Kolumny"
|
||||
* in the blade view) — broadcasting is best-effort, a dropped websocket
|
||||
* connection shouldn't mean the queue silently stops updating.
|
||||
*/
|
||||
public function refreshQueue(): void
|
||||
{
|
||||
unset($this->filteredTickets);
|
||||
$this->selectedIds = array_values(array_intersect($this->selectedIds, $this->filteredTickets->pluck('id')->all()));
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function savedViews()
|
||||
{
|
||||
@@ -417,7 +446,13 @@ class Queue extends Component
|
||||
|
||||
public function confirmDeleteSelected(): void
|
||||
{
|
||||
Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete();
|
||||
$ids = Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id');
|
||||
Ticket::query()->whereIn('id', $ids)->delete();
|
||||
|
||||
foreach ($ids as $id) {
|
||||
TicketQueueChanged::dispatch($id, 'deleted', Auth::id());
|
||||
}
|
||||
|
||||
$this->selectedIds = [];
|
||||
$this->pendingDeleteSelected = false;
|
||||
}
|
||||
|
||||
@@ -312,6 +312,25 @@ class Stats extends Component
|
||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One level deeper than byCategory() — same shape, but grouped by the
|
||||
* actual subcategory, labeled "Category / Subcategory" to disambiguate
|
||||
* subcategories that share a name across different parent categories.
|
||||
*/
|
||||
#[Computed]
|
||||
public function bySubcategory()
|
||||
{
|
||||
return (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
|
||||
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byTeam()
|
||||
{
|
||||
@@ -351,6 +370,170 @@ class Stats extends Component
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike teams/operators (small, fixed sets), the customer list is
|
||||
* unbounded — capped to the top 10 by ticket volume in the current
|
||||
* filtered range rather than listing every client who ever wrote in.
|
||||
* Guest submissions (no account) are summed into one "Goście" bucket
|
||||
* rather than grouped by e-mail, since a guest has no stable identity
|
||||
* to rank against registered clients.
|
||||
*/
|
||||
#[Computed]
|
||||
public function byCustomer()
|
||||
{
|
||||
$rows = (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.customer_id')
|
||||
->join('users', 'users.id', '=', 'tickets.customer_id')
|
||||
->select('users.id', 'users.name as label', DB::raw('count(*) as count'))
|
||||
->groupBy('users.id', 'users.name')
|
||||
->orderByDesc('count')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||
|
||||
$guestCount = (clone $this->baseQuery)->whereNull('tickets.customer_id')->count();
|
||||
|
||||
if ($guestCount > 0) {
|
||||
$rows->push(['label' => 'Goście (bez konta)', 'count' => $guestCount]);
|
||||
}
|
||||
|
||||
return $rows->sortByDesc('count')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Client × subcategory cross-tab — which clients' tickets fall into
|
||||
* which kind of subcategory. Both dimensions are unbounded (unlike
|
||||
* teams/operators), so this caps to the top 10 clients by overall
|
||||
* volume (rows, mirroring byCustomer()) and the top 5 subcategories by
|
||||
* overall volume (columns, mirroring assigneeSubcategoryMatrix()'s
|
||||
* "Inne" folding) — otherwise the table could grow arbitrarily in both
|
||||
* directions. Guest tickets (no customer_id) are excluded entirely
|
||||
* rather than folded into one "guest" row, since mixing a real client's
|
||||
* per-subcategory pattern with an anonymous aggregate wouldn't mean
|
||||
* anything.
|
||||
*
|
||||
* @return array{columns: array<int, string>, hasOther: bool, rows: array<int, array{label: string, cells: array<int, int>, other: ?int, total: int}>}
|
||||
*/
|
||||
#[Computed]
|
||||
public function customerSubcategoryMatrix(): array
|
||||
{
|
||||
$raw = (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.customer_id')
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||
->join('users', 'users.id', '=', 'tickets.customer_id')
|
||||
->select(
|
||||
'tickets.customer_id',
|
||||
'users.name as customer_name',
|
||||
'subcategories.id as subcategory_id',
|
||||
'categories.name as category_name',
|
||||
'subcategories.name as subcategory_name',
|
||||
DB::raw('count(*) as total'),
|
||||
)
|
||||
->groupBy('tickets.customer_id', 'users.name', 'subcategories.id', 'categories.name', 'subcategories.name')
|
||||
->get();
|
||||
|
||||
if ($raw->isEmpty()) {
|
||||
return ['columns' => [], 'hasOther' => false, 'rows' => []];
|
||||
}
|
||||
|
||||
$subcategoryTotals = $raw->groupBy('subcategory_id')->map(fn ($g) => $g->sum('total'));
|
||||
$topSubcategoryIds = $subcategoryTotals->sortDesc()->keys()->take(5);
|
||||
|
||||
$subcategoryLabels = $raw->unique('subcategory_id')->keyBy('subcategory_id')
|
||||
->map(fn ($r) => $r->category_name.' / '.$r->subcategory_name);
|
||||
|
||||
$columns = $topSubcategoryIds->map(fn ($id) => $subcategoryLabels[$id])->values()->all();
|
||||
$hasOther = $subcategoryTotals->keys()->diff($topSubcategoryIds)->isNotEmpty();
|
||||
|
||||
$byCustomer = $raw->groupBy('customer_id');
|
||||
$customerNames = $raw->unique('customer_id')->keyBy('customer_id')->map(fn ($r) => $r->customer_name);
|
||||
$topCustomerIds = $byCustomer->map(fn ($g) => $g->sum('total'))->sortDesc()->keys()->take(10);
|
||||
|
||||
$rows = $topCustomerIds
|
||||
->map(function ($customerId) use ($byCustomer, $customerNames, $topSubcategoryIds, $hasOther) {
|
||||
$entries = $byCustomer->get($customerId, collect());
|
||||
$bySubcategory = $entries->keyBy('subcategory_id');
|
||||
|
||||
return [
|
||||
'label' => $customerNames[$customerId],
|
||||
'cells' => $topSubcategoryIds->map(fn ($id) => (int) ($bySubcategory[$id]->total ?? 0))->values()->all(),
|
||||
'other' => $hasOther ? (int) $entries->whereNotIn('subcategory_id', $topSubcategoryIds->all())->sum('total') : null,
|
||||
'total' => (int) $entries->sum('total'),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return ['columns' => $columns, 'hasOther' => $hasOther, 'rows' => $rows];
|
||||
}
|
||||
|
||||
/**
|
||||
* Average CSAT rating per team, only among rated tickets in the current
|
||||
* filtered range — mirrors byTeam()'s "Bez zespołu" bucket handling, but
|
||||
* teams/buckets with zero ratings are dropped entirely (an average of
|
||||
* nothing isn't a meaningful bar to draw).
|
||||
*/
|
||||
#[Computed]
|
||||
public function csatByTeam()
|
||||
{
|
||||
$stats = (clone $this->baseQuery)
|
||||
->whereNotNull('csat_rating')
|
||||
->select('team_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
|
||||
->groupBy('team_id')
|
||||
->get();
|
||||
|
||||
$avgs = $stats->pluck('avg_rating', 'team_id');
|
||||
$counts = $stats->pluck('rated_count', 'team_id');
|
||||
|
||||
$rows = $this->teams
|
||||
->map(fn (Team $t) => [
|
||||
'label' => $t->name,
|
||||
'avg' => isset($avgs[$t->id]) ? round((float) $avgs[$t->id], 2) : null,
|
||||
'count' => (int) ($counts[$t->id] ?? 0),
|
||||
])
|
||||
->filter(fn ($row) => $row['count'] > 0)
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Bez zespołu', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows->sortByDesc('avg')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Average CSAT rating per assignee, same shape/semantics as csatByTeam().
|
||||
*/
|
||||
#[Computed]
|
||||
public function csatByAssignee()
|
||||
{
|
||||
$stats = (clone $this->baseQuery)
|
||||
->whereNotNull('csat_rating')
|
||||
->select('assignee_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
|
||||
->groupBy('assignee_id')
|
||||
->get();
|
||||
|
||||
$avgs = $stats->pluck('avg_rating', 'assignee_id');
|
||||
$counts = $stats->pluck('rated_count', 'assignee_id');
|
||||
|
||||
$rows = $this->operators
|
||||
->map(fn (User $u) => [
|
||||
'label' => $u->name,
|
||||
'avg' => isset($avgs[$u->id]) ? round((float) $avgs[$u->id], 2) : null,
|
||||
'count' => (int) ($counts[$u->id] ?? 0),
|
||||
])
|
||||
->filter(fn ($row) => $row['count'] > 0)
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Nieprzypisane', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows->sortByDesc('avg')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily created-vs-closed volume, capped at the most recent 60 days so a
|
||||
* wide range (or "Cały okres") never renders an unreadably thin column
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Events\TicketQueueChanged;
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\ReplyQuickAction;
|
||||
@@ -17,6 +18,7 @@ use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
@@ -66,6 +68,16 @@ class TicketShow extends Component
|
||||
|
||||
public string $editTimerSeconds = '0';
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the ticket page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
||||
@@ -168,6 +180,50 @@ class TicketShow extends Component
|
||||
return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketMessagePosted broadcast on this ticket's own
|
||||
* channel (see resources/js/echo.js) — a new reply/note from the other
|
||||
* party appears without the viewer refreshing, the "live chat" effect.
|
||||
* Ignores events for any other ticket id, since Livewire dispatches are
|
||||
* page-wide and this component only cares about its own ticket.
|
||||
*/
|
||||
#[On('ticket-message-posted')]
|
||||
public function onTicketMessagePosted(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
|
||||
* and Queue::onQueueChanged()) — lets a status/priority/team/assignee
|
||||
* change made by another operator, or by an automation rule firing in
|
||||
* the background, show up live on a ticket someone currently has open.
|
||||
*/
|
||||
#[On('queue-changed')]
|
||||
public function onQueueChanged(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic fallback refresh (see the countdown badge in the blade view)
|
||||
* — broadcasting is best-effort, a dropped websocket connection
|
||||
* shouldn't mean the thread/ticket silently stops updating.
|
||||
*/
|
||||
public function refreshTicketData(): void
|
||||
{
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function statuses()
|
||||
{
|
||||
@@ -192,6 +248,10 @@ class TicketShow extends Component
|
||||
#[Computed]
|
||||
public function suggestedArticles(): array
|
||||
{
|
||||
if (! $this->suggestedArticlesLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subcategory = $this->ticket->subcategory;
|
||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||
|
||||
@@ -500,7 +560,9 @@ class TicketShow extends Component
|
||||
|
||||
public function confirmDeleteTicket(): void
|
||||
{
|
||||
$ticketId = $this->ticket->id;
|
||||
$this->ticket->delete();
|
||||
TicketQueueChanged::dispatch($ticketId, 'deleted', Auth::id());
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user