Files
servicedesk/src/app/Livewire/Operator/Stats.php
2026-07-21 23:39:19 +02:00

395 lines
13 KiB
PHP

<?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;
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),
];
}
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]);
}
#[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;
}
/**
* 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;
}
public function render()
{
return view('livewire.operator.stats');
}
}