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>
This commit is contained in:
@@ -312,6 +312,25 @@ class Stats extends Component
|
||||
->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()
|
||||
{
|
||||
@@ -351,6 +370,170 @@ class Stats extends Component
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user