diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 59321f0..91b3657 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -65,11 +65,19 @@ rather than spreading it across Livewire components. ## Roles & permissions -Roles are a plain array on the user (`$user->roles`), not a separate pivot-backed -package — checked via `EnsureRole` at the route level. Every account gets -`client` by default (`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned -accounts); staff switch areas via the header role switcher, but always land on -`/client` first after login. +`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but +it's a **virtual attribute** (`User::getAttribute()`/`setAttribute()` +overrides) backed by a real `roles` lookup table + `role_user` pivot, not an +actual column — assigning `'roles' => [...]` on create/update stashes the keys +until the model's `saved` hook resolves them against `roles.key` and syncs the +pivot. This matters for tests/seeders: a role key must exist in the `roles` +table *before* it can be assigned this way, or the assignment silently becomes +a no-op (`Tests\TestCase::setUp()` seeds the 3 fixed roles for exactly this +reason, since almost every test creates a role-bearing user). Checked via +`EnsureRole` at the route level. Every account gets `client` by default +(`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned accounts); staff +switch areas via the header role switcher, but always land on `/client` first +after login. ## Authentication @@ -118,6 +126,72 @@ since one account can hold both — this decides whether the ticket link (both the e-mail body and the in-app notification's `url`) points into `/client/...` or `/operator/...`. +## Real-time broadcasting (Reverb) + +Two private channels, authorized in `routes/channels.php`: + +- **`operator.queue`** — one shared channel for every operator/admin (not + scoped per team/ticket), so the receiving `Operator\Queue` component just + re-queries through its own already-correct `Ticket::scopeVisibleToOperator()` + on any event instead of the channel-auth callback needing to duplicate that + ACL logic. Payloads stay minimal (ticket id + reason + actor id) for the + same reason. +- **`ticket.{id}`** — per-ticket channel for the message thread and detail + changes, authorized for an operator with `isVisibleToOperator()` **or** the + ticket's own customer (OR, not else-if — the one real account in this app + holds both roles at once). An internal note broadcasts on the same channel + a client can subscribe to, but the payload never carries the message body — + each side's Livewire component only ever re-queries whatever its own + already-authorized computed property returns, so there's nothing to leak. + +Two events, both `App\Events\TicketQueueChanged` (broadcasts on **both** +channels above — a status/priority/team/assignee change needs to reach a +client watching their own ticket too) and `App\Events\TicketMessagePosted` +(broadcasts on `ticket.{id}` only). Both implement `ShouldBroadcastNow`, not +`ShouldBroadcast` — this app runs with no queue worker by design (see +`TicketNotification`), so broadcasting happens synchronously within the +request like everything else here. `TicketService` dispatches both from every +ticket-mutating method (create/setStatus/setPriority/setAssignee/setTeam/ +operatorReply/operatorNote/clientReply/apiMessage/merge); the two ad hoc +delete call sites (`Operator\Queue::confirmDeleteSelected()`, +`Operator\TicketShow::confirmDeleteTicket()`) dispatch `TicketQueueChanged` +directly since there's no `TicketService::delete()` to hook into. + +Browser side, `resources/js/echo.js` bridges Reverb events into plain +Livewire events (`Livewire.dispatch('queue-changed', ...)` / +`'ticket-message-posted'`) rather than using the `#[On('echo-private:...')]` +attribute directly — version-agnostic, and each Livewire component just +declares a plain `#[On(...)]` listener that no-ops if the payload's `actorId` +matches the viewer's own id (self-echo suppression) or the ticket id doesn't +match the component's own ticket. Two easy-to-reintroduce bugs to know about +if "nothing updates live" ever comes back: + +1. **CSRF on `/broadcasting/auth`.** Echo's private-channel subscription + POSTs there under the app's normal CSRF middleware; the `Echo` constructor + must pass `auth.headers['X-CSRF-TOKEN']` (read from the `` tag in `layouts/app.blade.php`) or every subscription + attempt is silently rejected. +2. **Script load order.** `resources/js/app.js` (which imports `echo.js`) + loads via `@vite` as `type="module"`, which the HTML spec defers until + after the document is parsed — meaning Livewire's own bootstrap script + (`@livewireScripts`, a plain synchronous ` @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php index d88a4aa..e1740e1 100644 --- a/src/resources/views/livewire/admin/panel.blade.php +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -7,6 +7,7 @@ $tabGroups = [ ['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'], ['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'], ['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'], + ['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'], ], 'Zespół' => [ ['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'], @@ -322,6 +323,39 @@ $tabGroups = [ @endif @endif + {{-- ================= AUTOMATION RULES ================= --}} + @if ($tab === 'automation-rules') +
+

Automatyzacja SLA

+ +
+

Co 15 minut sprawdzane jest, czy zgłoszenie milczy (brak odpowiedzi klienta) dłużej niż próg reguły — jeśli tak (i pasuje do opcjonalnego zawężenia), wykonywana jest wybrana akcja. 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.

