Files
servicedesk/src/app/Livewire/Operator/Stats.php
Kacper 0b06687ea1 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>
2026-07-22 20:43:05 +02:00

630 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Livewire\Operator;
use App\Models\Category;
use App\Models\Priority;
use App\Models\SlaRule;
use App\Models\Status;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
class Stats extends Component
{
#[Url]
public string $range = '30d';
#[Url]
public ?string $from = null;
#[Url]
public ?string $to = null;
#[Url]
public string $filterTeam = 'all';
#[Url]
public string $filterPriority = 'all';
#[Url]
public string $filterCategory = 'all';
#[Url]
public string $filterAssignee = 'all';
/**
* Non-admin operators only ever see their own teams — same scoping as
* Queue's team tabs, so the filter options never expose data the
* visibleToOperator() query would filter back out anyway.
*/
#[Computed]
public function teams()
{
$query = Team::query();
if (! Auth::user()->isAdmin()) {
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
}
return $query->orderBy('name')->get();
}
#[Computed]
public function priorities()
{
return Priority::query()->orderBy('sort_order')->get();
}
#[Computed]
public function categories()
{
return Category::query()->orderBy('name')->get();
}
#[Computed]
public function operators()
{
$query = User::query()->withRole('operator');
if (! Auth::user()->isAdmin()) {
$teamIds = $this->teams->pluck('id');
$query->whereHas('teams', fn ($q) => $q->whereIn('teams.id', $teamIds));
}
return $query->orderBy('name')->get();
}
/**
* @return array{from: ?Carbon, to: ?Carbon}
*/
protected function bounds(): array
{
$now = now();
return match ($this->range) {
'today' => ['from' => $now->clone()->startOfDay(), 'to' => $now],
'7d' => ['from' => $now->clone()->subDays(6)->startOfDay(), 'to' => $now],
'30d' => ['from' => $now->clone()->subDays(29)->startOfDay(), 'to' => $now],
'90d' => ['from' => $now->clone()->subDays(89)->startOfDay(), 'to' => $now],
'custom' => [
'from' => $this->from ? Carbon::parse($this->from)->startOfDay() : $now->clone()->subDays(29)->startOfDay(),
'to' => $this->to ? Carbon::parse($this->to)->endOfDay() : $now,
],
default => ['from' => null, 'to' => null],
};
}
/**
* The shared, fully-filtered ticket scope every stat below reads from —
* cloned per terminal call (count/get/pluck) since none of those mutate
* the underlying where clauses, only cloning avoids one query's
* ->select()/->groupBy() leaking into the next.
*/
#[Computed]
public function baseQuery()
{
$query = Ticket::query()->visibleToOperator(Auth::user());
$bounds = $this->bounds();
if ($bounds['from']) {
$query->where('tickets.created_at', '>=', $bounds['from']);
}
if ($bounds['to']) {
$query->where('tickets.created_at', '<=', $bounds['to']);
}
if ($this->filterTeam !== 'all') {
$query->where('tickets.team_id', $this->filterTeam);
}
if ($this->filterPriority !== 'all') {
$query->where('tickets.priority_key', $this->filterPriority);
}
if ($this->filterCategory !== 'all') {
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
}
if ($this->filterAssignee === 'unassigned') {
$query->whereNull('tickets.assignee_id');
} elseif ($this->filterAssignee !== 'all') {
$query->where('tickets.assignee_id', $this->filterAssignee);
}
return $query;
}
#[Computed]
public function kpis(): array
{
$total = (clone $this->baseQuery)->count();
$closedKeys = Status::closedKeys();
$closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count();
return [
'total' => $total,
'open' => $total - $closed,
'closed' => $closed,
'closedPct' => $total > 0 ? round($closed / $total * 100, 1) : null,
'avgFirstResponseHours' => $this->avgFirstResponseHours(),
'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
'sla' => $this->slaBreachStats($closedKeys),
'csat' => $this->csatStats($closedKeys),
];
}
/**
* Response rate is against closed tickets (the only ones that can ever
* be rated — see Ticket::csatSubmittable()), not the whole filtered set.
*/
protected function csatStats(array $closedKeys): array
{
$closedTotal = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count();
$rated = (clone $this->baseQuery)->whereNotNull('csat_rating')->get(['csat_rating']);
return [
'avg' => $rated->isEmpty() ? null : round($rated->avg('csat_rating'), 2),
'count' => $rated->count(),
'responseRate' => $closedTotal > 0 ? round($rated->count() / $closedTotal * 100, 1) : null,
];
}
protected function avgFirstResponseHours(): ?float
{
$ids = (clone $this->baseQuery)->pluck('tickets.id');
if ($ids->isEmpty()) {
return null;
}
$firstReplies = DB::table('ticket_messages as tm')
->join('ticket_message_authors as tma', 'tma.ticket_message_id', '=', 'tm.id')
->join('roles as r', 'r.id', '=', 'tma.role_id')
->where('r.key', 'operator')
->whereIn('tm.ticket_id', $ids)
->select('tm.ticket_id', DB::raw('MIN(tm.created_at) as first_reply'))
->groupBy('tm.ticket_id')
->pluck('first_reply', 'ticket_id');
if ($firstReplies->isEmpty()) {
return null;
}
$createdAts = Ticket::query()->whereIn('id', $firstReplies->keys())->pluck('created_at', 'id');
$minutes = $firstReplies->map(fn ($firstReply, $ticketId) => $createdAts[$ticketId]?->diffInMinutes(Carbon::parse($firstReply)))
->filter(fn ($v) => $v !== null);
return $minutes->isEmpty() ? null : round($minutes->avg() / 60, 1);
}
/**
* Approximate: no dedicated "resolved_at" column exists, so a closed
* ticket's updated_at (touched whenever its status changes — see
* TicketService::setStatus()) stands in for when it was closed.
*/
protected function avgResolutionHours(array $closedKeys): ?float
{
$closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->get(['created_at', 'updated_at']);
if ($closed->isEmpty()) {
return null;
}
return round($closed->avg(fn (Ticket $t) => $t->created_at->diffInMinutes($t->updated_at)) / 60, 1);
}
/**
* A ticket "breaches SLA" if its resolution deadline (priority's
* sla_rules.resolution_mins, from creation) has passed — either already,
* for a still-open ticket, or before it was closed (updated_at stands in
* for the close time, same approximation as avgResolutionHours()).
* Priorities with resolution_mins = 0 (e.g. "Brak") never breach.
*/
protected function slaBreachStats(array $closedKeys): array
{
$tickets = (clone $this->baseQuery)->get(['status_key', 'priority_key', 'created_at', 'updated_at']);
if ($tickets->isEmpty()) {
return ['rate' => null, 'breached' => 0, 'total' => 0];
}
$rules = SlaRule::query()->pluck('resolution_mins', 'priority_key');
$now = now();
$breached = $tickets->filter(function (Ticket $t) use ($rules, $closedKeys, $now) {
$mins = $rules[$t->priority_key] ?? 0;
if ($mins <= 0) {
return false;
}
$deadline = $t->created_at->clone()->addMinutes($mins);
return in_array($t->status_key, $closedKeys, true)
? $t->updated_at->isAfter($deadline)
: $now->isAfter($deadline);
})->count();
return [
'rate' => round($breached / $tickets->count() * 100, 1),
'breached' => $breached,
'total' => $tickets->count(),
];
}
/**
* The "closed" stage's color, reused for the trend chart's closed-tickets
* strip so it visually matches the same status everywhere else in the
* app — falls back to the secondary brand hue if no status is closed.
*/
#[Computed]
public function closedColor(): string
{
return Status::query()->where('stage', 'closed')->value('color') ?? 'var(--color-accent-2)';
}
#[Computed]
public function byStatus()
{
$counts = (clone $this->baseQuery)
->select('status_key', DB::raw('count(*) as total'))
->groupBy('status_key')
->pluck('total', 'status_key');
return Status::query()->orderBy('sort_order')->get()->map(fn (Status $s) => [
'label' => $s->label,
'color' => $s->color,
'count' => $counts[$s->key] ?? 0,
])->values();
}
#[Computed]
public function byPriority()
{
$counts = (clone $this->baseQuery)
->select('priority_key', DB::raw('count(*) as total'))
->groupBy('priority_key')
->pluck('total', 'priority_key');
return $this->priorities->map(fn (Priority $p) => [
'label' => $p->label,
'color' => $p->color,
'count' => $counts[$p->key] ?? 0,
])->values();
}
#[Computed]
public function byCategory()
{
return (clone $this->baseQuery)
->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id')
->select('categories.name as label', DB::raw('count(*) as count'))
->groupBy('categories.id', 'categories.name')
->orderByDesc('count')
->get()
->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()
{
$counts = (clone $this->baseQuery)
->select('team_id', DB::raw('count(*) as total'))
->groupBy('team_id')
->pluck('total', 'team_id');
$rows = $this->teams->map(fn (Team $t) => ['label' => $t->name, 'count' => $counts[$t->id] ?? 0])
->sortByDesc('count')
->values();
if ($counts->get(null, 0)) {
$rows->push(['label' => 'Bez zespołu', 'count' => $counts->get(null)]);
}
return $rows;
}
#[Computed]
public function byAssignee()
{
$counts = (clone $this->baseQuery)
->select('assignee_id', DB::raw('count(*) as total'))
->groupBy('assignee_id')
->pluck('total', 'assignee_id');
$rows = $this->operators->map(fn (User $u) => ['label' => $u->name, 'count' => $counts[$u->id] ?? 0])
->filter(fn ($row) => $row['count'] > 0)
->sortByDesc('count')
->values();
if ($counts->get(null, 0)) {
$rows->push(['label' => 'Nieprzypisane', 'count' => $counts->get(null)]);
}
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
* per day — the KPI tiles/breakdowns above still reflect the full range.
*/
#[Computed]
public function trend(): array
{
$bounds = $this->bounds();
$to = ($bounds['to'] ?? now())->clone()->startOfDay();
$from = $bounds['from']?->clone()->startOfDay();
if (! $from) {
$earliest = (clone $this->baseQuery)->min('tickets.created_at');
$from = $earliest ? Carbon::parse($earliest)->startOfDay() : $to->clone()->subDays(29);
}
$maxDays = 60;
if ($from->diffInDays($to) + 1 > $maxDays) {
$from = $to->clone()->subDays($maxDays - 1);
}
$created = (clone $this->baseQuery)
->selectRaw('DATE(tickets.created_at) as d, count(*) as total')
->groupBy('d')->pluck('total', 'd');
$closedKeys = Status::closedKeys();
$closed = (clone $this->baseQuery)
->whereIn('tickets.status_key', $closedKeys)
->selectRaw('DATE(tickets.updated_at) as d, count(*) as total')
->groupBy('d')->pluck('total', 'd');
$days = [];
$cursor = $from->clone();
while ($cursor->lte($to)) {
$key = $cursor->format('Y-m-d');
$days[] = [
'date' => $key,
'label' => $cursor->translatedFormat('d.m'),
'created' => (int) ($created[$key] ?? 0),
'closed' => (int) ($closed[$key] ?? 0),
];
$cursor->addDay();
}
return $days;
}
public function setRange(string $range): void
{
$this->range = $range;
}
/**
* Row-per-ticket CSV of everything the active filters/date range
* currently show — streamed directly, no temp file, no new dependency.
*/
public function export(): StreamedResponse
{
$tickets = (clone $this->baseQuery)
->with(['subcategory.category', 'assignee', 'team', 'status', 'priority'])
->orderBy('tickets.created_at')
->get();
return response()->streamDownload(function () use ($tickets) {
$out = fopen('php://output', 'w');
fputcsv($out, ['Numer', 'Temat', 'Status', 'Priorytet', 'Kategoria', 'Zespół', 'Operator', 'Utworzono', 'Zaktualizowano', 'Ocena CSAT'], escape: '\\');
foreach ($tickets as $ticket) {
fputcsv($out, [
$ticket->number,
$ticket->subject,
$ticket->statusLabel(),
$ticket->priorityLabel(),
$ticket->categoryLabel(),
$ticket->team?->name,
$ticket->assignee?->name,
$ticket->created_at,
$ticket->updated_at,
$ticket->csat_rating,
], escape: '\\');
}
fclose($out);
}, 'statystyki-'.now()->format('Y-m-d').'.csv');
}
public function render()
{
return view('livewire.operator.stats');
}
}