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, hasOther: bool, rows: array, 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'); } }