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'); } }