Co nowego:
- Wsparcie Active Directory dla LDAP (obok LLDAP/OpenLDAP), przełącznik typu
  katalogu w Admin > Integracje.
- Wyszukiwarka klientów dla operatora (Operator > Klienci).
- Stronicowanie kolejki operatora (50/stronę) i dashboardu klienta (20/stronę).
- Globalna wyszukiwarka zgłoszeń (Ctrl+K/Cmd+K) z operatorami w stylu Gmaila
  (od:, temat:, treść:, numer:), plus przycisk "Szukaj" w panelu bocznym.
- Ostatnio przeglądane zgłoszenia w panelu bocznym operatora.
- Przeprojektowany pasek nawigacji: suwak Klient/Operator/Administrator
  zamiast rozwijanego menu, bogatsze menu profilu (nazwa/e-mail/role),
  dynamiczne tytuły kart przeglądarki na każdej podstronie.
- Narzędzie do jednorazowego importu historii zgłoszeń z Heska 3.x
  (scripts/hesk-import/).
- Poprawka: paginacja pokazywała surowe klucze tłumaczeń zamiast tekstu
  (brakujący lang/pl/pagination.php).

Zaktualizowana dokumentacja: README, CLAUDE.md, install.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 00:58:35 +02:00
parent 7a8cf2037c
commit 03c6ec7cae
57 changed files with 2029 additions and 162 deletions

View File

