diff --git a/src/routes/web.php b/src/routes/web.php
index 3446930..2fe2c07 100644
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -6,6 +6,7 @@ use App\Livewire\Client\Dashboard as ClientDashboard;
use App\Livewire\Client\NewTicket as ClientNewTicket;
use App\Livewire\Client\TicketShow as ClientTicketShow;
use App\Livewire\Landing;
+use App\Livewire\Operator\ClientSearch as OperatorClientSearch;
use App\Livewire\Operator\NewTicket as OperatorNewTicket;
use App\Livewire\Operator\Queue as OperatorQueue;
use App\Livewire\Operator\Stats as OperatorStats;
@@ -56,6 +57,7 @@ Route::middleware(['auth', 'role:operator'])->prefix('operator')->name('operator
Route::get('/', OperatorQueue::class)->name('queue');
Route::get('/new', OperatorNewTicket::class)->name('new');
Route::get('/stats', OperatorStats::class)->name('stats');
+ Route::get('/clients', OperatorClientSearch::class)->name('clients');
Route::get('/tickets/{ticket}', OperatorTicketShow::class)->name('ticket');
// Fired via navigator.sendBeacon on tab/browser close (see the
diff --git a/src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php b/src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php
new file mode 100644
index 0000000..013bc77
--- /dev/null
+++ b/src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php
@@ -0,0 +1,147 @@
+ '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]);
+});
diff --git a/src/tests/Feature/OperatorClientSearchTest.php b/src/tests/Feature/OperatorClientSearchTest.php
new file mode 100644
index 0000000..dc71911
--- /dev/null
+++ b/src/tests/Feature/OperatorClientSearchTest.php
@@ -0,0 +1,47 @@
+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();
+});
diff --git a/src/tests/Feature/PageTitleTest.php b/src/tests/Feature/PageTitleTest.php
new file mode 100644
index 0000000..9d71acd
--- /dev/null
+++ b/src/tests/Feature/PageTitleTest.php
@@ -0,0 +1,46 @@
+assertOk();
+ preg_match('#
(.*?)#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');
+});
diff --git a/src/tests/Feature/SmokeTest.php b/src/tests/Feature/SmokeTest.php
index 89ff253..3c3657f 100644
--- a/src/tests/Feature/SmokeTest.php
+++ b/src/tests/Feature/SmokeTest.php
@@ -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();
diff --git a/src/tests/Feature/TicketListPaginationTest.php b/src/tests/Feature/TicketListPaginationTest.php
new file mode 100644
index 0000000..61b1ece
--- /dev/null
+++ b/src/tests/Feature/TicketListPaginationTest.php
@@ -0,0 +1,80 @@
+ 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);
+});
diff --git a/wiki/admin/README.md b/wiki/admin/README.md
index c894723..879b2b0 100644
--- a/wiki/admin/README.md
+++ b/wiki/admin/README.md
@@ -7,7 +7,8 @@ treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz integracje
(LDAP, poczta SMTP/IMAP, BookStack, AI, API).
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
-panelu Administratora przez menu profilu (prawy górny róg).
+panelu Administratora suwakiem Klient/Operator/Administrator na środku
+górnego paska.
## Kategorie i pola dodatkowe
@@ -238,10 +239,12 @@ razem, zamiast być rozrzucone po różnych zakładkach.
## Integracje
-- **LDAP** — host, port, base DN, bind DN + hasło, SSL, filtr użytkownika
- (`(uid={0})` domyślnie), auto-provisioning gości, ograniczenie tworzenia
- kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** sprawdza
- bind bez zapisywania zmian.
+- **LDAP** — **Typ katalogu** (LLDAP/OpenLDAP domyślnie, albo Active
+ Directory — zmienia oczekiwany schemat i domyślny atrybut logowania), host,
+ port, base DN, bind DN + hasło, SSL, filtr użytkownika (`(uid={0})` dla
+ LLDAP, `(sAMAccountName={0})` dla AD), auto-provisioning gości, ograniczenie
+ tworzenia kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”**
+ sprawdza bind bez zapisywania zmian.
> Po świeżej instalacji (`migrate:fresh --seed`) LDAP i SMTP zawierają
> **przykładowe wartości** (`ldap.example.com`, `smtp.example.com`,
diff --git a/wiki/client/README.md b/wiki/client/README.md
index 0d3c6cd..263c998 100644
--- a/wiki/client/README.md
+++ b/wiki/client/README.md
@@ -2,8 +2,9 @@
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). Dzwoneczek powiadomień w górnym pasku pokazuje
+posiada też uprawnienia operatora lub administratora (przełączysz się suwakiem
+Klient/Operator/Administrator na środku górnego paska — pokazuje tylko te panele,
+do których masz dostęp). Dzwoneczek powiadomień w górnym pasku pokazuje
tylko **nieprzeczytane** powiadomienia — kliknięcie usuwa je z listy.
## Zgłaszanie nowej sprawy
@@ -43,7 +44,10 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki:
- **Archiwum** — zgłoszenia zamknięte.
Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz
-odpowiedzi w wątku).
+odpowiedzi w wątku). Obie zakładki są stronicowane (20 zgłoszeń na stronę),
+niezależnie od siebie — przełączenie zakładki nie cofa Cię na pierwszą stronę
+drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z
+dowolnego miejsca w aplikacji (tylko Twoich zgłoszeń).
Otwórz dowolne zgłoszenie, by zobaczyć:
diff --git a/wiki/operator/README.md b/wiki/operator/README.md
index e54af4e..0a14942 100644
--- a/wiki/operator/README.md
+++ b/wiki/operator/README.md
@@ -4,7 +4,8 @@ Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd,
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ę.
+panelu Operatora suwakiem Klient/Operator/Administrator na środku górnego
+paska, jeśli konto ma tę rolę.
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
Twoje **nieprzeczytane** powiadomienia, aktualizowane **na żywo** w chwili ich
utworzenia (niezależny od tego 30-sekundowy fallback dogrywa to, co ominęłoby
@@ -31,7 +32,20 @@ Panel główny (`/operator`) pokazuje listę zgłoszeń z zakładkami po lewej s
widzi wszystkie zespoły; też bez zamkniętych).
Nie-administratorzy widzą tylko zgłoszenia swoich zespołów, zgłoszenia bez
-przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich.
+przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich. Lista
+jest stronicowana (50 zgłoszeń na stronę) — zmiana filtra/wyszukiwania/
+zakładki wraca automatycznie na pierwszą stronę.
+
+Na pasku bocznym, nad zakładkami: przycisk **„Szukaj”** otwiera globalną
+wyszukiwarkę zgłoszeń (dostępną z każdej strony też pod **Ctrl+K**/**Cmd+K**)
+— oprócz zwykłego szukania po treści rozumie też operatory `od:`, `temat:`,
+`treść:` i `numer:` (np. `od:kowalski temat:drukarka`), które można łączyć.
+Niżej — **„Ostatnio przeglądane”**: ostatnie 6 zgłoszeń, które faktycznie
+otworzyłeś, najnowsze na górze.
+
+**Klienci** (przycisk nad tabelą) — osobna wyszukiwarka po imieniu/nazwisku/
+e-mailu dowolnego konta (nie tylko klientów), pokazuje liczbę jego zgłoszeń i
+prowadzi od razu do kolejki przefiltrowanej do tego klienta.
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie