- 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>
630 lines
23 KiB
PHP
630 lines
23 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;
|
||
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');
|
||
}
|
||
}
|