@if ($m->role === 'operator')
@@ -130,7 +146,7 @@
@foreach ($threadMessages as $m)
@php $mine = $m->role === 'operator'; @endphp
-
+
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
@@ -286,7 +302,9 @@
-
+
+
+
@endif
+
+ @script
+
+ @endscript
diff --git a/src/routes/channels.php b/src/routes/channels.php
new file mode 100644
index 0000000..80837aa
--- /dev/null
+++ b/src/routes/channels.php
@@ -0,0 +1,36 @@
+roles ?? []) || $user->isAdmin();
+});
+
+/**
+ * Per-ticket channel for message/detail changes (drives both the live
+ * message thread and live ticket-header updates). OR, not else-if — the
+ * owner's account holds both client and operator roles at once, so both
+ * branches must be checked rather than picking one based on role alone.
+ * Payloads here also stay minimal (never the message body itself), so an
+ * internal note can safely broadcast on the same channel a client is
+ * subscribed to — the client's own computed properties never touch
+ * internal messages regardless of which event arrived.
+ */
+Broadcast::channel('ticket.{ticketId}', function ($user, int $ticketId) {
+ $ticket = Ticket::query()->find($ticketId);
+
+ if (! $ticket) {
+ return false;
+ }
+
+ return (in_array('operator', $user->roles ?? []) && $ticket->isVisibleToOperator($user))
+ || $ticket->customer_id === $user->id;
+});
diff --git a/src/routes/console.php b/src/routes/console.php
index c187274..0ccaae6 100644
--- a/src/routes/console.php
+++ b/src/routes/console.php
@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
})->purpose('Display an inspiring quote');
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
+Schedule::command('automation:run-rules')->everyFifteenMinutes();
diff --git a/src/tests/Feature/AutomationRulesTest.php b/src/tests/Feature/AutomationRulesTest.php
new file mode 100644
index 0000000..5ae6d2c
--- /dev/null
+++ b/src/tests/Feature/AutomationRulesTest.php
@@ -0,0 +1,172 @@
+create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ $rule = AutomationRule::query()->create([
+ 'label' => 'Podnieś priorytet po 60 min',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+
+ Artisan::call('automation:run-rules');
+
+ expect($ticket->fresh()->priority_key)->toBe('low');
+ expect($rule->fresh()->hasFiredFor($ticket->fresh()))->toBeTrue();
+ // TicketService::setPriority() writes its own "Priorytet zmieniony na: ..."
+ // history line; RunAutomationRules adds a second, clearly-attributed one.
+ expect($ticket->fresh()->histories()->count())->toBe(2);
+});
+
+test('a rule does not fire again on the same ticket once already latched', function () {
+ seedStatusesAndPriorities();
+ Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ AutomationRule::query()->create([
+ 'label' => 'Podnieś priorytet po 60 min',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+
+ Artisan::call('automation:run-rules');
+ $ticket->refresh()->update(['priority_key' => 'high']); // simulate an operator manually reverting it
+ Artisan::call('automation:run-rules');
+
+ expect($ticket->fresh()->priority_key)->toBe('high');
+});
+
+test('a fresh client reply resets the latch so the rule can fire again after new silence', function () {
+ seedStatusesAndPriorities();
+ Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ $client = User::query()->create(['name' => 'Klient', 'email' => 'client-auto@example.com', 'roles' => ['client']]);
+ AutomationRule::query()->create([
+ 'label' => 'Podnieś priorytet po 60 min',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+
+ Artisan::call('automation:run-rules');
+ expect($ticket->fresh()->priority_key)->toBe('low');
+
+ app(TicketService::class)->clientReply($ticket->fresh(), $client, 'Nadal potrzebuję pomocy');
+ $ticket->refresh()->update(['last_customer_activity_at' => now()->subMinutes(90), 'priority_key' => 'high']);
+
+ Artisan::call('automation:run-rules');
+
+ expect($ticket->fresh()->priority_key)->toBe('low');
+});
+
+test('a rule scoped to a priority ignores tickets with a different priority', function () {
+ seedStatusesAndPriorities();
+ Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $ticket = makeTicket(['priority_key' => 'low', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ AutomationRule::query()->create([
+ 'label' => 'Tylko dla wysokiego priorytetu',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'scope_priority_key' => 'high',
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+
+ Artisan::call('automation:run-rules');
+
+ expect($ticket->fresh()->priority_key)->toBe('low');
+ expect($ticket->fresh()->histories()->count())->toBe(0);
+});
+
+test('a disabled rule never fires', function () {
+ seedStatusesAndPriorities();
+ Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ AutomationRule::query()->create([
+ 'label' => 'Wyłączona reguła',
+ 'enabled' => false,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+
+ Artisan::call('automation:run-rules');
+
+ expect($ticket->fresh()->priority_key)->toBe('high');
+});
+
+test('a closed ticket is never matched even if it has been silent long enough', function () {
+ seedStatusesAndPriorities();
+ Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ AutomationRule::query()->create([
+ 'label' => 'Podnieś priorytet po 60 min',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+
+ Artisan::call('automation:run-rules');
+
+ expect($ticket->fresh()->priority_key)->toBe('high');
+});
+
+test('two independent rules can both fire on the same ticket in one run', function () {
+ seedStatusesAndPriorities();
+ Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $team = Team::query()->create(['name' => 'Wsparcie L2']);
+ $ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ AutomationRule::query()->create([
+ 'label' => 'Podnieś priorytet',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+ AutomationRule::query()->create([
+ 'label' => 'Przekaż do L2',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_team',
+ 'action_value' => (string) $team->id,
+ ]);
+
+ Artisan::call('automation:run-rules');
+
+ $ticket->refresh();
+ expect($ticket->priority_key)->toBe('low');
+ expect($ticket->team_id)->toBe($team->id);
+ expect($ticket->histories()->count())->toBe(4);
+});
+
+test('reopening a closed ticket clears its automation logs so rules can fire again', function () {
+ seedStatusesAndPriorities();
+ Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
+ $ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
+ $rule = AutomationRule::query()->create([
+ 'label' => 'Podnieś priorytet',
+ 'enabled' => true,
+ 'condition_minutes' => 60,
+ 'action_type' => 'change_priority',
+ 'action_value' => 'low',
+ ]);
+
+ Artisan::call('automation:run-rules');
+ expect($rule->logs()->count())->toBe(1);
+
+ app(TicketService::class)->setStatus($ticket->fresh(), 'closed');
+ expect($rule->logs()->count())->toBe(0);
+});
diff --git a/src/tests/Feature/EmailNotificationsAdminTest.php b/src/tests/Feature/EmailNotificationsAdminTest.php
index 3777c7e..cc9c542 100644
--- a/src/tests/Feature/EmailNotificationsAdminTest.php
+++ b/src/tests/Feature/EmailNotificationsAdminTest.php
@@ -3,13 +3,15 @@
use App\Livewire\Admin\Panel;
use App\Models\EmailTemplate;
use App\Models\NotificationSetting;
+use App\Models\User;
use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
- $admin = adminUser();
+ $this->seed();
+ $admin = User::query()->withRole('admin')->firstOrFail();
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
Livewire::actingAs($admin)->test(Panel::class)
@@ -26,10 +28,11 @@ test('admin can toggle a notification on/off but cannot add, delete, or reassign
});
test('admin can edit the subject and body of a trigger\'s fixed template', function () {
- $admin = adminUser();
+ $this->seed();
+ $admin = User::query()->withRole('admin')->firstOrFail();
- // A bare migrated (unseeded) database has no email_templates rows yet, so
- // ticket_created's binding is still null at this point — give it one.
+ // Repoint ticket_created's NotificationSetting at a template we control,
+ // instead of the real seeded one, so this test's assertions are its own.
$template = EmailTemplate::query()->create(['key' => 'tpl-created-test', 'name' => 'Nowe', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B']);
$ticketCreated = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail();
$ticketCreated->update(['email_template_id' => $template->id]);
@@ -50,7 +53,7 @@ test('admin can edit the subject and body of a trigger\'s fixed template', funct
test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
$template = EmailTemplate::query()->create([
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',
diff --git a/src/tests/Feature/ExtendedNotificationTriggersTest.php b/src/tests/Feature/ExtendedNotificationTriggersTest.php
index c71eb61..7538574 100644
--- a/src/tests/Feature/ExtendedNotificationTriggersTest.php
+++ b/src/tests/Feature/ExtendedNotificationTriggersTest.php
@@ -9,19 +9,21 @@ use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
-test('the 6 extended triggers exist and are disabled by default, alongside the 2 enabled originals', function () {
+test('the 10 notification triggers exist, with the customer-facing lifecycle ones (and the new-ticket-for-team one) enabled by default and the rest opt-in', function () {
+ $this->seed();
$settings = NotificationSetting::query()->get()->keyBy('trigger_key');
expect($settings->keys()->sort()->values()->all())->toBe([
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created',
+ 'ticket_created_team',
]);
- foreach (['ticket_created', 'status_changed'] as $key) {
+ foreach (['ticket_created', 'status_changed', 'ticket_closed', 'operator_replied', 'ticket_created_team'] as $key) {
expect($settings[$key]->enabled)->toBeTrue();
}
- foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'ticket_closed', 'operator_replied', 'sla_breached'] as $key) {
+ foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'sla_breached'] as $key) {
expect($settings[$key]->enabled)->toBeFalse()
->and($settings[$key]->email_template_id)->not->toBeNull();
}
@@ -29,7 +31,7 @@ test('the 6 extended triggers exist and are disabled by default, alongside the 2
test('a disabled-by-default trigger sends nothing until an admin turns it on', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
@@ -42,7 +44,7 @@ test('a disabled-by-default trigger sends nothing until an admin turns it on', f
test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -57,7 +59,7 @@ test('changing the assignee fires assignee_changed with the {operator} placehold
test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -72,7 +74,7 @@ test('changing the team fires team_changed with the {zespol} placeholder once en
test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
$category = Category::query()->create(['name' => 'IT-Pomoc']);
@@ -92,12 +94,12 @@ test('changing the subcategory fires category_changed, but re-saving details wit
test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
- // status_changed ships enabled by default, but in a bare migrated (unseeded)
- // database it has no template assigned yet — give it one so it would have
- // something to send if it (wrongly) fired, isolating this test from seeding order.
+ // status_changed ships enabled by default — repoint it at a template we
+ // control so this test's "did it wrongly fire" assertion isn't tied to
+ // whatever content the real seeded template happens to have.
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
@@ -112,7 +114,7 @@ test('closing a ticket fires only ticket_closed, not status_changed, so it does
test('a non-closing status change still fires status_changed as usual', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
@@ -128,7 +130,7 @@ test('a non-closing status change still fires status_changed as usual', function
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
$ticket = makeTicket();
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]);
@@ -137,3 +139,48 @@ test('an operator reply fires operator_replied once enabled, independent of any
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
+
+test('every member of a team whose subcategory matches a new ticket gets notified once, no duplicates', function () {
+ Notification::fake();
+ $this->seed();
+
+ $category = Category::query()->create(['name' => 'IT-Pomoc']);
+ $sub = $category->subcategories()->create(['name' => 'VPN']);
+ $team = Team::query()->create(['name' => 'Zespół VPN']);
+ $team->subcategories()->attach($sub->id);
+ $memberA = User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']]);
+ $memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
+ $team->members()->attach([$memberA->id, $memberB->id]);
+
+ app(TicketService::class)->create([
+ 'email' => 'client-team-notif@example.com',
+ 'subject' => 'Problem z VPN',
+ 'body' => 'Nie mogę się połączyć.',
+ 'subcategory_id' => $sub->id,
+ ], null);
+
+ Notification::assertSentTo($memberA, TicketNotification::class);
+ Notification::assertSentTo($memberB, TicketNotification::class);
+ // Plus one more: TicketService::create() also fires the pre-existing
+ // 'ticket_created' trigger, routed anonymously to the guest's e-mail
+ // since this ticket has no real customer account.
+ Notification::assertSentTimes(TicketNotification::class, 3);
+});
+
+test('a new ticket with no matching team notifies no operator', function () {
+ Notification::fake();
+ $this->seed();
+
+ $category = Category::query()->create(['name' => 'Bez zespołu']);
+ $sub = $category->subcategories()->create(['name' => 'Inne']);
+ $operator = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
+
+ app(TicketService::class)->create([
+ 'email' => 'client-no-team@example.com',
+ 'subject' => 'Coś innego',
+ 'body' => 'Treść.',
+ 'subcategory_id' => $sub->id,
+ ], null);
+
+ Notification::assertNotSentTo($operator, TicketNotification::class);
+});
diff --git a/src/tests/Feature/ReplyQuickActionsTest.php b/src/tests/Feature/ReplyQuickActionsTest.php
index 329338c..cd0b7f4 100644
--- a/src/tests/Feature/ReplyQuickActionsTest.php
+++ b/src/tests/Feature/ReplyQuickActionsTest.php
@@ -6,6 +6,8 @@ use App\Models\ReplyQuickAction;
use Livewire\Livewire;
test('the 3 default reply quick actions exist out of the box, matching the old hardcoded menu', function () {
+ $this->seed();
+
// "Wyślij i oznacz jako rozwiązane" used to point at the now-removed
// "resolved" status (folded into "closed" — see the status restructure
// migration), so it points at "closed" today, same as "Wyślij i zamknij".
@@ -72,7 +74,7 @@ test('admin can edit and delete a reply quick action', function () {
});
test('the operator ticket view lists the configured reply quick actions in the send menu', function () {
- seedStatusesAndPriorities();
+ $this->seed();
$operator = operatorUser('quickaction-view@example.com');
$ticket = makeTicket(['number' => '1001']);
@@ -83,7 +85,7 @@ test('the operator ticket view lists the configured reply quick actions in the s
});
test('sending via a status-changing quick action updates the ticket status', function () {
- seedStatusesAndPriorities();
+ $this->seed();
$operator = operatorUser('quickaction-send@example.com');
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
diff --git a/src/tests/Feature/SlaBreachNotificationTest.php b/src/tests/Feature/SlaBreachNotificationTest.php
index 70fd35a..1678cb8 100644
--- a/src/tests/Feature/SlaBreachNotificationTest.php
+++ b/src/tests/Feature/SlaBreachNotificationTest.php
@@ -21,7 +21,7 @@ test('the SLA-breach check does nothing while its trigger is disabled (the defau
test('an overdue ticket with an assigned operator gets notified once the trigger is enabled, and only once', function () {
Notification::fake();
- seedStatusesAndPriorities();
+ $this->seed();
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
$operator = User::query()->create(['name' => 'Ola Operator', 'email' => 'sla-op-2@example.com', 'roles' => ['operator']]);
@@ -29,15 +29,15 @@ test('an overdue ticket with an assigned operator gets notified once the trigger
Artisan::call('tickets:check-sla-breaches');
- Notification::assertSentOnDemand(
- TicketNotification::class,
- fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'sla-op-2@example.com'
- );
+ // $operator is a real persisted User (not a guest), so TicketService::notify()
+ // notifies it directly rather than routing anonymously — assertSentTo, not
+ // assertSentOnDemand (which only matches AnonymousNotifiable routing).
+ Notification::assertSentTo($operator, TicketNotification::class);
expect($ticket->fresh()->sla_notified_at)->not->toBeNull();
Artisan::call('tickets:check-sla-breaches');
- Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
+ Notification::assertSentTimes(TicketNotification::class, 1);
});
test('an overdue but unassigned ticket is never notified (nobody to send it to)', function () {
diff --git a/src/tests/Feature/SmokeTest.php b/src/tests/Feature/SmokeTest.php
index 2055dc6..89ff253 100644
--- a/src/tests/Feature/SmokeTest.php
+++ b/src/tests/Feature/SmokeTest.php
@@ -8,11 +8,25 @@ use Livewire\Livewire;
test('every page renders without error against fully seeded data', function () {
$this->seed();
- $client = User::query()->withRole('client')->firstOrFail();
- $operator = User::query()->withRole('operator')->firstOrFail();
+ // DatabaseSeeder deliberately only seeds one admin fallback account and
+ // no ticket data (see README) — this test needs a client and an operator
+ // plus a ticket to exercise every page, so it creates its own on top of
+ // the seeded reference data (categories/teams/statuses/priorities/etc).
+ $client = User::query()->create(['name' => 'Smoke Client', 'email' => 'smoke-client@example.com', 'roles' => ['client']]);
+ $operator = User::query()->create(['name' => 'Smoke Operator', 'email' => 'smoke-operator@example.com', 'roles' => ['operator']]);
$admin = User::query()->withRole('admin')->firstOrFail();
- $clientTicket = Ticket::query()->where('customer_id', $client->id)->firstOrFail();
- $anyTicket = Ticket::query()->firstOrFail();
+ $clientTicket = Ticket::query()->create([
+ 'number' => '9001',
+ 'customer_id' => $client->id,
+ 'email' => $client->email,
+ 'name' => $client->name,
+ 'subject' => 'Smoke test subject',
+ 'body' => 'Smoke test body',
+ 'status_key' => 'new',
+ 'priority_key' => 'high',
+ 'custom_fields' => [],
+ ]);
+ $anyTicket = $clientTicket;
$this->get('/')->assertOk()->assertSee('Jak możemy pomóc');
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');
diff --git a/src/tests/Feature/StatsCsatBreakdownTest.php b/src/tests/Feature/StatsCsatBreakdownTest.php
new file mode 100644
index 0000000..86f11ad
--- /dev/null
+++ b/src/tests/Feature/StatsCsatBreakdownTest.php
@@ -0,0 +1,48 @@
+create(['name' => 'Admin', 'email' => 'stats-admin@example.com', 'roles' => ['admin']]);
+
+ $teamA = Team::query()->create(['name' => 'Zespół A']);
+ $teamB = Team::query()->create(['name' => 'Zespół B']);
+ $teamC = Team::query()->create(['name' => 'Zespół C (bez ocen)']);
+
+ makeTicket(['number' => '2001', 'status_key' => 'closed', 'team_id' => $teamA->id, 'csat_rating' => 5]);
+ makeTicket(['number' => '2002', 'status_key' => 'closed', 'team_id' => $teamA->id, 'csat_rating' => 3]);
+ makeTicket(['number' => '2003', 'status_key' => 'closed', 'team_id' => $teamB->id, 'csat_rating' => 4]);
+ makeTicket(['number' => '2004', 'status_key' => 'closed', 'team_id' => $teamC->id, 'csat_rating' => null]);
+ makeTicket(['number' => '2005', 'status_key' => 'closed', 'team_id' => null, 'csat_rating' => 2]);
+
+ $rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->csatByTeam;
+
+ expect($rows->firstWhere('label', 'Zespół A'))->toBe(['label' => 'Zespół A', 'avg' => 4.0, 'count' => 2])
+ ->and($rows->firstWhere('label', 'Zespół B'))->toBe(['label' => 'Zespół B', 'avg' => 4.0, 'count' => 1])
+ ->and($rows->firstWhere('label', 'Bez zespołu'))->toBe(['label' => 'Bez zespołu', 'avg' => 2.0, 'count' => 1])
+ ->and($rows->firstWhere('label', 'Zespół C (bez ocen)'))->toBeNull();
+});
+
+test('csatByAssignee averages ratings per operator and drops operators with no ratings', function () {
+ seedStatusesAndPriorities();
+ $admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-2@example.com', 'roles' => ['admin']]);
+ $opA = User::query()->create(['name' => 'Ola', 'email' => 'stats-op-a@example.com', 'roles' => ['operator']]);
+ $opB = User::query()->create(['name' => 'Jan', 'email' => 'stats-op-b@example.com', 'roles' => ['operator']]);
+ $opUnrated = User::query()->create(['name' => 'Bez ocen', 'email' => 'stats-op-c@example.com', 'roles' => ['operator']]);
+
+ makeTicket(['number' => '3001', 'status_key' => 'closed', 'assignee_id' => $opA->id, 'csat_rating' => 5]);
+ makeTicket(['number' => '3002', 'status_key' => 'closed', 'assignee_id' => $opA->id, 'csat_rating' => 1]);
+ makeTicket(['number' => '3003', 'status_key' => 'closed', 'assignee_id' => $opB->id, 'csat_rating' => 3]);
+ makeTicket(['number' => '3004', 'status_key' => 'closed', 'assignee_id' => null, 'csat_rating' => 4]);
+
+ $rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->csatByAssignee;
+
+ expect($rows->firstWhere('label', 'Ola'))->toBe(['label' => 'Ola', 'avg' => 3.0, 'count' => 2])
+ ->and($rows->firstWhere('label', 'Jan'))->toBe(['label' => 'Jan', 'avg' => 3.0, 'count' => 1])
+ ->and($rows->firstWhere('label', 'Nieprzypisane'))->toBe(['label' => 'Nieprzypisane', 'avg' => 4.0, 'count' => 1])
+ ->and($rows->firstWhere('label', 'Bez ocen'))->toBeNull();
+});
diff --git a/src/tests/Feature/StatsCustomerBreakdownTest.php b/src/tests/Feature/StatsCustomerBreakdownTest.php
new file mode 100644
index 0000000..e2f95dd
--- /dev/null
+++ b/src/tests/Feature/StatsCustomerBreakdownTest.php
@@ -0,0 +1,44 @@
+create(['name' => 'Admin', 'email' => 'stats-admin-cust@example.com', 'roles' => ['admin']]);
+ $clientA = User::query()->create(['name' => 'Klient A', 'email' => 'stats-client-a@example.com', 'roles' => ['client']]);
+ $clientB = User::query()->create(['name' => 'Klient B', 'email' => 'stats-client-b@example.com', 'roles' => ['client']]);
+
+ makeTicket(['number' => '5001', 'customer_id' => $clientA->id]);
+ makeTicket(['number' => '5002', 'customer_id' => $clientA->id]);
+ makeTicket(['number' => '5003', 'customer_id' => $clientB->id]);
+ makeTicket(['number' => '5004', 'customer_id' => null]);
+ makeTicket(['number' => '5005', 'customer_id' => null]);
+
+ $rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCustomer;
+
+ expect($rows->firstWhere('label', 'Klient A'))->toBe(['label' => 'Klient A', 'count' => 2])
+ ->and($rows->firstWhere('label', 'Klient B'))->toBe(['label' => 'Klient B', 'count' => 1])
+ ->and($rows->firstWhere('label', 'Goście (bez konta)'))->toBe(['label' => 'Goście (bez konta)', 'count' => 2]);
+});
+
+test('byCustomer caps the ranking at the top 10 clients by volume', function () {
+ seedStatusesAndPriorities();
+ $admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-2@example.com', 'roles' => ['admin']]);
+
+ $ticketNumber = 6000;
+
+ foreach (range(1, 12) as $i) {
+ $client = User::query()->create(['name' => "Klient {$i}", 'email' => "stats-client-{$i}@example.com", 'roles' => ['client']]);
+ // Give each client a distinct ticket count (12 down to 1) so ranking is deterministic.
+ foreach (range(1, 13 - $i) as $n) {
+ makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id]);
+ }
+ }
+
+ $rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCustomer;
+
+ expect($rows)->toHaveCount(10)
+ ->and($rows->first())->toBe(['label' => 'Klient 1', 'count' => 12]);
+});
diff --git a/src/tests/Feature/StatsCustomerSubcategoryMatrixTest.php b/src/tests/Feature/StatsCustomerSubcategoryMatrixTest.php
new file mode 100644
index 0000000..1cce4cc
--- /dev/null
+++ b/src/tests/Feature/StatsCustomerSubcategoryMatrixTest.php
@@ -0,0 +1,81 @@
+create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix@example.com', 'roles' => ['admin']]);
+ $clientA = User::query()->create(['name' => 'Klient A', 'email' => 'stats-matrix-client-a@example.com', 'roles' => ['client']]);
+ $clientB = User::query()->create(['name' => 'Klient B', 'email' => 'stats-matrix-client-b@example.com', 'roles' => ['client']]);
+
+ $it = Category::query()->create(['name' => 'IT-Pomoc']);
+ $vpn = $it->subcategories()->create(['name' => 'VPN']);
+ $printers = $it->subcategories()->create(['name' => 'Drukarki']);
+
+ makeTicket(['number' => '9001', 'customer_id' => $clientA->id, 'subcategory_id' => $vpn->id]);
+ makeTicket(['number' => '9002', 'customer_id' => $clientA->id, 'subcategory_id' => $vpn->id]);
+ makeTicket(['number' => '9003', 'customer_id' => $clientA->id, 'subcategory_id' => $printers->id]);
+ makeTicket(['number' => '9004', 'customer_id' => $clientB->id, 'subcategory_id' => $printers->id]);
+ makeTicket(['number' => '9005', 'customer_id' => null, 'subcategory_id' => $vpn->id]);
+
+ $matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
+
+ expect($matrix['columns'])->toBe(['IT-Pomoc / VPN', 'IT-Pomoc / Drukarki'])
+ ->and($matrix['hasOther'])->toBeFalse();
+
+ $rowsByLabel = collect($matrix['rows'])->keyBy('label');
+
+ expect($rowsByLabel['Klient A'])->toBe(['label' => 'Klient A', 'cells' => [2, 1], 'other' => null, 'total' => 3])
+ ->and($rowsByLabel['Klient B'])->toBe(['label' => 'Klient B', 'cells' => [0, 1], 'other' => null, 'total' => 1]);
+});
+
+test('customerSubcategoryMatrix caps rows at top 10 clients and columns at top 5 subcategories, folding the rest into "Inne"', function () {
+ seedStatusesAndPriorities();
+ $admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix-2@example.com', 'roles' => ['admin']]);
+ $client = User::query()->create(['name' => 'Klient', 'email' => 'stats-matrix-client-c@example.com', 'roles' => ['client']]);
+
+ $category = Category::query()->create(['name' => 'Kategoria']);
+ $ticketNumber = 9100;
+
+ foreach (range(1, 7) as $i) {
+ $sub = $category->subcategories()->create(['name' => "Sub {$i}"]);
+ // Distinct volumes (7 down to 1) so which 5 make the cut is deterministic.
+ foreach (range(1, 8 - $i) as $n) {
+ makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id, 'subcategory_id' => $sub->id]);
+ }
+ }
+
+ $matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
+
+ expect($matrix['columns'])->toBe(['Kategoria / Sub 1', 'Kategoria / Sub 2', 'Kategoria / Sub 3', 'Kategoria / Sub 4', 'Kategoria / Sub 5'])
+ ->and($matrix['hasOther'])->toBeTrue();
+
+ $row = $matrix['rows'][0];
+ expect($row['cells'])->toBe([7, 6, 5, 4, 3])
+ ->and($row['other'])->toBe(2 + 1)
+ ->and($row['total'])->toBe(7 + 6 + 5 + 4 + 3 + 2 + 1);
+});
+
+test('customerSubcategoryMatrix caps rows at the top 10 clients by volume', function () {
+ seedStatusesAndPriorities();
+ $admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix-3@example.com', 'roles' => ['admin']]);
+ $category = Category::query()->create(['name' => 'Kategoria']);
+ $sub = $category->subcategories()->create(['name' => 'Sub']);
+ $ticketNumber = 9200;
+
+ foreach (range(1, 12) as $i) {
+ $client = User::query()->create(['name' => "Klient {$i}", 'email' => "stats-matrix-client-{$i}@example.com", 'roles' => ['client']]);
+ foreach (range(1, 13 - $i) as $n) {
+ makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id, 'subcategory_id' => $sub->id]);
+ }
+ }
+
+ $matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
+
+ expect($matrix['rows'])->toHaveCount(10)
+ ->and($matrix['rows'][0]['label'])->toBe('Klient 1')
+ ->and($matrix['rows'][0]['total'])->toBe(12);
+});
diff --git a/src/tests/Feature/StatsSubcategoryBreakdownTest.php b/src/tests/Feature/StatsSubcategoryBreakdownTest.php
new file mode 100644
index 0000000..ffc593a
--- /dev/null
+++ b/src/tests/Feature/StatsSubcategoryBreakdownTest.php
@@ -0,0 +1,30 @@
+create(['name' => 'Admin', 'email' => 'stats-admin-sub@example.com', 'roles' => ['admin']]);
+
+ $it = Category::query()->create(['name' => 'IT-Pomoc']);
+ $vpn = $it->subcategories()->create(['name' => 'VPN']);
+ $printers = $it->subcategories()->create(['name' => 'Drukarki']);
+ $orders = Category::query()->create(['name' => 'Zamówienia']);
+ $hardware = $orders->subcategories()->create(['name' => 'Sprzęt']);
+
+ makeTicket(['number' => '4001', 'subcategory_id' => $vpn->id]);
+ makeTicket(['number' => '4002', 'subcategory_id' => $vpn->id]);
+ makeTicket(['number' => '4003', 'subcategory_id' => $printers->id]);
+ makeTicket(['number' => '4004', 'subcategory_id' => $hardware->id]);
+ makeTicket(['number' => '4005', 'subcategory_id' => null]);
+
+ $rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->bySubcategory;
+
+ expect($rows->firstWhere('label', 'IT-Pomoc / VPN'))->toBe(['label' => 'IT-Pomoc / VPN', 'count' => 2])
+ ->and($rows->firstWhere('label', 'IT-Pomoc / Drukarki'))->toBe(['label' => 'IT-Pomoc / Drukarki', 'count' => 1])
+ ->and($rows->firstWhere('label', 'Zamówienia / Sprzęt'))->toBe(['label' => 'Zamówienia / Sprzęt', 'count' => 1])
+ ->and($rows->count())->toBe(3);
+});
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
index 6afd668..1753b1e 100644
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -2,6 +2,7 @@
namespace Tests;
+use App\Models\Role;
use App\Support\Settings;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@@ -14,5 +15,15 @@ abstract class TestCase extends BaseTestCase
// Settings caches statically for the lifetime of a (production) request;
// reset it between tests since Pest reuses one process for the whole run.
Settings::flush();
+
+ // User::roles is a virtual attribute backed by the roles/role_user
+ // pivot (see User::setAttribute()) — assigning a role by key only
+ // takes effect if a matching Role row already exists, so every test
+ // that creates a role-bearing user needs these seeded first. Mirrors
+ // DatabaseSeeder::seedRoles(); each test's transaction rolls this
+ // back, so it's re-seeded fresh before every test rather than once.
+ foreach (['client' => 'Klient', 'operator' => 'Operator', 'admin' => 'Administrator'] as $key => $label) {
+ Role::query()->firstOrCreate(['key' => $key], ['label' => $label]);
+ }
}
}
diff --git a/wiki/admin/README.md b/wiki/admin/README.md
index c7acf0d..73a17dd 100644
--- a/wiki/admin/README.md
+++ b/wiki/admin/README.md
@@ -55,6 +55,28 @@ zespołu i przypisanymi mu bezpośrednio).
„Brak”). Naruszenia sprawdza cykliczne zadanie co 15 minut
(`tickets:check-sla-breaches`) i może powiadomić operatora.
+## Automatyzacja SLA
+
+Reguły, które same zmieniają zgłoszenie po określonym czasie **ciszy ze strony
+klienta** (liczonym od ostatniej odpowiedzi klienta, a jeśli jeszcze nie
+odpowiedział — od utworzenia zgłoszenia). Każda reguła ma:
+
+- **Nazwę** i przełącznik **aktywna/nieaktywna**.
+- **Próg** w minutach.
+- Opcjonalne **zawężenie** — priorytet / kategoria (podkategoria) / zespół;
+ puste pole = dowolny. Wszystkie warunki muszą być spełnione naraz.
+- **Akcję** — zmień priorytet / status / zespół / przypisanego operatora, oraz
+ wartość docelową.
+
+Reguły sprawdza cykliczne zadanie co 15 minut (`automation:run-rules`, razem z
+`tickets:check-sla-breaches`). Akcja korzysta z tych samych mechanizmów co
+ręczna zmiana przez operatora — dostaje wpis w historii zgłoszenia (z dopiskiem
+„Automatyzacja: nazwa reguły”), wysyła standardowe powiadomienie dla tej zmiany
+i pojawia się na żywo w kolejce/widoku zgłoszenia. Reguła nie powtarza się dla
+tego samego zgłoszenia, dopóki klient znów nie napisze albo zgłoszenie nie
+zostanie zamknięte i otwarte ponownie — więc bezpiecznie zostawić kilka
+aktywnych reguł naraz, bez ryzyka zapętlenia się co 15 minut.
+
## Szybkie akcje odpowiedzi
Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu
@@ -80,14 +102,18 @@ więcej informacji”, „Restart usuwa problem”.
„Resetuj” do wartości domyślnej); sam layout nie jest edytowalny z poziomu UI.
- **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/
kategorii/priorytetu/zespołu/przypisania, zgłoszenie zamknięte, operator
- odpowiedział, SLA przekroczone) — każde ma przełącznik włącz/wyłącz, odbiorcę
- (klient / operator) i przypisany szablon. Usunięcie przypisanego szablonu po
- prostu wyłącza wysyłkę tego powiadomienia, dopóki ktoś nie wybierze nowego.
- „Zmiana statusu” i „zgłoszenie zamknięte” się wzajemnie wykluczają dla tej
- samej zmiany — zamknięcie zgłoszenia wysyła wyłącznie powiadomienie
- „zgłoszenie zamknięte”, żeby nie dublować maila. **Ten sam przełącznik
- kontroluje zarówno e-mail, jak i powiadomienie w dzwoneczku w aplikacji** —
- nie ma osobnego ustawienia dla powiadomień w apce.
+ odpowiedział, SLA przekroczone, **nowe zgłoszenie w zespole**) — każde ma
+ przełącznik włącz/wyłącz, odbiorcę (klient / operator) i przypisany szablon.
+ Usunięcie przypisanego szablonu po prostu wyłącza wysyłkę tego powiadomienia,
+ dopóki ktoś nie wybierze nowego. „Zmiana statusu” i „zgłoszenie zamknięte” się
+ wzajemnie wykluczają dla tej samej zmiany — zamknięcie zgłoszenia wysyła
+ wyłącznie powiadomienie „zgłoszenie zamknięte”, żeby nie dublować maila.
+ „Nowe zgłoszenie w zespole” (domyślnie włączone) trafia do **każdego**
+ operatora w zespole, którego podkategorie pasują do nowego zgłoszenia, nie
+ tylko do jednej przypisanej osoby. **Ten sam przełącznik kontroluje zarówno
+ e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — nie ma osobnego
+ ustawienia dla powiadomień w apce, a dzwoneczek pokazuje tylko nieprzeczytane
+ (znikają po kliknięciu/oznaczeniu).
## Wygląd / Branding
diff --git a/wiki/client/README.md b/wiki/client/README.md
index 26c6ea8..2662089 100644
--- a/wiki/client/README.md
+++ b/wiki/client/README.md
@@ -3,7 +3,8 @@
Panel klienta (`/client`) służy do zgłaszania problemów/próśb i śledzenia ich
rozwiązania. Po zalogowaniu każde konto domyślnie ląduje właśnie tutaj — nawet jeśli
posiada też uprawnienia operatora lub administratora (przełączysz się przez menu
-profilu w prawym górnym rogu).
+profilu w prawym górnym rogu). Dzwoneczek powiadomień w górnym pasku pokazuje
+tylko **nieprzeczytane** powiadomienia — kliknięcie usuwa je z listy.
## Zgłaszanie nowej sprawy
@@ -41,10 +42,20 @@ odpowiedzi w wątku).
Otwórz dowolne zgłoszenie, by zobaczyć:
-- aktualny **status** i **priorytet**,
+- aktualny **status** i **priorytet**, oraz **przypisanego operatora** i
+ **zespół**, który obsługuje sprawę,
- pełną **historię wiadomości** (Twoje i operatora — notatki wewnętrzne operatora
nie są widoczne dla klienta); załączone obrazy pokazują się jako miniatury,
-- **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy.
+- **historię zmian** — log statusu/priorytetu/zespołu/przypisania z datą,
+- **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy,
+- jeśli administrator włączył integrację z bazą wiedzy — panel z artykułami
+ dopasowanymi do kategorii/podkategorii sprawy (te same podpowiedzi, co przy
+ tworzeniu zgłoszenia).
+
+Wszystko na tej stronie aktualizuje się **na żywo** — jeśli operator odpowie
+albo zmieni status/przypisanie, zobaczysz to bez odświeżania strony. Mały
+licznik przy przycisku „Wróć do listy” to niezależny, okresowy fallback (co
+ok. 30 s), na wypadek gdyby połączenie w tle się zerwało.
## Odpowiadanie
diff --git a/wiki/operator/README.md b/wiki/operator/README.md
index 868081f..25bd748 100644
--- a/wiki/operator/README.md
+++ b/wiki/operator/README.md
@@ -6,9 +6,10 @@ odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu.
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
-zdarzenia na Twoich zgłoszeniach na bieżąco, bez odświeżania strony.
+Twoje **nieprzeczytane** powiadomienia — kliknięcie (albo „Oznacz wszystkie jako
+przeczytane”) usuwa je z listy.
-## Kolejka zgłoszeń
+## Kolejka zgłoszeń — aktualizacje na żywo
Panel główny (`/operator`) pokazuje listę zgłoszeń z zakładkami po lewej stronie:
@@ -37,8 +38,20 @@ swoje.
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
zamknięta) albo **usunąć**.
+Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/
+przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
+przycisku „Kolumny” widać mały licznik odliczający do zera — to niezależny od
+połączenia na żywo, okresowy fallback (co ok. 60 s), na wypadek gdyby
+połączenie sieciowe w tle się zerwało.
+
## Praca ze zgłoszeniem
+Widok zgłoszenia też aktualizuje się na żywo — nowa wiadomość klienta pojawia
+się od razu (bez odświeżania), podobnie jak zmiana statusu/priorytetu/zespołu
+zrobiona przez innego operatora albo przez regułę automatyzacji SLA. Licznik
+przy przycisku „Wróć do listy” to taki sam fallbackowy zegar jak w kolejce
+(co ok. 30 s).
+
W widoku pojedynczego zgłoszenia:
- **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
@@ -65,7 +78,8 @@ W widoku pojedynczego zgłoszenia:
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
zmiana kategorii może wysłać powiadomienie do klienta.
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
- datą.
+ datą; wpis zaczynający się od „Automatyzacja: …” oznacza, że zmianę wykonała
+ reguła automatyzacji SLA (Admin > Automatyzacja SLA), nie operator ręcznie.
## Statystyki (`/operator/stats`)
@@ -94,15 +108,33 @@ zmianie filtra, bez przeładowania strony.
Przycisk **„Eksportuj CSV"** pobiera listę zgłoszeń (jeden wiersz na zgłoszenie) z
uwzględnieniem aktualnie wybranego zakresu dat i filtrów.
-**Wykresy** (paski poziome, kolor = ta sama identyfikacja co w kolejce dla statusu/
-priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
+Reszta strony jest podzielona na sekcje:
+
+**Rozkład zgłoszeń** (paski poziome, kolor = ta sama identyfikacja co w kolejce
+dla statusu/priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
- Zgłoszenia wg statusu
- Zgłoszenia wg priorytetu
- Zgłoszenia wg kategorii
+- Zgłoszenia wg podkategorii
+
+**Obciążenie**:
+
- Obciążenie zespołów
- Obciążenie operatorów (ranking wg liczby przypisanych zgłoszeń)
+**Klienci**:
+
+- Najaktywniejsi klienci (Top 10 wg liczby zgłoszeń w wybranym okresie; osobny
+ wiersz „Goście (bez konta)” sumuje zgłoszenia bez zalogowanego klienta)
+- **Klienci wg podkategorii** — tabela krzyżowa: top 10 klientów × top 5
+ najczęstszych podkategorii w wybranym okresie, reszta podkategorii zbiorczo w
+ kolumnie „Inne”.
+
+**Ocena obsługi (CSAT)** — średnia ocena (X.XX / 5) wg zespołu i wg operatora;
+zespół/operator bez żadnej oceny w wybranym okresie po prostu nie pojawia się
+na liście.
+
**Trend** — dzienny wykres słupkowy „Nowe zgłoszenia” i „Zamknięte zgłoszenia”
obok siebie (maks. ostatnie 60 dni wybranego zakresu, żeby słupki pozostały
czytelne przy długich okresach).