@@ -0,0 +1,147 @@
<?php
use App\Livewire\GlobalSearch;
use App\Livewire\Operator\Queue;
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Models\User;
use Livewire\Livewire;
test('opening a ticket records a view that surfaces in the operator sidebar, most recent first', function () {
seedStatusesAndPriorities();
$operator = operatorUser('recent-view@example.com');
$ticketA = makeTicket(['number' => '1001', 'subject' => 'First one']);
$ticketB = makeTicket(['number' => '1002', 'subject' => 'Second one']);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
$queue = Livewire::actingAs($operator)->test(Queue::class);
$recent = $queue->instance()->recentlyViewed;
expect($recent->pluck('id')->all())->toBe([$ticketB->id, $ticketA->id]);
});
test('re-opening the same ticket bumps it to the top instead of duplicating it', function () {
seedStatusesAndPriorities();
$operator = operatorUser('recent-view-bump@example.com');
$ticketA = makeTicket(['number' => '1001']);
$ticketB = makeTicket(['number' => '1002']);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
$recent = Livewire::actingAs($operator)->test(Queue::class)->instance()->recentlyViewed;
expect($recent->pluck('id')->all())->toBe([$ticketA->id, $ticketB->id]);
});
test('global search only returns tickets the searching user is allowed to see', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-operator@example.com');
$clientA = User::query()->create(['name' => 'Client A', 'email' => 'client-a@example.com', 'roles' => ['client']]);
$clientB = User::query()->create(['name' => 'Client B', 'email' => 'client-b@example.com', 'roles' => ['client']]);
$ticketA = makeTicket(['number' => '2001', 'subject' => 'Unikalny temat A', 'customer_id' => $clientA->id, 'email' => $clientA->email]);
makeTicket(['number' => '2002', 'subject' => 'Unikalny temat B', 'customer_id' => $clientB->id, 'email' => $clientB->email]);
// Operator sees both, scoped only by the search term.
$operatorResults = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'Unikalny')
->instance()->results;
expect($operatorResults)->toHaveCount(2);
// Client A only ever sees their own ticket, even when the term matches both.
$clientResults = Livewire::actingAs($clientA)->test(GlobalSearch::class)
->set('search', 'Unikalny')
->instance()->results;
expect($clientResults->pluck('id')->all())->toBe([$ticketA->id]);
});
test('global search returns nothing for a blank query', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-blank@example.com');
makeTicket(['number' => '3001']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)->instance()->results;
expect($results)->toBeEmpty();
});
test('the od: operator matches the reporter, not ticket content', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-od@example.com');
$byKacper = makeTicket(['number' => '4001', 'name' => 'Kacper Zbikowski', 'email' => 'kacper@example.com', 'subject' => 'Nieistotny temat']);
makeTicket(['number' => '4002', 'name' => 'Ktos Inny', 'email' => 'ktos@example.com', 'subject' => 'Kacper w temacie']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'od:Kacper')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$byKacper->id]);
});
test('the temat: operator matches only the subject', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-temat@example.com');
$matching = makeTicket(['number' => '4101', 'subject' => 'Awaria drukarki', 'body' => 'coś innego']);
makeTicket(['number' => '4102', 'subject' => 'Coś innego', 'body' => 'Awaria drukarki w opisie']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'temat:drukarki')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('the treść: operator matches only ticket/message body, not the subject', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-tresc@example.com');
$matching = makeTicket(['number' => '4201', 'subject' => 'Coś innego', 'body' => 'Drukarka nie działa od rana']);
makeTicket(['number' => '4202', 'subject' => 'Drukarka znów nie działa', 'body' => 'coś innego']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'treść:drukarka')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('the numer: operator matches the ticket number', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-numer@example.com');
$matching = makeTicket(['number' => '9999']);
makeTicket(['number' => '1111']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'numer:9999')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('operators combine with AND, matching Gmail-style multi-field search', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-combo@example.com');
$matching = makeTicket(['number' => '4301', 'name' => 'Kacper Zbikowski', 'subject' => 'Awaria drukarki']);
makeTicket(['number' => '4302', 'name' => 'Kacper Zbikowski', 'subject' => 'Inny temat']);
makeTicket(['number' => '4303', 'name' => 'Ktos Inny', 'subject' => 'Awaria drukarki']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'od:Kacper temat:drukarki')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('an unrecognized "key:value" token is treated as free text instead of being silently dropped', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-unrecognized@example.com');
$matching = makeTicket(['number' => '4401', 'subject' => 'status:pilne coś']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'status:pilne')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});

View File

@@ -0,0 +1,47 @@
<?php
use App\Livewire\Operator\ClientSearch;
use App\Models\User;
use Livewire\Livewire;
test('searching by name or email finds matching accounts, empty search shows nothing', function () {
$operator = operatorUser('client-search-op@example.com');
$anna = User::query()->create(['name' => 'Anna Kowalska', 'email' => 'anna.kowalska@example.com', 'roles' => ['client']]);
User::query()->create(['name' => 'Jan Nowak', 'email' => 'jan.nowak@example.com', 'roles' => ['client']]);
$component = Livewire::actingAs($operator)->test(ClientSearch::class);
expect($component->instance()->results)->toHaveCount(0);
$component->set('search', 'Kowalska');
expect($component->instance()->results->pluck('id')->all())->toBe([$anna->id]);
$component->set('search', 'jan.nowak@example.com');
expect($component->instance()->results)->toHaveCount(1);
$component->set('search', 'nobody-matches-this');
expect($component->instance()->results)->toHaveCount(0);
});
test('results include the ticket count and are capped at 20', function () {
seedStatusesAndPriorities();
$operator = operatorUser('client-search-op2@example.com');
$client = User::query()->create(['name' => 'Piotr Testowy', 'email' => 'piotr@example.com', 'roles' => ['client']]);
makeTicket(['number' => '2001', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
makeTicket(['number' => '2002', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
foreach (range(1, 25) as $i) {
User::query()->create(['name' => "Testowy Kandydat {$i}", 'email' => "cand{$i}@example.com", 'roles' => ['client']]);
}
$component = Livewire::actingAs($operator)->test(ClientSearch::class)->set('search', 'Testowy');
expect($component->instance()->results)->toHaveCount(20);
$component->set('search', 'Piotr');
expect($component->instance()->results->first()->tickets_as_customer_count)->toBe(2);
});
test('a client cannot reach the operator client-search page', function () {
$client = User::query()->create(['name' => 'Client', 'email' => 'not-operator@example.com', 'roles' => ['client']]);
$this->actingAs($client)->get(route('operator.clients'))->assertForbidden();
});

View File

@@ -0,0 +1,46 @@
<?php
use App\Models\User;
use Illuminate\Testing\TestResponse;
function assertTitle(TestResponse $response, string $expectedContext): void
{
$response->assertOk();
preg_match('#<title>(.*?)</title>#s', $response->getContent(), $m);
expect($m[1] ?? null)->toBe($expectedContext.' — Servicedesk');
}
test('login and landing pages get a page-specific title', function () {
assertTitle($this->get('/login'), 'Logowanie');
assertTitle($this->get('/'), 'Zgłoś problem');
});
test('client pages carry a distinct, ticket-specific title', function () {
seedStatusesAndPriorities();
$client = User::query()->create(['name' => 'Client', 'email' => 'title-client@example.com', 'roles' => ['client']]);
$ticket = makeTicket(['number' => '6001', 'subject' => 'Drukarka nie działa', 'customer_id' => $client->id, 'email' => $client->email]);
assertTitle($this->actingAs($client)->get('/client'), 'Moje zgłoszenia');
assertTitle($this->actingAs($client)->get('/client/new'), 'Nowe zgłoszenie');
assertTitle($this->actingAs($client)->get(route('client.ticket', $ticket)), '#6001 — Drukarka nie działa');
});
test('operator queue title reflects the currently selected queue', function () {
seedStatusesAndPriorities();
$operator = operatorUser('title-operator@example.com');
$ticket = makeTicket(['number' => '6101', 'subject' => 'Awaria monitora']);
assertTitle($this->actingAs($operator)->get('/operator'), 'Otwarte');
assertTitle($this->actingAs($operator)->get('/operator?queue=mine'), 'Moje zgłoszenia');
assertTitle($this->actingAs($operator)->get('/operator/new'), 'Nowe zgłoszenie');
assertTitle($this->actingAs($operator)->get('/operator/stats'), 'Statystyki');
assertTitle($this->actingAs($operator)->get('/operator/clients'), 'Klienci');
assertTitle($this->actingAs($operator)->get(route('operator.ticket', $ticket)), '#6101 — Awaria monitora');
});
test('admin panel title reflects the currently selected tab', function () {
$admin = adminUser();
assertTitle($this->actingAs($admin)->get('/admin'), 'Kategorie');
assertTitle($this->actingAs($admin)->get('/admin?tab=users'), 'Użytkownicy');
});

View File

@@ -31,16 +31,16 @@ test('every page renders without error against fully seeded data', function () {
$this->get('/')->assertOk()->assertSee('Jak możemy pomóc');
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');
$this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Panel Klienta');
$this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Panel Klienta');
$this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Panel Klienta');
$this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Klient');
$this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Klient');
$this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Klient');
$this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Panel Operatora');
$this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Panel Operatora');
$this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Panel Operatora');
$this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Operator');
$this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Operator');
$this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Operator');
foreach (['categories', 'fields', 'users', 'teams', 'user-fields', 'reply-quick-actions', 'response-templates', 'statuses', 'priorities', 'templates', 'branding', 'config', 'about'] as $tab) {
$this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Panel Administratora');
$this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Administrator');
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', $tab)
->assertOk();

View File

@@ -0,0 +1,80 @@
<?php
use App\Livewire\Client\Dashboard;
use App\Livewire\Client\TicketShow;
use App\Livewire\Operator\Queue;
use App\Models\User;
use Livewire\Livewire;
test('the operator queue paginates instead of rendering every matching ticket at once', function () {
seedStatusesAndPriorities();
$operator = operatorUser('queue-page@example.com');
foreach (range(1, 55) as $i) {
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT)]);
}
$component = Livewire::actingAs($operator)->test(Queue::class);
expect($component->instance()->filteredTickets)->toHaveCount(50)
->and($component->instance()->filteredTickets->total())->toBe(55);
$component->call('gotoPage', 2);
expect($component->instance()->filteredTickets)->toHaveCount(5);
});
test('changing the queue search resets the operator to page 1', function () {
seedStatusesAndPriorities();
$operator = operatorUser('queue-page-reset@example.com');
foreach (range(1, 55) as $i) {
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'subject' => 'Zgłoszenie']);
}
makeTicket(['number' => '9999', 'subject' => 'Unikalny temat']);
$component = Livewire::actingAs($operator)->test(Queue::class)->call('gotoPage', 2);
expect($component->instance()->filteredTickets->currentPage())->toBe(2);
$component->set('search', 'Unikalny');
expect($component->instance()->filteredTickets->currentPage())->toBe(1)
->and($component->instance()->filteredTickets)->toHaveCount(1);
});
test('the client dashboard paginates current/archive tickets separately', function () {
seedStatusesAndPriorities();
$client = User::query()->create(['name' => 'Client', 'email' => 'dash-page@example.com', 'roles' => ['client']]);
foreach (range(1, 25) as $i) {
makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'new']);
}
foreach (range(1, 3) as $i) {
makeTicket(['number' => '90'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']);
}
$component = Livewire::actingAs($client)->test(Dashboard::class);
expect($component->instance()->currentTickets)->toHaveCount(20)
->and($component->instance()->currentTickets->total())->toBe(25)
->and($component->instance()->archiveTickets)->toHaveCount(3);
});
test('"Inne Twoje zgłoszenia" shows only a handful until "Pokaż wszystkie" is clicked', function () {
seedStatusesAndPriorities();
$client = User::query()->create(['name' => 'Client', 'email' => 'other-tickets@example.com', 'roles' => ['client']]);
$viewed = makeTicket(['number' => '5000', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
foreach (range(1, 8) as $i) {
makeTicket(['number' => '60'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]);
}
$component = Livewire::actingAs($client)->test(TicketShow::class, ['ticket' => $viewed]);
expect($component->instance()->otherTickets)->toHaveCount(5)
->and($component->instance()->otherTicketsCount)->toBe(8);
$component->call('revealAllOtherTickets');
expect($component->instance()->otherTickets)->toHaveCount(8);
});