+ @if ($this->automationRules->isNotEmpty()) +
+ @foreach ($this->automationRules as $rule) +
+
+
+ {{ $rule->label }} + Brak odpowiedzi klienta ≥ {{ $rule->condition_minutes }} min. → {{ match($rule->action_type) { 'change_priority' => 'zmień priorytet', 'change_status' => 'zmień status', 'change_team' => 'zmień zespół', 'change_assignee' => 'zmień przypisanie', default => $rule->action_type } }} +
+
+ + + +
+
+
+ @endforeach +
+ @else +

Brak reguł automatyzacji. Dodaj pierwszą używając przycisku wyżej.

+ @endif + @endif + {{-- ================= STATUSES ================= --}} @if ($tab === 'statuses')

Statusy

@@ -803,6 +837,96 @@ $tabGroups = [ @endif + @if ($automationRuleFormOpen) +
+
+
{{ $automationRuleForm['id'] ? 'Edytuj regułę automatyzacji' : 'Nowa reguła automatyzacji' }}
+
+ @error('automationRuleForm.label') {{ $message }} @enderror +
+ + +
+ @error('automationRuleForm.condition_minutes') {{ $message }} @enderror + +
+ + + + +
+ +
+ + +
+
+ + @switch ($automationRuleForm['action_type']) + @case ('change_priority') + + @break + @case ('change_status') + + @break + @case ('change_team') + + @break + @case ('change_assignee') + + @break + @endswitch +
+ @error('automationRuleForm.action_value') {{ $message }} @enderror + +
+ + +
+
+
+ @endif + @if ($this->editingTemplate)
diff --git a/src/resources/views/livewire/client/new-ticket.blade.php b/src/resources/views/livewire/client/new-ticket.blade.php index e94eebf..f424e20 100644 --- a/src/resources/views/livewire/client/new-ticket.blade.php +++ b/src/resources/views/livewire/client/new-ticket.blade.php @@ -60,7 +60,9 @@
- +
+ +
diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index c8de634..5603cb1 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -1,8 +1,24 @@
-
- ← Wróć do listy +
+
+ ← Wróć do listy + + {{-- Live updates arrive via broadcasting, but websocket connections can + drop silently — this is a periodic fallback refresh, with a visible + countdown so it's clear the thread is still refreshing on its own. --}} +
+ schedule + +
+
@@ -31,7 +47,7 @@
@foreach ($threadMessages as $m) @php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp -
+
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}
@@ -94,12 +110,18 @@
{{ auth()->user()->name }}
{{ auth()->user()->email }}
+
+ +
+
Status i priorytet
-
+
{{ $ticket->priorityLabel() }} {{ $ticket->statusLabel() }}
+
Przypisany operator: {{ $ticket->assignee?->name ?? 'Nieprzypisane' }}
+
Zespół: {{ $ticket->team?->name ?? 'Brak' }}
@if (! $ticket->isClosed()) @elseif ($ticket->isClosed()) @@ -137,7 +159,7 @@
Historia zmian
@forelse ($ticket->histories as $h) -
{{ $h->text }}
{{ \App\Support\Rel::format($h->created_at) }}
+
{{ $h->text }}
{{ \App\Support\Rel::format($h->created_at) }}
@empty

Brak historii zmian.

@endforelse @@ -169,4 +191,19 @@
@endif + + @script + + @endscript
diff --git a/src/resources/views/livewire/landing.blade.php b/src/resources/views/livewire/landing.blade.php index 88d739f..7fa70b6 100644 --- a/src/resources/views/livewire/landing.blade.php +++ b/src/resources/views/livewire/landing.blade.php @@ -90,7 +90,9 @@
- +
+ +
@@ -143,7 +158,7 @@ @foreach ($this->filteredTickets as $t) @php $sla = $t->slaInfo(); @endphp - + id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"> @if (in_array('number', $visibleColumns)) {{ $t->number }} diff --git a/src/resources/views/livewire/operator/stats.blade.php b/src/resources/views/livewire/operator/stats.blade.php index ca5489d..344d6c4 100644 --- a/src/resources/views/livewire/operator/stats.blade.php +++ b/src/resources/views/livewire/operator/stats.blade.php @@ -61,6 +61,8 @@ {{-- KPI tiles --}} @php($kpis = $this->kpis) +
+

Podsumowanie

Łącznie zgłoszeń
@@ -96,84 +98,200 @@
{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}
+
-
- {{-- By status --}} -
-
Zgłoszenia wg statusu
- @php($max = max($this->byStatus->max('count'), 1)) - @forelse ($this->byStatus as $row) -
-
{{ $row['label'] }}
-
-
{{ $row['count'] }}
-
- @empty -
Brak danych w wybranym okresie.
- @endforelse + {{-- Rozkład zgłoszeń --}} +
+

Rozkład zgłoszeń

+
+
+
Zgłoszenia wg statusu
+ @php($max = max($this->byStatus->max('count'), 1)) + @forelse ($this->byStatus as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ +
+
Zgłoszenia wg priorytetu
+ @php($max = max($this->byPriority->max('count'), 1)) + @forelse ($this->byPriority as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ +
+
Zgłoszenia wg kategorii
+ @php($cats = $this->byCategory) + @php($max = max($cats->max('count') ?? 0, 1)) + @forelse ($cats as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ +
+
Zgłoszenia wg podkategorii
+ @php($subs = $this->bySubcategory) + @php($max = max($subs->max('count') ?? 0, 1)) + @forelse ($subs as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+
+
+ + {{-- Obciążenie --}} +
+

Obciążenie

+
+
+
Obciążenie zespołów
+ @php($teamRows = $this->byTeam) + @php($max = max($teamRows->max('count') ?? 0, 1)) + @forelse ($teamRows as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+ +
+
Obciążenie operatorów
+ @php($opRows = $this->byAssignee) + @php($max = max($opRows->max('count') ?? 0, 1)) + @forelse ($opRows as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
+
+
+ + {{-- Klienci --}} +
+

Klienci

+
+
+
Najaktywniejsi klienci (Top 10)
+ @php($customerRows = $this->byCustomer) + @php($max = max($customerRows->max('count') ?? 0, 1)) + @forelse ($customerRows as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['count'] }}
+
+ @empty +
Brak danych w wybranym okresie.
+ @endforelse +
- {{-- By priority --}}
-
Zgłoszenia wg priorytetu
- @php($max = max($this->byPriority->max('count'), 1)) - @forelse ($this->byPriority as $row) -
-
{{ $row['label'] }}
-
-
{{ $row['count'] }}
+
Klienci wg podkategorii
+
Top 10 klientów × top 5 podkategorii wg wolumenu w wybranym okresie; reszta zbiorczo w kolumnie „Inne”.
+ @php($matrix = $this->customerSubcategoryMatrix) + @if (count($matrix['rows'])) +
+ + + + + @foreach ($matrix['columns'] as $col) + + @endforeach + @if ($matrix['hasOther']) + + @endif + + + + + @foreach ($matrix['rows'] as $row) + + + @foreach ($row['cells'] as $cell) + + @endforeach + @if ($matrix['hasOther']) + + @endif + + + @endforeach + +
Klient{{ $col }}InneRazem
{{ $row['label'] }}{{ $cell ?: '—' }}{{ $row['other'] ?: '—' }}{{ $row['total'] }}
- @empty + @else
Brak danych w wybranym okresie.
- @endforelse + @endif
+
- {{-- By category --}} -
-
Zgłoszenia wg kategorii
- @php($cats = $this->byCategory) - @php($max = max($cats->max('count') ?? 0, 1)) - @forelse ($cats as $row) -
-
{{ $row['label'] }}
-
-
{{ $row['count'] }}
-
- @empty -
Brak danych w wybranym okresie.
- @endforelse -
+ {{-- Ocena obsługi (CSAT) --}} +
+

Ocena obsługi (CSAT)

+
+
+
CSAT wg zespołu
+ @php($csatTeamRows = $this->csatByTeam) + @forelse ($csatTeamRows as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['avg'] }} / 5
+
+ @empty +
Brak ocen w wybranym okresie.
+ @endforelse +
- {{-- By team --}} -
-
Obciążenie zespołów
- @php($teamRows = $this->byTeam) - @php($max = max($teamRows->max('count') ?? 0, 1)) - @forelse ($teamRows as $row) -
-
{{ $row['label'] }}
-
-
{{ $row['count'] }}
-
- @empty -
Brak danych w wybranym okresie.
- @endforelse -
- - {{-- By assignee --}} -
-
Obciążenie operatorów
- @php($opRows = $this->byAssignee) - @php($max = max($opRows->max('count') ?? 0, 1)) - @forelse ($opRows as $row) -
-
{{ $row['label'] }}
-
-
{{ $row['count'] }}
-
- @empty -
Brak danych w wybranym okresie.
- @endforelse +
+
CSAT wg operatora
+ @php($csatOpRows = $this->csatByAssignee) + @forelse ($csatOpRows as $row) +
+
{{ $row['label'] }}
+
+
{{ $row['avg'] }} / 5
+
+ @empty +
Brak ocen w wybranym okresie.
+ @endforelse +
@@ -181,6 +299,8 @@ @php($trend = $this->trend) @php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1)) @php($labelStep = max((int) ceil(count($trend) / 12), 1)) +
+

Trend w czasie

Trend zgłoszeń
Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).
@@ -223,5 +343,6 @@
@endif
+
diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index 4f2f721..94fa3e2 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -3,7 +3,23 @@
- ← Wróć do listy +
+ ← Wróć do listy + + {{-- Live updates arrive via broadcasting, but websocket connections can + drop silently — this is a periodic fallback refresh, with a visible + countdown so it's clear the thread is still refreshing on its own. --}} +
+ schedule + +
+
@@ -67,7 +83,7 @@
Notatki wewnętrzne
@forelse ($this->internalMessages as $m) -
+
{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}
@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 @@
- +
+ +
SLA
@@ -435,4 +453,19 @@
@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).