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 225cb9124e
57 changed files with 2029 additions and 162 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/mysql/
.env
compose.yaml
scripts/hesk-import/.env

View File

@@ -167,6 +167,16 @@ the account used for first login after a fresh install (see
`SyncUserFieldsFromLdap` keeps `UserFieldValue` rows in sync with directory
attributes.
`app/Ldap/` has two directory-schema models — `LldapUser` (LLDAP/OpenLDAP,
the default) and `AdUser` (Active Directory, `LdapRecord\Models\ActiveDirectory\User`
under the hood). `Settings::ldapUserModelClass()` picks between them based on
the `ldap_directory_type` setting, and `AppServiceProvider::applyLdapSettingsOverride()`
wires the chosen class into `config('auth.providers.users.model')` on every
request — same live-override mechanism as the connection host/base DN below.
`LdapUserProvisioner` (used for sync + guest auto-provisioning) resolves the
same setting at call time rather than caching the class, so switching
directory type takes effect without a redeploy.
## Settings override ("live config")
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader

View File

@@ -3,6 +3,66 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.4.0] - 2026-08-05
### Added
- **Active Directory support for LDAP auth** (Admin > Integracje > "LDAP /
Active Directory") — a "Typ katalogu" dropdown switches between LLDAP/
OpenLDAP (the original, still the default) and Active Directory. AD uses a
different schema (no inetOrgPerson/posixAccount, a binary `objectGUID`
instead of `entryUUID`) and login attribute (`sAMAccountName`, not `uid`);
both are now auto-detected from the directory type instead of requiring
LLDAP's schema everywhere.
- **Operator client search** (Operator > Klienci) — a dedicated search page
(name or e-mail, any account, not just role=client) showing each match's
role badges and ticket count, linking straight into the queue pre-filtered
to that customer.
- **Paginated ticket lists** — the operator queue (50/page) and client
dashboard (20/page, current/archive tracked as separate pages so switching
tabs doesn't lose your place) no longer render every matching ticket at
once; sorting still happens over the full filtered result first.
- **Command-palette global search (Ctrl+K / Cmd+K)** — searches tickets from
anywhere in the app, scoped to what the searching user can actually see
(operators/admins search everything visible to them, clients only their
own). Supports Gmail-style operators — `od:` (reporter), `temat:`
(subject only), `treść:`/`tresc:` (message content only), `numer:`/`nr:`
(ticket number), combinable and AND'd together (`od:kacper
temat:drukarka`) — plain text with no operator still searches everything
as before. A "Szukaj" button in the operator/admin sidebars opens the same
dialog for anyone who doesn't know the shortcut.
- **Recently viewed tickets** (operator sidebar) — the last 6 tickets an
operator actually opened, most-recent first, re-bumped (not duplicated) on
a repeat visit.
- **Navbar redesign: panel switcher.** The old dropdown-only role switcher
(in the profile menu) and the plain "Panel Klienta/Operatora/Administratora"
text label are replaced by a single segmented control centered in the top
bar — Klient / Operator / Administrator — showing only the areas the
logged-in user actually holds, highlighting the current one, and sliding a
preview to whichever option is hovered before you click. Adapts to a
full-width row below the icons on narrow screens instead of overlapping
them.
- **Richer profile dropdown** — now shows the account's name, e-mail, and
role badges above the existing Powiadomienia/Wyloguj się links.
- **Per-page browser tab titles** — every page sets its own `<title>`
(e.g. the ticket subject, the selected queue, the active admin tab)
instead of every tab just showing the company name, always anchored with
the company name as a suffix so it's still identifiable once the browser
truncates a long tab title.
- **Hesk 3.x historical import** (`scripts/hesk-import/`) — a one-time,
read-only migration of tickets (with full reply/note history) from an old
Hesk helpdesk database, filtered by e-mail domain. Dry-run by default,
resumable, auto-maps categories (exact name match) and teams (when
unambiguous). See `scripts/hesk-import/README.md`.
### Fixed
- Pagination controls ("« Poprzednia" / "Następna »") were showing the raw
translation keys `pagination.previous`/`pagination.next` instead of
actual text — the app's `APP_LOCALE=pl` had no matching `lang/pl/`
translation file and no English fallback (`APP_FALLBACK_LOCALE` is also
`pl`), so Laravel had nothing to resolve those strings to.
## [1.3.0] - 2026-07-27
### Added

View File

@@ -21,7 +21,12 @@ roles). Treat the running database as production, not a sandbox:
## Container operations: use `sudo`, never build the image locally
All Docker commands against this stack need `sudo` (e.g.
`sudo docker compose exec servicedesk ...`, `sudo docker exec servicedesk-servicedesk-1 ...`).
`sudo docker compose exec app ...`, `sudo docker exec servicedesk-app-1 ...`).
The stack is four services sharing the one `servicedesk` image — `app` (Apache,
what actually serves HTTP), `reverb` (websocket server, `php artisan
reverb:start`), `cron` (scheduler loop, `php artisan schedule:work` — see
below), and `mariadb`. Only `app` and `reverb` are reachable from Traefik.
**Never run `docker build`, `docker compose build`, or `--build`.** The
`servicedesk` image is built by CI (`.gitea/workflows/build.yml`, triggered on
@@ -29,7 +34,7 @@ All Docker commands against this stack need `sudo` (e.g.
`compose.yaml` only ever `pull`s a tag (`sudo docker compose pull && sudo docker
compose up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would
just diverge from what CI produces. The app container
(`servicedesk-servicedesk-1`) mounts `./src` from the host over NFS
(`servicedesk-app-1`) mounts `./src` from the host over NFS
(`/mnt/rabbit-containers` → NFS export), so plain file edits already take effect
with no rebuild or restart:
@@ -56,33 +61,32 @@ with no rebuild or restart:
surfaces in production as a 500 with `touch(): Utime failed: Operation not
permitted`. If you ran `php artisan test`/`tinker`/any artisan command via
`docker exec` in a session where you also edited Blade files afterward,
finish with `sudo docker exec servicedesk-servicedesk-1 php artisan
finish with `sudo docker exec servicedesk-app-1 php artisan
view:clear` to flush any root-owned compiled views before ending the
session — don't wait for a report of a broken page to catch it.
## Scheduled commands need a host crontab entry
## Scheduled commands run in the dedicated `cron` container
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)),
so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`,
and `ai:run-ticket-automation` (all registered in `routes/console.php` via
`Schedule::command(...)`) only ever run if something outside the container
calls `php artisan schedule:run` on a timer. **As of 2026-07-23 this is
configured** — root's crontab on the host runs, every minute:
`Schedule::command(...)`) only ever run if something calls `php artisan
schedule:run` on a timer. **As of 2026-08-04 this is the `cron` service** in
`compose.yaml` — same `servicedesk` image, running `php artisan schedule:work`
(Laravel's own foreground scheduler loop, ticks every minute internally, no
external trigger needed). Before this it was a root crontab entry on the host
calling `docker compose exec -T servicedesk schedule:run`; that entry has been
removed from `sudo crontab -l -u root` now that the container replaces it —
don't re-add it, the two would double-run every scheduled command.
```cron
* * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
```
(`sudo crontab -l -u root` to inspect/edit — it previously did not exist at all,
which meant none of the four scheduled commands above had ever run
automatically; ask before changing this again, since removing it silently
breaks SLA checks, automation rules, IMAP fetching and AI ticket automation,
and confusingly not the IMAP feature alone if you're only debugging that one.)
IMAP-specific activity (connect attempts, per-message accept/reject decisions,
created/replied ticket ids) is logged separately from the app's normal
`LOG_LEVEL` to `storage/logs/imap-*.log` (see the `imap` channel in
`config/logging.php`) — check there first when a mailbox isn't behaving as
expected, before assuming the scheduler itself isn't firing.
If the `cron` container isn't running (`sudo docker compose ps cron`), none of
the four scheduled commands fire — same failure mode as the old missing-crontab
case, just check the container instead of the crontab. IMAP-specific activity
(connect attempts, per-message accept/reject decisions, created/replied ticket
ids) is logged separately from the app's normal `LOG_LEVEL` to
`storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) —
check there first when a mailbox isn't behaving as expected, before assuming
the scheduler itself isn't firing.
All four commands' intervals are admin-configurable (Admin > Konfiguracja —
`schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/

View File

@@ -16,11 +16,11 @@ build through a throwaway `node:22` container as documented there.
1. **Run the test suite** — see [TESTING.md](TESTING.md) for details:
```bash
docker compose exec servicedesk php artisan test
docker compose exec app php artisan test
```
2. **Run Pint** (Laravel's code-style fixer, default preset, no project overrides):
```bash
docker compose exec servicedesk ./vendor/bin/pint
docker compose exec app ./vendor/bin/pint
```
3. If you changed anything under `resources/`, rebuild the frontend bundle and
commit the result if `public/build/` is tracked, or confirm the deploy step

View File

@@ -23,9 +23,22 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
## Feature overview
- **Navigation** — a segmented Klient/Operator/Administrator switcher in the
top bar (only the areas a user actually holds, current one highlighted,
hover-previews the destination before you click) replaces the old dropdown
role switcher; the profile menu shows name/e-mail/role badges above
Powiadomienia/Wyloguj się; every page sets its own browser-tab title
(ticket subject, selected queue, active admin tab, ...) anchored with the
company name; and a command-palette global search (Ctrl+K/Cmd+K, or a
"Szukaj" sidebar button) finds tickets from anywhere, scoped to what the
searching user can see, with Gmail-style `od:`/`temat:`/`treść:`/`numer:`
operators. The operator sidebar also lists the last 6 tickets they
actually opened ("Ostatnio przeglądane").
- **Tickets** — number, subject, body, category/subcategory, status, priority, team,
assignee, custom fields (per subcategory), attachments, full message thread
(public replies + internal notes), history log, merge, delete.
(public replies + internal notes), history log, merge, delete. The operator
queue (50/page) and client dashboard (20/page, current/archive tracked
separately) paginate rather than rendering every matching ticket at once.
- **SLA** — per-priority response/resolution time targets; a scheduled command
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
the assigned operator.
@@ -53,6 +66,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
team's queue (plus unrouted tickets and anything assigned to them) unless they're
an admin. Reassigning a ticket to a team, though, is unrestricted — an operator
can route a ticket to any team, not just one they belong to.
- **Client search** (Operator > Klienci) — find any account by name or e-mail
(not just role=client — a ticket's customer can be any user), see its role
badges and ticket count, and jump straight into the queue pre-filtered to
that customer.
- **Templates** — canned response snippets for the reply box, admin-configurable
"quick actions" (send + transition status in one click), and HTML e-mail
templates for every ticket lifecycle event (created, status/priority/category/
@@ -69,9 +86,12 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
sync + BookStack (Admin > Integracje).
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
- **LDAP auth** — logins bind against a directory (`config/auth.php`,
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
to e-mail + local password when the LDAP bind doesn't match.
to e-mail + local password when the LDAP bind doesn't match. A "Typ katalogu"
toggle (Admin > Integracje) switches between LLDAP/OpenLDAP (default) and
Active Directory, which auto-selects the right schema/login attribute
(`sAMAccountName` + `objectGUID` for AD, vs. `uid` + `entryUUID`).
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
immediately on a ticket lifecycle event (created, any field updated, status/
priority/assignee/team/category changed, new public reply): AND-combined
@@ -201,11 +221,13 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
build step beyond the CSS bundle.
- **Database**: MariaDB.
- **Deployment**: `compose.yaml``servicedesk` (source bind-mounted from `./src`,
- **Deployment**: `compose.yaml``app` (source bind-mounted from `./src`,
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA
TLS cert (the websocket path is routed to `reverb` by a higher-priority
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image
(same image, `php artisan reverb:start`) + `cron` (same image, `php artisan
schedule:work` — runs the scheduled commands below without needing a host
crontab), fronted by Traefik with a private-CA TLS cert (the websocket path
is routed to `reverb` by a higher-priority Traefik rule; everything else
goes to `app`). The `servicedesk` image
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
just pulls a tag, it never builds locally.
@@ -227,7 +249,7 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
```
- Fresh install / reset:
```bash
sudo docker exec servicedesk-servicedesk-1 php artisan migrate:fresh --seed
sudo docker exec servicedesk-app-1 php artisan migrate:fresh --seed
```
Seeds real reference data (categories, custom fields, statuses/priorities/SLA,
teams, quick actions, response/e-mail templates, branding/config with example
@@ -243,12 +265,14 @@ src/ Laravel application
app/Models/ Eloquent models
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch,
AI ticket triage/summary) + bookstack:tag-content
AI ticket triage/summary) + bookstack:tag-content + hesk:import
(one-time historical migration, see scripts/hesk-import/)
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic),
AiClient (generic LLM client), BookStackContentTagger,
TicketAiTriageService, TicketAiSummaryService, SnipeItClient
app/Ldap/ LDAP user model + sync handlers
app/Ldap/ LDAP user models — LldapUser (LLDAP/OpenLDAP, default) and
AdUser (Active Directory) — plus sync handlers
database/migrations/ Schema (one file per table group, final shape)
database/seeders/ DatabaseSeeder — reference data, no ticket data
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
@@ -261,4 +285,6 @@ wiki/
client/ How-to guide for the Client role
operator/ How-to guide for the Operator role
admin/ How-to guide for the Admin role
scripts/
hesk-import/ One-time Hesk 3.x ticket history import — see its own README.md
```

View File

@@ -13,20 +13,20 @@ fast in-process fakes (`array`/`sync`) for the same reason.
From inside the app container (or on a bare-metal install, from `src/`):
```bash
docker compose exec servicedesk php artisan test
docker compose exec app php artisan test
```
or directly with Pest:
```bash
docker compose exec servicedesk ./vendor/bin/pest
docker compose exec app ./vendor/bin/pest
```
Run a single file or filter by name:
```bash
docker compose exec servicedesk php artisan test --filter=SlaBreachNotificationTest
docker compose exec servicedesk ./vendor/bin/pest tests/Feature/TicketApiTest.php
docker compose exec app php artisan test --filter=SlaBreachNotificationTest
docker compose exec app ./vendor/bin/pest tests/Feature/TicketApiTest.php
```
There is no CI pipeline configured for this repository — running the suite
@@ -60,5 +60,5 @@ automatically without extra boilerplate.
on Laravel's default preset). Run it before committing:
```bash
docker compose exec servicedesk ./vendor/bin/pint
docker compose exec app ./vendor/bin/pint
```

View File

@@ -24,7 +24,7 @@ osobne pliki, w dwóch różnych miejscach.
- Docker + wtyczka `docker compose`.
- Zewnętrzna sieć Docker `traefik_public`, jeśli używasz Traefika tak jak w
`compose.yaml` (`docker network create traefik_public`, jeśli jeszcze nie
istnieje). Bez Traefika trzeba samodzielnie zmapować porty serwisu `servicedesk`
istnieje). Bez Traefika trzeba samodzielnie zmapować porty serwisu `app`
na hosta (`ports: ["8080:80"]`) i obsłużyć TLS inaczej (patrz sekcja 2 niżej, w
razie potrzeby reverse-proxy przed kontenerem).
@@ -90,8 +90,8 @@ QUEUE_CONNECTION=database
`APP_KEY` wygenerujesz komendą artisan (krok 1.4) — zostaw puste w pliku.
`LDAP_*` i `MAIL_*` w `src/.env` są tylko **wartościami startowymi/awaryjnymi**.
Docelowo LDAP i SMTP konfiguruje się wygodniej z poziomu **Admin > Konfiguracja**
w samej aplikacji (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć
Docelowo LDAP konfiguruje się wygodniej z poziomu **Admin > Integracje**, a SMTP
z **Admin > Poczta** (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć
sensowny fallback zanim ktokolwiek się zaloguje do panelu admina, warto je od razu
uzupełnić:
@@ -184,7 +184,7 @@ w restartach z błędem `Undefined constant "...SIGINT"`; dodaj `pcntl posix` do
listy w `docker-php-ext-install` i poczekaj na przebudowanie obrazu przez CI.
Traefik musi kierować ścieżkę websocketu (`/app*`) do `reverb`, a resztę do
`servicedesk` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
`app` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
```yaml
reverb:
@@ -240,10 +240,10 @@ te wartości są wypiekane w zbudowany bundle JS, nie czytane w runtime.
### 1.4. Instalacja aplikacji wewnątrz kontenera
```bash
docker compose exec servicedesk composer install --no-dev --optimize-autoloader
docker compose exec servicedesk php artisan key:generate
docker compose exec servicedesk php artisan migrate --seed
docker compose exec servicedesk php artisan storage:link
docker compose exec app composer install --no-dev --optimize-autoloader
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seed
docker compose exec app php artisan storage:link
```
`migrate --seed` (bez `--fresh`) na pustej bazie utworzy wszystkie tabele i
@@ -254,7 +254,7 @@ po pierwszym zalogowaniu (Admin > Użytkownicy).
### 1.5. Zbudowanie zasobów front-endowych (CSS/Tailwind)
Ani host, ani kontener `servicedesk` nie mają zainstalowanego Node.js — buduj
Ani host, ani kontener `app` nie mają zainstalowanego Node.js — buduj
przez jednorazowy kontener `node:22` zamiast dorzucać Node do obrazu aplikacji:
```bash
@@ -273,16 +273,35 @@ patrz Admin > Poczta) i `ai:run-ticket-automation` (opcjonalna automatyczna
kategoryzacja/podsumowania AI zgłoszeń — patrz Admin > Integracje) co 5 minut,
ale **obraz Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego
kroku żadne z tych zadań nigdy się nie uruchomi (poczta IMAP nadal da się
sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale bez crona nic nie dzieje się
sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale bez tego nic nie dzieje się
samo). Wszystkie cztery interwały są też konfigurowalne z poziomu **Admin >
Konfiguracja** (bez potrzeby edycji kodu czy restartu — nowa wartość obowiązuje
od najbliższego tyknięcia harmonogramu). Najprościej dodać wpis crona **na
hoście**:
od najbliższego tyknięcia harmonogramu).
```cron
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1
`compose.yaml` rozwiązuje to czwartą usługą, `cron` — tego samego obrazu
`servicedesk`, tylko z innym poleceniem:
```yaml
cron:
image: gitea.kzbikowski.pl/kzbkowski/servicedesk:${IMAGE_TAG:-latest}
command: php artisan schedule:work
volumes:
- ./src:/var/www/html
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
networks:
- internal
```
`schedule:work` to własna, pierwszoplanowa pętla harmonogramu Laravela —
odpowiednik odpalania `schedule:run` co minutę, ale bez potrzeby zewnętrznego
triggera. Ten kontener nie musi być widoczny w Traefiku (nie obsługuje ruchu
HTTP), stąd tylko sieć `internal`. Sprawdź, że działa: `docker compose ps cron`
oraz `docker compose logs -f cron` (loguje każde odpalenie zaplanowanego
zadania).
Powiadomienia e-mail wysyłają się synchronicznie (nie trafiają do kolejki), więc
`php artisan queue:work` nie jest obowiązkowy — `QUEUE_CONNECTION=database` w
`.env` wystarcza jako bezpieczny domyślny driver, gdyby coś w przyszłości zaczęło
@@ -291,8 +310,9 @@ kolejkować zadania.
### ⚠️ Ważne: LDAP/SMTP z panelu Admina nadpisują `.env` w locie
`AppServiceProvider` na starcie żądania sprawdza tabelę `settings` — jeśli w
Admin > Konfiguracja pole **host LDAP** albo **SMTP włączony + host** jest
ustawione, **te wartości wygrywają z `.env`**, bez potrzeby restartu czy redeployu.
Admin > Integracje pole **host LDAP** albo w Admin > Poczta **SMTP włączony +
host** jest ustawione, **te wartości wygrywają z `.env`**, bez potrzeby
restartu czy redeployu.
Po świeżym `migrate --seed` te pola zawierają **przykładowe placeholdery**
(`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że
@@ -301,10 +321,13 @@ adresami**, nawet jeśli w `.env` wpisałeś prawdziwe dane! Zanim oddasz system
użytku:
1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`.
2. Wejdź w **Admin > Konfiguracja** i wpisz prawdziwe dane LDAP/SMTP (albo wyczyść
pole hosta LDAP, żeby wrócić do wartości z `.env`).
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
polegać na logowaniu przez katalog.
2. Wejdź w **Admin > Integracje** i wpisz prawdziwe dane LDAP (wybierz też
właściwy **Typ katalogu** LLDAP/OpenLDAP albo Active Directory — jeśli
katalog to nie LLDAP; albo wyczyść pole hosta LDAP, żeby wrócić do
wartości z `.env`), a w **Admin > Poczta** dane SMTP.
3. Użyj przycisku **„Testuj połączenie”** w Integracje i **„Wyślij testową
wiadomość”** w Poczta, zanim zaczniesz polegać na logowaniu przez katalog
albo na powiadomieniach e-mail.
### Integracje opcjonalne (BookStack, AI)
@@ -328,6 +351,14 @@ automatyczną kategoryzację/podsumowania AI zgłoszeń (Admin > Integracje >
„Automatyzacja AI dla zgłoszeń”, wymaga też wpisu crona z kroku 1.6/2.6
powyżej — to ten sam harmonogram co SLA/automatyzacje/IMAP).
### Import historycznych zgłoszeń z Heska (opcjonalnie)
Jeśli migrujesz z helpdesku Hesk 3.x, `scripts/hesk-import/` zawiera
jednorazowe (nie ciągłe) narzędzie migracyjne — importuje zgłoszenia wraz z
pełną historią odpowiedzi/notatek, ograniczone do jednej domeny e-mail, w
trybie dry-run domyślnie. Nie dotyka bazy Heska poza odczytem. Zobacz
`scripts/hesk-import/README.md` po pełną instrukcję.
---
## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera)
@@ -383,7 +414,7 @@ QUEUE_CONNECTION=database
```
Uzupełnij też `LDAP_*`/`MAIL_*` jak w sekcji 1.2 (to samo ostrzeżenie o
Admin > Konfiguracja nadpisującym te wartości w locie dotyczy tu identycznie).
Admin > Integracje/Poczta nadpisującym te wartości w locie dotyczy tu identycznie).
```bash
php artisan key:generate
@@ -524,9 +555,9 @@ jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
### 2.7. Pierwsze logowanie i dalsza konfiguracja
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
hasło, uzupełnij prawdziwe LDAP/SMTP w Admin > Konfiguracja (placeholdery z seeda
inaczej realnie próbują łączyć się z fałszywymi adresami), przetestuj oba
połączenia przyciskiem „Testuj połączenie”.
hasło, uzupełnij prawdziwe LDAP w Admin > Integracje i SMTP w Admin > Poczta
(placeholdery z seeda inaczej realnie próbują łączyć się z fałszywymi
adresami), przetestuj oba połączenia.
### 2.8. Aktualizacje (bez przestoju)

View File

@@ -0,0 +1,5 @@
HESK_DB_HOST=
HESK_DB_PORT=3306
HESK_DB_DATABASE=
HESK_DB_USERNAME=
HESK_DB_PASSWORD=

View File

@@ -0,0 +1,83 @@
# Hesk 3.x import
One-time historical migration of tickets (with full reply/note history) from a
Hesk 3.x helpdesk database into this app, restricted to a single e-mail
domain. Read-only against the Hesk database — never writes there.
This is **not** an ongoing sync. Run it once to backfill history from an
old Hesk install; it doesn't pick up edits made in Hesk afterward.
## What it does
- Matches Hesk tickets by requester e-mail domain (`--domain=firma.pl`).
- Imports each ticket's subject/body, status, priority, and full reply +
internal-note history, converting Hesk's `<br />`-laden "plain" text into
real line breaks.
- Maps Hesk categories to this app's categories by exact (case/whitespace-
insensitive) name match. A Hesk category with no match is skipped by
default — pass `--include-unmapped-categories` to import those tickets
anyway, uncategorized.
- Auto-assigns a team when every subcategory under the matched category
routes to the same single team (same rule `TicketService::autoAssignTeam()`
uses for normal ticket creation); ambiguous categories are left unrouted.
- Finds or creates a client account per requester e-mail, reusing an existing
account (adding the `client` role if it doesn't have it yet) rather than
duplicating.
- Hesk staff replies are **not** linked to a real operator account (this
script never creates operator accounts) — the reply still shows the
correct staff name and "operator" badge via `author_name`, just without a
clickable user behind it.
## Setup
```bash
cp scripts/hesk-import/.env.example scripts/hesk-import/.env
```
Fill in `scripts/hesk-import/.env` with the Hesk database's
host/port/database/username/password. That file is gitignored — it holds
real credentials for a database this app otherwise has no access to.
## Usage
Always dry-run first — it reports what *would* happen without writing
anything:
```bash
scripts/hesk-import/hesk-import.sh --domain=firma.pl
```
Try a small batch for real before committing to the whole thing:
```bash
scripts/hesk-import/hesk-import.sh --domain=firma.pl --limit=10 --commit
```
Then the full import:
```bash
scripts/hesk-import/hesk-import.sh --domain=firma.pl --commit
```
Safe to re-run (including after an interrupted/crashed run): already-imported
Hesk ticket ids are tracked in `storage/app/hesk-import-state.json` inside the
app container and skipped on subsequent runs.
### Backfilling team assignment
If tickets were already imported before team-by-category mapping existed (or
teams/subcategories changed since), backfill `team_id` on existing imported
tickets without importing anything new:
```bash
scripts/hesk-import/hesk-import.sh --assign-teams --commit
```
## How it's wired up
`hesk-import.sh` is a thin wrapper: it loads `.env` in this folder, then runs
`php artisan hesk:import` inside the `app` container via `docker compose
exec`, passing the Hesk DB credentials as one-off environment variables (they
never touch the app's own `.env` or get persisted anywhere but the state
file). The actual import logic lives in
[`../../src/app/Console/Commands/ImportHeskTickets.php`](../../src/app/Console/Commands/ImportHeskTickets.php).

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Imports tickets from a Hesk 3.x helpdesk database into this app, filtered
# to one e-mail domain. Thin wrapper around `php artisan hesk:import` (see
# ../../src/app/Console/Commands/ImportHeskTickets.php for the actual logic)
# — this script only wires up the Hesk DB credentials and runs it inside the
# app container. See README.md in this folder for full setup/usage docs.
#
# Setup: copy .env.example (this folder) to .env (gitignored) and fill in
# your Hesk database's host/port/database/username/password.
#
# Usage:
# scripts/hesk-import/hesk-import.sh --domain=firma.pl # dry run (default, writes nothing)
# scripts/hesk-import/hesk-import.sh --domain=firma.pl --limit=10 --commit # real run, first 10 tickets only
# scripts/hesk-import/hesk-import.sh --domain=firma.pl --commit # real run, everything
#
# Safe to re-run: already-imported Hesk tickets are tracked in
# storage/app/hesk-import-state.json inside the app container and skipped.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Brak $ENV_FILE — skopiuj scripts/hesk-import/.env.example i uzupełnij dane dostępowe do bazy Heska." >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${HESK_DB_HOST:?ustaw HESK_DB_HOST w $ENV_FILE}"
: "${HESK_DB_DATABASE:?ustaw HESK_DB_DATABASE w $ENV_FILE}"
: "${HESK_DB_USERNAME:?ustaw HESK_DB_USERNAME w $ENV_FILE}"
HESK_DB_PORT="${HESK_DB_PORT:-3306}"
cd "$REPO_ROOT"
exec sudo docker compose exec \
-e HESK_DB_HOST="$HESK_DB_HOST" \
-e HESK_DB_PORT="$HESK_DB_PORT" \
-e HESK_DB_DATABASE="$HESK_DB_DATABASE" \
-e HESK_DB_USERNAME="$HESK_DB_USERNAME" \
-e HESK_DB_PASSWORD="$HESK_DB_PASSWORD" \
app php artisan hesk:import "$@"

View File

@@ -5,7 +5,7 @@ APP_DEBUG=false
APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.3.0
VERSION=1.4.0
APP_LOCALE=en
APP_FALLBACK_LOCALE=en

View File

@@ -0,0 +1,504 @@
<?php
namespace App\Console\Commands;
use App\Models\Category;
use App\Models\Role;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use PDO;
/**
* One-off migration from a Hesk 3.x helpdesk database into this app's own
* tickets/users. Never writes to the Hesk database read-only there.
*
* Runs in dry-run mode by default (reports what it would do); pass --commit
* to actually write. Resumable: every successfully imported Hesk ticket id
* is recorded in a local state file (--state, defaults to
* storage/app/hesk-import-state.json), so a re-run (interrupted connection,
* crashed midway, etc.) skips tickets already imported instead of
* duplicating them. Not idempotent across *edits* on the Hesk side this is
* a one-time historical import, not an ongoing sync.
*/
class ImportHeskTickets extends Command
{
protected $signature = 'hesk:import
{--domain= : Only import tickets whose requester e-mail ends in @this-domain}
{--commit : Actually write to the database (default is a dry run)}
{--limit= : Only process this many Hesk tickets (after the domain filter), useful for a test run}
{--state= : Path to the resume-state JSON file (default storage/app/hesk-import-state.json)}
{--include-unmapped-categories : Also import tickets whose Hesk category has no matching servicedesk category (default: skip them)}
{--assign-teams : Backfill team_id (by category) on already-imported tickets that don\'t have one yet, then exit — does not import anything}';
protected $description = 'Import tickets (with full reply/note history) from a Hesk 3.x database, restricted to one e-mail domain';
/**
* Hesk ticket.status -> our status_key. Hesk's built-in codes are
* 0=New, 1=Waiting reply (for staff), 2=Replied (waiting on customer),
* 3=Resolved. Any other value (seen: a handful of "5" rows, presumably
* a since-deleted custom status) falls back to 'open'.
*/
private const STATUS_MAP = [
0 => 'new',
1 => 'waiting_operator',
2 => 'waiting_customer',
3 => 'closed',
];
/**
* Hesk ticket.priority -> our priority_key. Hesk's enum is 1=Critical,
* 2=High, 3=Medium (the near-universal default every Hesk category
* here defaults new tickets to priority 3); 0 is an unused/rare edge
* value, treated as the closest thing Hesk has to "Low".
*/
private const PRIORITY_MAP = [
0 => 'low',
1 => 'critical',
2 => 'high',
3 => 'medium',
];
private array $categoryMap = [];
/** @var array<int, int> servicedesk category id => servicedesk team id, only when unambiguous */
private array $teamByCategory = [];
/** @var array<int, string> Hesk help_users.id => name, loaded once */
private array $heskStaffNames = [];
private array $state = ['imported' => []];
private string $statePath;
public function handle(): int
{
if ($this->option('assign-teams')) {
return $this->runAssignTeams((bool) $this->option('commit'));
}
$domain = trim((string) $this->option('domain'), " \t\n\r\0\x0B@");
if ($domain === '') {
$this->error('Podaj --domain=twoja-domena.pl (bez @).');
return self::FAILURE;
}
$commit = (bool) $this->option('commit');
$limit = $this->option('limit') !== null ? (int) $this->option('limit') : null;
$includeUnmapped = (bool) $this->option('include-unmapped-categories');
$this->statePath = $this->option('state') ?: storage_path('app/hesk-import-state.json');
if (! $this->configureHeskConnection()) {
return self::FAILURE;
}
$this->loadState();
$this->buildCategoryMap();
$this->buildTeamMap();
$this->loadHeskStaffNames();
$tickets = DB::connection('hesk')->table('help_tickets')
->where('email', 'like', '%@'.$domain)
->orderBy('id')
->when($limit, fn ($q) => $q->limit($limit))
->get();
$this->info(sprintf(
'%s tryb: %d zgłoszeń z Heska pasuje do domeny @%s (%d już zaimportowanych wcześniej, zostaną pominięte).',
$commit ? 'KOMMIT' : 'DRY-RUN',
$tickets->count(),
$domain,
$tickets->whereIn('id', $this->state['imported'])->count(),
));
$stats = ['created' => 0, 'skipped' => 0, 'skipped_unmapped_category' => 0, 'failed' => 0, 'messages' => 0, 'customers_created' => 0];
$unmappedCategories = [];
$bar = $this->output->createProgressBar($tickets->count());
$bar->start();
foreach ($tickets as $heskTicket) {
$bar->advance();
if (in_array($heskTicket->id, $this->state['imported'], true)) {
$stats['skipped']++;
continue;
}
$categoryMapped = array_key_exists($heskTicket->category, $this->categoryMap);
if (! $categoryMapped && ! in_array($heskTicket->category, $unmappedCategories, true)) {
$unmappedCategories[] = $heskTicket->category;
}
// Default: a Hesk category with no servicedesk equivalent means
// this ticket is skipped entirely rather than imported without
// a category — --include-unmapped-categories opts back in.
if (! $categoryMapped && ! $includeUnmapped) {
$stats['skipped_unmapped_category']++;
continue;
}
if (! $commit) {
$stats['created']++;
continue;
}
try {
DB::transaction(function () use ($heskTicket, &$stats) {
$this->importOneTicket($heskTicket, $stats);
});
$this->state['imported'][] = $heskTicket->id;
$this->saveState();
} catch (\Throwable $e) {
$stats['failed']++;
$this->newLine();
$this->error("Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: ".$e->getMessage());
}
}
$bar->finish();
$this->newLine(2);
$this->table(['Miara', 'Wartość'], [
['Zgłoszenia utworzone', $stats['created']],
['Wiadomości/notatki utworzone', $stats['messages']],
['Nowe konta klientów', $stats['customers_created']],
['Pominięte (już zaimportowane)', $stats['skipped']],
['Pominięte (kategoria bez odpowiednika)', $stats['skipped_unmapped_category']],
['Błędy', $stats['failed']],
]);
if ($unmappedCategories) {
$names = collect($unmappedCategories)
->map(fn ($id) => DB::connection('hesk')->table('help_categories')->where('id', $id)->value('name') ?? "id={$id}")
->implode(', ');
$action = $includeUnmapped ? 'zgłoszenia zaimportowane bez kategorii' : 'zgłoszenia POMINIĘTE — użyj --include-unmapped-categories, żeby jednak je zaimportować bez kategorii';
$this->warn("Kategorie Heska bez odpowiednika w servicedesk ({$action}): {$names}");
}
if (! $commit) {
$this->newLine();
$this->comment('To był dry-run — nic nie zostało zapisane. Uruchom ponownie z --commit, żeby faktycznie zaimportować.');
}
return self::SUCCESS;
}
private function configureHeskConnection(): bool
{
$host = env('HESK_DB_HOST');
$port = env('HESK_DB_PORT', 3306);
$database = env('HESK_DB_DATABASE');
$username = env('HESK_DB_USERNAME');
$password = env('HESK_DB_PASSWORD');
if (! $host || ! $database || ! $username) {
$this->error('Brakuje HESK_DB_HOST / HESK_DB_DATABASE / HESK_DB_USERNAME w środowisku (patrz scripts/hesk-import/hesk-import.sh).');
return false;
}
Config::set('database.connections.hesk', [
'driver' => 'mysql',
'host' => $host,
'port' => $port,
'database' => $database,
'username' => $username,
'password' => $password,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'options' => [PDO::ATTR_TIMEOUT => 10],
]);
try {
DB::connection('hesk')->getPdo();
} catch (\Throwable $e) {
$this->error('Nie udało się połączyć z bazą Heska: '.$e->getMessage());
return false;
}
return true;
}
/**
* Hesk category name (normalized) -> servicedesk category id. Only
* exact (case/whitespace-insensitive) name matches are mapped; anything
* else is left uncategorized and reported at the end instead of guessed at.
*/
private function buildCategoryMap(): void
{
$ours = Category::query()->get()->keyBy(fn (Category $c) => $this->normalizeCategoryName($c->name));
foreach (DB::connection('hesk')->table('help_categories')->get() as $heskCategory) {
$match = $ours->get($this->normalizeCategoryName($heskCategory->name));
if ($match) {
$this->categoryMap[$heskCategory->id] = $match->id;
}
}
}
private function normalizeCategoryName(string $name): string
{
return mb_strtolower(trim($name));
}
/**
* servicedesk category id -> servicedesk team id, only when every
* subcategory under that category belongs to the same single team (the
* existing team_subcategory routing see TicketService::autoAssignTeam()
* for the same rule applied to normal in-app ticket creation, there scoped
* to a specific subcategory rather than a whole category). A category
* whose subcategories are split across more than one team is left
* unmapped rather than guessed at.
*/
private function buildTeamMap(): void
{
foreach (Category::query()->pluck('id') as $categoryId) {
$teamIds = Team::query()
->whereHas('subcategories', fn ($q) => $q->where('subcategories.category_id', $categoryId))
->pluck('id')
->unique();
if ($teamIds->count() === 1) {
$this->teamByCategory[$categoryId] = $teamIds->first();
}
}
}
/**
* Backfill mode (--assign-teams): sets team_id on tickets that already
* exist (from an earlier --commit run, before team assignment was added)
* and don't have one yet — doesn't import anything, doesn't touch the
* Hesk database at all.
*/
private function runAssignTeams(bool $commit): int
{
$this->buildTeamMap();
if (! $this->teamByCategory) {
$this->warn('Żadna kategoria nie ma jednoznacznie przypisanego zespołu (na podstawie podkategorii) — nie ma czego przypisać.');
return self::SUCCESS;
}
$totalUpdated = 0;
foreach ($this->teamByCategory as $categoryId => $teamId) {
$query = Ticket::query()->where('category_id', $categoryId)->whereNull('team_id');
$count = $query->count();
if ($count === 0) {
continue;
}
$this->line(sprintf(
'Kategoria "%s" -> zespół "%s": %d zgłoszeń%s',
Category::query()->find($categoryId)?->name ?? "id={$categoryId}",
Team::query()->find($teamId)?->name ?? "id={$teamId}",
$count,
$commit ? '' : ' (dry-run)',
));
if ($commit) {
$query->update(['team_id' => $teamId]);
}
$totalUpdated += $count;
}
$this->newLine();
$this->info(($commit ? 'Zaktualizowano' : 'Do zaktualizowania').': '.$totalUpdated.' zgłoszeń.');
if (! $commit) {
$this->comment('To był dry-run — uruchom ponownie z --assign-teams --commit, żeby faktycznie zapisać.');
}
return self::SUCCESS;
}
private function loadHeskStaffNames(): void
{
$this->heskStaffNames = DB::connection('hesk')->table('help_users')->pluck('name', 'id')->all();
}
private function importOneTicket(object $heskTicket, array &$stats): void
{
$customer = $this->resolveCustomer($heskTicket->email, $heskTicket->name, $stats);
$categoryId = $this->categoryMap[$heskTicket->category] ?? null;
$ticket = Ticket::query()->create([
'number' => Ticket::nextNumber(),
'customer_id' => $customer->id,
'email' => $heskTicket->email,
'name' => $heskTicket->name ?: $heskTicket->email,
'category_id' => $categoryId,
'team_id' => $categoryId ? ($this->teamByCategory[$categoryId] ?? null) : null,
'subject' => $this->cleanText($heskTicket->subject) ?: '(bez tematu)',
'body' => $this->cleanText($heskTicket->message),
'status_key' => self::STATUS_MAP[(int) $heskTicket->status] ?? 'open',
'priority_key' => self::PRIORITY_MAP[(int) $heskTicket->priority] ?? 'medium',
'source' => 'hesk_import',
'last_customer_activity_at' => $heskTicket->lastchange,
'created_at' => $heskTicket->dt,
'updated_at' => $heskTicket->lastchange,
]);
// Ticket::booted() re-saves the row right after create() to stamp a
// checksum, which — being a normal Eloquent save() — stomps
// updated_at back to "now". Restore the historical value via the
// query builder so it bypasses Eloquent's timestamp handling.
DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $heskTicket->lastchange]);
$opening = $ticket->messages()->create([
'author_name' => $heskTicket->name ?: $heskTicket->email,
'body' => $this->cleanText($heskTicket->message),
'created_at' => $heskTicket->dt,
'updated_at' => $heskTicket->dt,
]);
$opening->attachAuthor($customer->id, 'client');
$stats['messages']++;
foreach ($this->heskReplies($heskTicket->id) as $reply) {
$this->importReply($ticket, $reply, $customer, $stats);
}
foreach ($this->heskNotes($heskTicket->id) as $note) {
$this->importNote($ticket, $note, $stats);
}
$stats['created']++;
}
private function heskReplies(int $heskTicketId): Collection
{
return DB::connection('hesk')->table('help_replies')
->where('replyto', $heskTicketId)
->orderBy('dt')
->get();
}
private function heskNotes(int $heskTicketId): Collection
{
return DB::connection('hesk')->table('help_notes')
->where('ticket', $heskTicketId)
->orderBy('dt')
->get();
}
private function importReply(Ticket $ticket, object $reply, User $customer, array &$stats): void
{
$isStaff = (int) $reply->staffid > 0;
$authorName = $isStaff
? ($this->heskStaffNames[$reply->staffid] ?? 'Personel')
: ($reply->name ?: $customer->name);
$message = $ticket->messages()->create([
'author_name' => $authorName,
'body' => $this->cleanText($reply->message),
'created_at' => $reply->dt,
'updated_at' => $reply->dt,
]);
// Staff replies aren't linked to a real User (we deliberately don't
// create operator accounts for imported Hesk staff — see the
// migration script's design questions) — attachAuthor(null,
// 'operator') still tags the role/badge correctly via author_name.
$message->attachAuthor($isStaff ? null : $customer->id, $isStaff ? 'operator' : 'client');
$stats['messages']++;
}
private function importNote(Ticket $ticket, object $note, array &$stats): void
{
$message = $ticket->messages()->create([
'author_name' => $this->heskStaffNames[$note->who] ?? 'Personel',
'internal' => true,
'body' => $this->cleanText($note->message),
'created_at' => $note->dt,
'updated_at' => $note->dt,
]);
$message->attachAuthor(null, 'operator');
$stats['messages']++;
}
/**
* Finds or creates the local client account for a Hesk requester e-mail.
* Reuses an existing account (e.g. the one real admin account, or one
* already created by an earlier ticket from the same person) rather than
* duplicating, and only ever adds the 'client' role never removes
* whatever roles the account already had.
*/
private function resolveCustomer(string $email, ?string $name, array &$stats): User
{
$email = trim($email);
$user = User::query()->where('email', $email)->first();
if (! $user) {
$user = User::query()->create([
'name' => $name ?: $email,
'email' => $email,
'roles' => ['client'],
]);
$stats['customers_created']++;
return $user;
}
if (! in_array('client', $user->roles, true)) {
$user->roles = [...$user->roles, 'client'];
$user->save();
}
return $user;
}
/**
* Hesk's "plain" message/subject columns still carry <br /> tags (and
* occasionally other inline HTML) from HTML-formatted source e-mails
* this app renders ticket/message bodies as escaped plain text
* (white-space:pre-wrap), so raw tags would show up literally instead
* of as line breaks.
*/
private function cleanText(?string $value): string
{
if ($value === null || $value === '') {
return '';
}
$text = preg_replace('/<br\s*\/?>/i', "\n", $value);
$text = strip_tags($text);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($text);
}
private function loadState(): void
{
if (is_file($this->statePath)) {
$decoded = json_decode(file_get_contents($this->statePath), true);
$this->state = is_array($decoded) ? $decoded : $this->state;
$this->state['imported'] ??= [];
}
}
private function saveState(): void
{
$dir = dirname($this->statePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($this->statePath, json_encode($this->state));
}
}

18
src/app/Ldap/AdUser.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Ldap;
use LdapRecord\Models\ActiveDirectory\User as ActiveDirectoryUser;
/**
* Active Directory counterpart to LldapUser same role (the LdapRecord
* model backing the 'users' auth provider), but for AD's schema instead of
* LLDAP/OpenLDAP's. AD user objects carry objectClass top/person/
* organizationalPerson/user (no inetOrgPerson/posixAccount/mailAccount, so
* LldapUser's object-class scope matches zero AD entries) and expose a
* binary objectGUID rather than entryUUID both already handled correctly
* by LdapRecord's stock ActiveDirectory\User, so no overrides are needed
* here, only the swap in AppServiceProvider::applyLdapSettingsOverride()
* (driven by the ldap_directory_type setting).
*/
class AdUser extends ActiveDirectoryUser {}

View File

@@ -219,6 +219,7 @@ class Panel extends Component
$this->ldapConfig = [
'enabled' => Settings::bool('ldap_enabled'),
'directoryType' => Settings::get('ldap_directory_type', 'lldap'),
'host' => Settings::get('ldap_host'),
'port' => Settings::get('ldap_port'),
'baseDn' => Settings::get('ldap_base_dn'),
@@ -1498,6 +1499,7 @@ class Panel extends Component
public function saveLdapConfig(): void
{
Settings::set('ldap_enabled', $this->ldapConfig['enabled'] ? '1' : '0');
Settings::set('ldap_directory_type', $this->ldapConfig['directoryType'] === 'ad' ? 'ad' : 'lldap');
Settings::set('ldap_host', $this->ldapConfig['host']);
Settings::set('ldap_port', (string) $this->ldapConfig['port']);
Settings::set('ldap_base_dn', $this->ldapConfig['baseDn']);
@@ -1814,8 +1816,37 @@ class Panel extends Component
$this->cancelPendingDelete();
}
/**
* Mirrors the labels in the $tabGroups array built inline in
* admin/panel.blade.php (icons/grouping live only there this is just
* the page-title-sized subset, not worth threading the whole structure
* through the PHP side for).
*/
private const TAB_LABELS = [
'categories' => 'Kategorie',
'fields' => 'Pola dodatkowe',
'statuses' => 'Statusy',
'priorities' => 'Priorytety i SLA',
'reply-quick-actions' => 'Szybkie akcje odpowiedzi',
'response-templates' => 'Szablony odpowiedzi',
'automation-rules' => 'Automatyzacja SLA',
'triggers' => 'Wyzwalacze',
'users' => 'Użytkownicy',
'teams' => 'Zespoły',
'user-fields' => 'Pola dodatkowe',
'templates' => 'Szablony e-mail',
'email' => 'Poczta',
'branding' => 'Wygląd i branding',
'config' => 'Konfiguracja',
'integrations' => 'Integracje',
'api-keys' => 'Klucze API',
'about' => 'O aplikacji',
];
public function render()
{
return view('livewire.admin.panel');
$tabLabel = self::TAB_LABELS[$this->tab] ?? 'Panel administratora';
return view('livewire.admin.panel')->title(Settings::pageTitle($tabLabel));
}
}

View File

@@ -75,6 +75,6 @@ class Login extends Component
public function render()
{
return view('livewire.auth.login');
return view('livewire.auth.login')->title(Settings::pageTitle('Logowanie'));
}
}

View File

@@ -3,36 +3,52 @@
namespace App\Livewire\Client;
use App\Models\Status;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
class Dashboard extends Component
{
use WithPagination;
private const PER_PAGE = 20;
public string $tab = 'current';
public string $search = '';
#[Computed]
public function tickets()
protected function baseQuery()
{
return Auth::user()->ticketsAsCustomer()
->search($this->search)
->with('subcategory.category')
->orderByDesc('updated_at')
->get();
->orderByDesc('updated_at');
}
/**
* Separate named paginators (see pageName below) so switching tabs
* doesn't reset whichever page the other tab was on.
*/
#[Computed]
public function currentTickets()
{
return $this->tickets->whereNotIn('status_key', Status::closedKeys());
return $this->baseQuery()->whereNotIn('status_key', Status::closedKeys())
->paginate(self::PER_PAGE, pageName: 'currentPage');
}
#[Computed]
public function archiveTickets()
{
return $this->tickets->whereIn('status_key', Status::closedKeys());
return $this->baseQuery()->whereIn('status_key', Status::closedKeys())
->paginate(self::PER_PAGE, pageName: 'archivePage');
}
public function updatedSearch(): void
{
$this->resetPage('currentPage');
$this->resetPage('archivePage');
}
public function setTab(string $tab): void
@@ -42,6 +58,6 @@ class Dashboard extends Component
public function render()
{
return view('livewire.client.dashboard');
return view('livewire.client.dashboard')->title(Settings::pageTitle('Moje zgłoszenia'));
}
}

View File

@@ -209,6 +209,6 @@ class NewTicket extends Component
public function render()
{
return view('livewire.client.new-ticket');
return view('livewire.client.new-ticket')->title(Settings::pageTitle('Nowe zgłoszenie'));
}
}

View File

@@ -33,6 +33,10 @@ class TicketShow extends Component
public string $csatComment = '';
public bool $showAllOtherTickets = false;
private const OTHER_TICKETS_PREVIEW_COUNT = 5;
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the ticket page's first paint — it loads in a beat later instead.
@@ -103,7 +107,21 @@ class TicketShow extends Component
#[Computed]
public function otherTickets()
{
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
$query = Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->latest();
return $this->showAllOtherTickets ? $query->get() : $query->take(self::OTHER_TICKETS_PREVIEW_COUNT)->get();
}
#[Computed]
public function otherTicketsCount(): int
{
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->count();
}
public function revealAllOtherTickets(): void
{
$this->showAllOtherTickets = true;
unset($this->otherTickets);
}
/**
@@ -222,6 +240,6 @@ class TicketShow extends Component
public function render()
{
return view('livewire.client.ticket-show');
return view('livewire.client.ticket-show')->title(Settings::pageTitle($this->ticket->displayNumber().' — '.$this->ticket->subject));
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Livewire;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Component;
class GlobalSearch extends Component
{
public string $search = '';
/**
* Gmail-style "field:value" operators recognized keys (accent-
* insensitive, a couple of Polish synonyms each) narrow the match to
* that one field; anything left over after stripping them still runs
* through the broad Ticket::scopeSearch() default. Multiple operators
* combine with AND, same as Gmail's "from:x subject:y".
*/
private const FIELD_PREFIXES = [
'od' => 'from',
'nadawca' => 'from',
'temat' => 'subject',
'tytul' => 'subject',
'tytuł' => 'subject',
'tresc' => 'body',
'treść' => 'body',
'numer' => 'number',
'nr' => 'number',
];
#[Computed]
public function results()
{
$term = trim($this->search);
if ($term === '' || ! ($user = Auth::user())) {
return collect();
}
$query = $this->baseQuery($user);
if (! $query) {
return collect();
}
[$fields, $free] = $this->parseQuery($term);
foreach ($fields as $field => $values) {
foreach ($values as $value) {
$this->applyFieldFilter($query, $field, $value);
}
}
// search() itself no-ops on an empty term, so this is safe to call
// unconditionally — it only matters when there was no operator at
// all, or an operator left some free text behind.
$query->search($free);
return $query->orderByDesc('updated_at')->limit(8)->get();
}
/**
* Splits "od:kacper temat:drukarka reszta" into recognized field
* operators plus whatever free text is left over. An unrecognized
* "key:value" (e.g. a pasted URL) is left untouched in the free text
* rather than silently dropped.
*/
private function parseQuery(string $term): array
{
$fields = [];
$free = preg_replace_callback(
'/(\pL+):(\S+)/u',
function ($m) use (&$fields) {
$key = mb_strtolower($m[1]);
if (! isset(self::FIELD_PREFIXES[$key])) {
return $m[0];
}
$fields[self::FIELD_PREFIXES[$key]][] = $m[2];
return '';
},
$term
);
return [$fields, trim(preg_replace('/\s+/', ' ', $free))];
}
private function applyFieldFilter($query, string $field, string $value): void
{
$like = '%'.$value.'%';
match ($field) {
'from' => $query->where(fn ($q) => $q->where('name', 'like', $like)->orWhere('email', 'like', $like)),
'subject' => $query->where('subject', 'like', $like),
'number' => $query->where('number', 'like', $like),
'body' => $query->where(fn ($q) => $q->where('body', 'like', $like)
->orWhereIn('id', DB::table('ticket_messages')->where('body', 'like', $like)->pluck('ticket_id'))),
default => null,
};
}
public function urlFor(Ticket $ticket): string
{
$user = Auth::user();
return $user->isOperator()
? route('operator.ticket', $ticket)
: route('client.ticket', $ticket);
}
/**
* Scoped (and routed, see urlFor()) by the operator role specifically
* rather than isAdmin() admin alone doesn't grant the operator.*
* routes (see routes/web.php's role:operator middleware), so a result
* pointing there would 404 for an admin-only account.
*/
protected function baseQuery(User $user)
{
if ($user->isOperator()) {
return Ticket::query()->visibleToOperator($user);
}
if ($user->isClient()) {
return $user->ticketsAsCustomer();
}
return null;
}
public function render()
{
return view('livewire.global-search');
}
}

View File

@@ -10,6 +10,7 @@ use App\Services\BookStackClient;
use App\Services\LdapUserProvisioner;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -41,6 +42,13 @@ class Landing extends Component
// delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function mount(): void
{
if (Auth::check()) {
$this->redirect(route('client.dashboard'), navigate: true);
}
}
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
@@ -187,6 +195,8 @@ class Landing extends Component
public function render()
{
return view('livewire.landing');
$context = $this->submittedTicketId ? 'Zgłoszenie utworzone' : 'Zgłoś problem';
return view('livewire.landing')->title(Settings::pageTitle($context));
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Operator;
use App\Models\User;
use App\Support\Settings;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
class ClientSearch extends Component
{
#[Url]
public string $search = '';
/**
* A search box, not a browse-everything list capped rather than
* paginated, same reasoning as the debounced search inputs elsewhere
* (queue/dashboard): once there's a match count this large, the fix is a
* narrower search term, not another page to click through.
*/
private const MAX_RESULTS = 20;
/**
* Matches by name or e-mail across every account, not just role=client
* a ticket's customer_id can point at any user (e.g. an operator who
* also filed a ticket), so narrowing to clients only would hide valid
* results. Role badges in the view make it clear who's who.
*/
#[Computed]
public function results()
{
$term = trim($this->search);
if ($term === '') {
return collect();
}
return User::query()
->where(fn ($q) => $q->where('name', 'like', "%{$term}%")->orWhere('email', 'like', "%{$term}%"))
->withCount('ticketsAsCustomer')
->orderBy('name')
->limit(self::MAX_RESULTS)
->get();
}
public function render()
{
return view('livewire.operator.client-search')->title(Settings::pageTitle('Klienci'));
}
}

View File

@@ -160,6 +160,6 @@ class NewTicket extends Component
public function render()
{
return view('livewire.operator.new-ticket');
return view('livewire.operator.new-ticket')->title(Settings::pageTitle('Nowe zgłoszenie'));
}
}

View File

@@ -10,14 +10,30 @@ use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Queue extends Component
{
use WithPagination;
/**
* Rows per page for the ticket table below. Sorting (see sortTickets())
* still happens in PHP over the full filtered result several sortable
* columns (kategoria, przypisany, zespół...) are derived labels with no
* single backing SQL column so this slices the already-sorted
* collection rather than using a query-level ->paginate(). That still
* bounds how many rows ever hit the DOM at once, which is what actually
* mattered once the ticket count grew into the thousands.
*/
private const PER_PAGE = 50;
#[Url]
public string $queue = 'all';
@@ -129,6 +145,32 @@ class Queue extends Component
$this->sortDir = $filters['sortDir'] ?? $this->sortDir;
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
$this->selectedIds = [];
$this->resetPage();
}
/**
* Any change to a filter/search/queue-tab input can shrink the result
* set out from under whatever page the operator was on snap back to
* page 1 rather than showing an empty table.
*/
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedFilterStatus(): void
{
$this->resetPage();
}
public function updatedFilterPriority(): void
{
$this->resetPage();
}
public function updatedFilterCategory(): void
{
$this->resetPage();
}
public function saveCurrentView(): void
@@ -285,6 +327,21 @@ class Queue extends Component
return $groups;
}
/**
* Last few tickets this operator actually opened (see
* Ticket::recordViewBy(), called from TicketShow::mount()) re-scoped
* through visibleToOperator() in case a team reassignment since the
* view happened would now hide it from them.
*/
#[Computed]
public function recentlyViewed()
{
return Auth::user()->recentlyViewedTickets()
->visibleToOperator(Auth::user())
->take(6)
->get();
}
#[Computed]
public function filteredTickets()
{
@@ -314,8 +371,15 @@ class Queue extends Component
}
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
$sorted = $this->sortTickets($tickets);
return $this->sortTickets($tickets);
return new LengthAwarePaginator(
$sorted->forPage($this->getPage(), self::PER_PAGE)->values(),
$sorted->count(),
self::PER_PAGE,
$this->getPage(),
['path' => request()->url()],
);
}
/**
@@ -415,11 +479,14 @@ class Queue extends Component
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
$this->filterStatus = 'all';
}
$this->resetPage();
}
public function clearCustomerFilter(): void
{
$this->filterCustomerId = null;
$this->resetPage();
}
public function toggleSelect(int $id): void
@@ -496,6 +563,8 @@ class Queue extends Component
public function render()
{
return view('livewire.operator.queue');
$queueLabel = $this->queueDefs()[$this->queue]['label'] ?? 'Kolejka';
return view('livewire.operator.queue')->title(Settings::pageTitle($queueLabel));
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\Status;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -624,6 +625,6 @@ class Stats extends Component
public function render()
{
return view('livewire.operator.stats');
return view('livewire.operator.stats')->title(Settings::pageTitle('Statystyki'));
}
}

View File

@@ -124,6 +124,8 @@ class TicketShow extends Component
// ticket-show.blade.php) — so every open resumes it, not just the
// very first one.
$this->ticket->resumeTimer();
$this->ticket->recordViewBy(Auth::user());
}
/**
@@ -773,6 +775,6 @@ class TicketShow extends Component
// progress is saved incrementally rather than only on explicit stop.
$this->ticket->flushTimer();
return view('livewire.operator.ticket-show');
return view('livewire.operator.ticket-show')->title(Settings::pageTitle($this->ticket->displayNumber().' — '.$this->ticket->subject));
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Livewire\Settings;
use App\Models\NotificationPreference;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
@@ -39,6 +40,7 @@ class NotificationPreferences extends Component
public function render()
{
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]);
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()])
->title(Settings::pageTitle('Powiadomienia'));
}
}

View File

@@ -97,6 +97,26 @@ class Ticket extends Model
return $this->watchers()->where('users.id', $user->id)->exists();
}
public function viewers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'ticket_views')->withPivot('viewed_at');
}
/**
* Bumps viewed_at for an existing view rather than duplicating it sync()
* updates pivot columns on already-attached rows, not just new ones.
*
* Formatted explicitly with microseconds: a plain Carbon instance gets
* bound through the connection's default date format (whole seconds,
* regardless of the column's own declared precision), so two views in
* the same second would otherwise tie and silently fall back to sorting
* by row id instead of actual recency.
*/
public function recordViewBy(User $user): void
{
$this->viewers()->syncWithoutDetaching([$user->id => ['viewed_at' => now()->format('Y-m-d H:i:s.u')]]);
}
public function status(): BelongsTo
{
return $this->belongsTo(Status::class, 'status_key');

View File

@@ -189,6 +189,13 @@ class User extends Authenticatable implements LdapAuthenticatable
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
}
public function recentlyViewedTickets(): BelongsToMany
{
return $this->belongsToMany(Ticket::class, 'ticket_views')
->withPivot('viewed_at')
->orderByPivot('viewed_at', 'desc');
}
public function ticketsAssigned(): HasMany
{
return $this->hasMany(Ticket::class, 'assignee_id');

View File

@@ -158,6 +158,12 @@ class AppServiceProvider extends ServiceProvider
Config::set('ldap.connections.default', $config);
Container::addConnection(new Connection($config), 'default');
// Active Directory's objectClass chain (top/person/organizationalPerson/
// user) and login attribute (sAMAccountName) differ from the
// LLDAP/OpenLDAP schema LldapUser is scoped to — swap in AdUser so a
// directory switch doesn't leave every login matching zero entries.
Config::set('auth.providers.users.model', Settings::ldapUserModelClass());
}
/**

View File

@@ -2,10 +2,11 @@
namespace App\Services;
use App\Ldap\LldapUser;
use App\Models\User;
use App\Models\UserField;
use App\Support\Settings;
use Illuminate\Support\Facades\Log;
use LdapRecord\Models\Model as LdapModel;
use Throwable;
/**
@@ -80,11 +81,26 @@ class LdapUserProvisioner
return $matched;
}
protected function findLdapEntryForUser(User $user): ?LldapUser
/**
* The LdapRecord model class for whichever directory is currently
* configured (LLDAP vs Active Directory see
* Settings::ldapUserModelClass()) resolved fresh on every call rather
* than cached, since an admin can flip the directory type mid-session.
*/
protected function ldapUserModel(): string
{
return Settings::ldapUserModelClass();
}
protected function findLdapEntryForUser(User $user): ?LdapModel
{
if ($user->guid) {
try {
$byGuid = LldapUser::query()->where('entryuuid', '=', $user->guid)->first();
// findByGuid() builds the right raw filter for either a
// binary objectGUID (AD) or a plain entryUUID string
// (LLDAP/OpenLDAP) — unlike a plain ->where(), it doesn't
// need to know which attribute that is.
$byGuid = $this->ldapUserModel()::query()->findByGuid($user->guid);
} catch (Throwable $e) {
Log::warning('LDAP lookup by guid failed: '.$e->getMessage());
$byGuid = null;
@@ -98,10 +114,10 @@ class LdapUserProvisioner
return $this->findLdapEntryByEmail($user->email);
}
protected function findLdapEntryByEmail(string $email): ?LldapUser
protected function findLdapEntryByEmail(string $email): ?LdapModel
{
try {
return LldapUser::query()->where('mail', '=', $email)->first();
return $this->ldapUserModel()::query()->where('mail', '=', $email)->first();
} catch (Throwable $e) {
Log::warning('LDAP lookup by email failed: '.$e->getMessage());
@@ -109,7 +125,7 @@ class LdapUserProvisioner
}
}
public function applyFieldsFromLdap(User $user, LldapUser $ldapEntry): void
public function applyFieldsFromLdap(User $user, LdapModel $ldapEntry): void
{
$values = $user->custom_field_values ?? [];

View File

@@ -2,6 +2,8 @@
namespace App\Support;
use App\Ldap\AdUser;
use App\Ldap\LldapUser;
use App\Models\Setting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
@@ -229,6 +231,20 @@ class Settings
return $path ? Storage::disk('public')->url($path) : asset('branding/default-mark.svg');
}
/**
* Per-page <title>: the context first (so it's still visible once the
* browser truncates a long tab title, and so tabs are distinguishable
* at a glance) with the company name as a trailing, always-present
* anchor. Falls back to just the company name for pages with no more
* specific context (landing, login).
*/
public static function pageTitle(?string $context = null): string
{
$company = static::get('company_name');
return $context ? "{$context}{$company}" : $company;
}
/**
* The admin-configured timezone (IANA identifier, e.g. "Europe/Warsaw"),
* applied at runtime by AppServiceProvider so every date/time displayed
@@ -286,19 +302,46 @@ class Settings
]);
}
/**
* Whether the admin has pointed LDAP auth at Active Directory rather
* than an LLDAP/OpenLDAP-schema directory changes which LdapRecord
* model class backs logins (see ldapUserModelClass()) and which
* attribute a bare username search defaults to (see
* ldapUsernameAttribute()), since AD's objectClass chain and login
* attribute (sAMAccountName, not uid) differ from LLDAP's.
*/
public static function isActiveDirectory(): bool
{
return static::get('ldap_directory_type') === 'ad';
}
/**
* The LdapRecord model class the 'users' auth provider should use
* wired into config('auth.providers.users.model') at runtime by
* AppServiceProvider::applyLdapSettingsOverride(), same as the
* connection host/base DN below.
*/
public static function ldapUserModelClass(): string
{
return static::isActiveDirectory() ? AdUser::class : LldapUser::class;
}
/**
* Parses the admin-configurable LDAP user filter (e.g. "(uid={0})") to
* find which LDAP attribute logins are matched against.
* find which LDAP attribute logins are matched against. Defaults to
* Active Directory's sAMAccountName when no filter is set and the
* directory type is AD uid is never populated on a stock AD user.
*/
public static function ldapUsernameAttribute(): string
{
$filter = static::get('ldap_user_filter', '(uid={0})');
$default = static::isActiveDirectory() ? '(sAMAccountName={0})' : '(uid={0})';
$filter = static::get('ldap_user_filter') ?: $default;
if (preg_match('/\(([a-zA-Z0-9-]+)=\{0\}\)/', (string) $filter, $matches)) {
return $matches[1];
}
return 'uid';
return static::isActiveDirectory() ? 'sAMAccountName' : 'uid';
}
/**

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_views', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Microsecond precision (not the plain-timestamp default) so two
// views landing in the same second — plausible with fast repeat
// clicks, not just test speed — still order correctly instead of
// tying and falling back to row id.
$table->timestamp('viewed_at', 6);
$table->unique(['ticket_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('ticket_views');
}
};

View File

@@ -0,0 +1,8 @@
<?php
return [
'previous' => '&laquo; Poprzednia',
'next' => 'Następna &raquo;',
];

View File

@@ -211,6 +211,63 @@ body {
.seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
.panel-switch {
display: inline-flex;
padding: 3px;
border: 1px solid var(--color-divider);
border-radius: 999px;
background: color-mix(in srgb, var(--color-text) 4%, transparent);
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.panel-switch::-webkit-scrollbar { display: none; }
.panel-switch-indicator {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 999px;
background: var(--color-accent);
transition: transform 0.2s ease;
z-index: 0;
}
.nav .panel-switch-option {
position: relative;
z-index: 1;
flex: 1 1 0;
min-width: 128px;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
font-size: 12.5px;
font-weight: 500;
color: var(--color-text);
text-decoration: none;
white-space: nowrap;
border-radius: 999px;
}
.nav .panel-switch-option:hover { color: #fff; text-decoration: none; }
.nav .panel-switch-option-active, .nav .panel-switch-option-active:hover { color: #fff; }
/* Preview the destination before the click actually navigates there:
the indicator follows whichever option is under the cursor (falling
back to the real active position, set inline per-request by
panel-switcher.blade.php, the moment the pointer leaves). Position is
purely by hovered index independent of how many roles/options
exist so these three rules cover the max of three areas regardless
of which subset a given user has. */
.panel-switch:has(> .panel-switch-option:nth-of-type(1):hover) .panel-switch-indicator { transform: translateX(0%) !important; }
.panel-switch:has(> .panel-switch-option:nth-of-type(2):hover) .panel-switch-indicator { transform: translateX(100%) !important; }
.panel-switch:has(> .panel-switch-option:nth-of-type(3):hover) .panel-switch-indicator { transform: translateX(200%) !important; }
/* The real active option's white text is only correct while the
indicator sits under it once hover has pulled the indicator away to
a neighboring option, drop it back to normal text color so it doesn't
read as near-invisible white-on-track. */
.panel-switch:hover .panel-switch-option-active:not(:hover) { color: var(--color-text); }
.theme-toggle-option {
display: flex;
align-items: center;
@@ -341,8 +398,26 @@ body {
@media (max-width: 640px) {
.page-pad { padding: 16px !important; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
.nav-panel-label { display: none; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; }
/* At this width the switcher's own centered slot collides with the
brand text and the icon buttons sharing the row (nothing left to
shrink once labels are already at their minimum width) same
"restructure instead of cram" fix as the mobile table pattern above:
drop it to its own full-width row below instead of fighting for
space with everything else in the bar. */
.panel-switch {
position: relative !important;
left: auto !important;
top: auto !important;
transform: none !important;
order: 10;
width: 100%;
max-width: 100%;
justify-content: center;
margin-top: 10px;
}
.nav .panel-switch-option { padding: 10px; font-size: 11.5px; min-width: 92px; }
/* Theme/notifications/profile dropdowns are anchored (position:absolute)
to their own small trigger button by default, which overflows off the

View File

@@ -0,0 +1,50 @@
@props(['area' => null])
@php
// $area is passed explicitly by each page (e.g. <x-topbar area="operator" />)
// rather than inferred from request()->routeIs() here: this component is
// rendered as part of each top-level Livewire page's own template, so it
// re-renders on every wire:click/wire:model round-trip on that page (tab
// switches, pagination, search, ...) — and during that AJAX request,
// request()->route() is Livewire's own update route, not client./operator./
// admin.*, which silently broke the highlight on every in-page interaction
// when this used to key off the ambient request instead of an explicit prop.
$user = auth()->user();
$areas = [];
if ($user) {
if ($user->isClient()) {
$areas[] = ['label' => 'Klient', 'url' => route('client.dashboard'), 'active' => $area === 'client'];
}
if ($user->isOperator()) {
$areas[] = ['label' => 'Operator', 'url' => route('operator.queue'), 'active' => $area === 'operator'];
}
if ($user->isAdmin()) {
$areas[] = ['label' => 'Administrator', 'url' => route('admin.panel'), 'active' => $area === 'admin'];
}
}
$activeIndex = collect($areas)->search(fn ($area) => $area['active']);
@endphp
@if (count($areas))
<div
class="panel-switch"
style="position:absolute;left:50%;top:50%;transform:translate(-50%, -50%)"
>
@if ($activeIndex !== false)
<span
class="panel-switch-indicator"
style="width:calc(100% / {{ count($areas) }});transform:translateX({{ $activeIndex * 100 }}%)"
></span>
@endif
@foreach ($areas as $item)
<a
href="{{ $item['url'] }}"
wire:navigate
class="panel-switch-option {{ $item['active'] ? 'panel-switch-option-active' : '' }}"
>{{ $item['label'] }}</a>
@endforeach
</div>
@endif

View File

@@ -1,18 +1,5 @@
@php
$user = auth()->user();
$areas = [];
if ($user) {
if ($user->isClient()) {
$areas[] = ['label' => 'Panel Klienta', 'url' => route('client.dashboard'), 'active' => request()->routeIs('client.*')];
}
if ($user->isOperator()) {
$areas[] = ['label' => 'Panel Operatora', 'url' => route('operator.queue'), 'active' => request()->routeIs('operator.*')];
}
if ($user->isAdmin()) {
$areas[] = ['label' => 'Panel Administratora', 'url' => route('admin.panel'), 'active' => request()->routeIs('admin.*')];
}
}
@endphp
@if ($user)
@@ -26,23 +13,27 @@
x-show="open"
x-cloak
class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:30"
>
@foreach ($areas as $area)
<a
href="{{ $area['url'] }}"
wire:navigate
@click="open = false"
class="theme-toggle-option"
style="text-decoration:none;color:{{ $area['active'] ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
>{{ $area['label'] }}</a>
@endforeach
<div style="display:flex;flex-direction:column;gap:6px;padding:12px">
<span style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $user->name }}</span>
<span style="font-size:11.5px;color:color-mix(in srgb, var(--color-text) 60%, transparent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $user->email }}</span>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:2px">
@if ($user->isClient())
<span class="tag tag-neutral">Klient</span>
@endif
@if ($user->isOperator())
<span class="tag tag-accent-2">Operator</span>
@endif
@if ($user->isAdmin())
<span class="tag tag-accent">Administrator</span>
@endif
</div>
</div>
@if (count($areas))
<div style="border-top:1px solid var(--color-divider)"></div>
@endif
<div style="border-top:1px solid var(--color-divider)"></div>
@if ($user && ($user->isOperator() || $user->isAdmin()))
@if ($user->isOperator() || $user->isAdmin())
<a
href="{{ route('settings.notifications') }}"
wire:navigate

View File

@@ -1,18 +1,9 @@
@php
$panelLabel = match (true) {
request()->routeIs('client.*') => 'Panel Klienta',
request()->routeIs('operator.*') => 'Panel Operatora',
request()->routeIs('admin.*') => 'Panel Administratora',
default => null,
};
@endphp
@props(['area' => null])
<div class="nav" style="position:relative;padding:16px 28px;border-bottom:1px solid var(--color-divider)">
<span class="nav-brand">{{ \App\Support\Settings::get('company_name') }}</span>
@if ($panelLabel)
<span class="nav-panel-label" style="position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);font-weight:500;font-size:13.5px;white-space:nowrap">{{ $panelLabel }}</span>
@endif
<x-panel-switcher :area="$area" />
<x-theme-toggle />
@@ -20,6 +11,7 @@
@auth
<livewire:notification-bell />
<livewire:global-search />
@endauth
<x-profile-menu />

View File

@@ -27,10 +27,15 @@ $tabGroups = [
];
@endphp
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="admin" />
<div class="app-split" style="flex:1;display:flex;min-height:0">
<div class="side-rail" style="padding:16px 10px;gap:16px;overflow:auto;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))">
<button type="button" @click="window.dispatchEvent(new CustomEvent('open-global-search'))" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;border:none;cursor:pointer;font-size:13.5px;text-align:left;white-space:nowrap;background:transparent;color:var(--color-text)">
<span class="material-symbols-outlined" style="font-size:18px">search</span>
<span>Szukaj</span>
</button>
@foreach ($tabGroups as $groupLabel => $items)
<div style="display:flex;flex-direction:column;gap:1px">
<div style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">{{ $groupLabel }}</div>
@@ -684,6 +689,13 @@ $tabGroups = [
<label class="radio"><input type="checkbox" wire:model="ldapConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz autentykację LDAP/AD</strong></label>
@if ($ldapConfig['enabled'])
<div class="field">
<label>Typ katalogu</label>
<select class="input" wire:model="ldapConfig.directoryType">
<option value="lldap">LLDAP / OpenLDAP</option>
<option value="ad">Active Directory</option>
</select>
</div>
<div class="field"><label>Host serwera LDAP</label><input class="input" placeholder="ldap.example.com" wire:model="ldapConfig.host"></div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="389" wire:model="ldapConfig.port"></div>
@@ -692,7 +704,13 @@ $tabGroups = [
<div class="field"><label>Base DN</label><input class="input" placeholder="dc=example,dc=com" wire:model="ldapConfig.baseDn"></div>
<div class="field"><label>Bind DN</label><input class="input" placeholder="cn=admin,dc=example,dc=com" wire:model="ldapConfig.bindDn"></div>
<div class="field"><label>Hasło Bind</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="ldapConfig.bindPassword"></div>
<div class="field"><label>User Filter</label><input class="input" placeholder="(uid={0})" wire:model="ldapConfig.userFilter"></div>
<div class="field">
<label>User Filter</label>
<input class="input" placeholder="{{ $ldapConfig['directoryType'] === 'ad' ? '(sAMAccountName={0})' : '(uid={0})' }}" wire:model="ldapConfig.userFilter">
@if ($ldapConfig['directoryType'] === 'ad')
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Active Directory nie ma atrybutu "uid" puste pole domyślnie użyje sAMAccountName.</p>
@endif
</div>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.autoProvisionGuests" style="position:static;opacity:1;width:auto;height:auto">Automatycznie zakładaj konto dla gościa zgłaszającego, jeśli jego e-mail istnieje w LDAP</label>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictUserCreationToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na ręczne zapraszanie użytkowników tylko, jeśli ich e-mail istnieje w LDAP</label>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictTicketsToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na tworzenie zgłoszeń bez logowania tylko dla adresów e-mail istniejących w LDAP</label>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
@@ -9,8 +9,8 @@
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<div class="seg">
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->count() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->count() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->total() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->total() }})</label>
</div>
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
</div>
@@ -32,6 +32,10 @@
@if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty())
<p class="text-muted" style="font-size:13px">Brak zgłoszeń w tej zakładce.</p>
@endif
<div style="margin-top:4px">
{{ ($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->links() }}
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
@@ -182,13 +182,18 @@
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Inne Twoje zgłoszenia</div>
@forelse ($this->otherTickets as $ot)
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
<a wire:key="other-ticket-{{ $ot->id }}" href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
<span style="font-size:13px">{{ $ot->displayNumber() }} {{ $ot->subject }}</span>
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
</a>
@empty
<p class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p>
<p wire:key="other-tickets-empty" class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p>
@endforelse
@if (! $showAllOtherTickets && $this->otherTicketsCount > count($this->otherTickets))
<button wire:key="show-all-other-tickets" type="button" wire:click="revealAllOtherTickets" class="btn btn-secondary" style="font-size:12px;padding:6px 10px;align-self:flex-start">
Pokaż wszystkie ({{ $this->otherTicketsCount }})
</button>
@endif
</div>
</div>
</div>

View File

@@ -0,0 +1,51 @@
<div
x-data="{ open: false }"
x-on:open-global-search.window="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.cmd.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.ctrl.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.escape.window="open = false"
>
<div class="dialog-backdrop" x-show="open" x-cloak style="align-items:flex-start;padding-top:10vh">
<div class="dialog" style="max-width:720px;padding:0;gap:0;overflow:hidden" @click.outside="open = false">
<div style="display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:1px solid var(--color-divider)">
<span class="material-symbols-outlined" style="font-size:26px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">search</span>
<input
x-ref="searchInput"
type="text"
wire:model.live.debounce.200ms="search"
placeholder="Szukaj zgłoszenia… (np. od:kacper temat:drukarka)"
class="input"
style="border:none;padding:10px 12px;box-shadow:none;font-size:18px"
x-on:keydown.enter="$refs.resultsList?.querySelector('a')?.click()"
>
<kbd style="font-size:11px;color:color-mix(in srgb, var(--color-text) 45%, transparent);border:1px solid var(--color-divider);border-radius:4px;padding:2px 7px;flex:none">Esc</kbd>
</div>
<div x-ref="resultsList" style="max-height:460px;overflow-y:auto">
@forelse ($this->results as $ticket)
<a
href="{{ $this->urlFor($ticket) }}"
wire:navigate
@click="open = false"
style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 22px;text-decoration:none;color:inherit;border-bottom:1px solid var(--color-divider)"
>
<div style="min-width:0">
<div style="font-weight:500;font-size:14.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div>
<div class="card-meta" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ $ticket->categoryLabel() }} &middot; {{ $ticket->name }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}
</div>
</div>
<div style="display:flex;gap:6px;flex:none">
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</div>
</a>
@empty
<div style="padding:28px 22px;text-align:center;font-size:13.5px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">
{{ trim($search) === '' ? 'Zacznij pisać, aby wyszukać zgłoszenie… (obsługuje też od:, temat:, treść:, numer:)' : 'Brak wyników.' }}
</div>
@endforelse
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,53 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px;max-width:820px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">Klienci</h2>
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost">&larr; Wróć do listy</a>
</div>
<input
class="input"
type="search"
placeholder="Szukaj po imieniu, nazwisku lub adresie e-mail…"
wire:model.live.debounce.400ms="search"
autofocus
>
<div style="display:flex;flex-direction:column;gap:10px">
@if (trim($search) === '')
<p class="text-muted" style="font-size:13px">Zacznij pisać, aby wyszukać klienta.</p>
@else
@forelse ($this->results as $u)
<a
wire:key="client-{{ $u->id }}"
href="{{ route('operator.queue', ['filterCustomerId' => $u->id]) }}"
wire:navigate
class="card elev-sm"
style="padding:14px 16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit"
>
<div>
<div style="font-weight:500">{{ $u->name }}</div>
<div class="card-meta">{{ $u->email }}</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end">
@if ($u->hasRole('client'))
<span class="tag tag-outline">Klient</span>
@endif
@if ($u->hasRole('operator'))
<span class="tag tag-outline">Operator</span>
@endif
@if ($u->hasRole('admin'))
<span class="tag tag-outline">Administrator</span>
@endif
<span class="tag tag-neutral">{{ $u->tickets_as_customer_count }} {{ $u->tickets_as_customer_count === 1 ? 'zgłoszenie' : 'zgłoszeń' }}</span>
</div>
</a>
@empty
<p class="text-muted" style="font-size:13px">Brak wyników dla {{ $search }}.</p>
@endforelse
@endif
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="app-split" style="flex:1;display:flex;min-height:0">
<div
@@ -14,6 +14,11 @@
<span class="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span>
</button>
<button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))">
<span class="material-symbols-outlined" style="font-size:18px">search</span>
<span class="side-rail-toggle-label">Szukaj</span>
</button>
<div class="side-rail-scroll">
@foreach ($this->queueGroups as $groupLabel => $items)
<div style="display:flex;flex-direction:column;gap:1px">
@@ -27,6 +32,18 @@
@endforeach
</div>
@endforeach
@if ($this->recentlyViewed->isNotEmpty())
<div style="display:flex;flex-direction:column;gap:1px">
<div class="side-rail-group-label" style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">Ostatnio przeglądane</div>
@foreach ($this->recentlyViewed as $ticket)
<a href="{{ route('operator.ticket', $ticket) }}" wire:navigate class="side-rail-item" title="{{ $ticket->displayNumber() }} — {{ $ticket->subject }}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;font-size:13.5px;text-align:left;white-space:nowrap;text-decoration:none;color:var(--color-text);overflow:hidden">
<span class="material-symbols-outlined" style="font-size:18px;flex:none">history</span>
<span class="side-rail-label" style="overflow:hidden;text-overflow:ellipsis">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</span>
</a>
@endforeach
</div>
@endif
</div>
</div>
@@ -40,6 +57,10 @@
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 2) wire:click="mergeSelected">Scal zgłoszenia ({{ count($selectedIds) }})</button>
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 1) wire:click="requestDeleteSelected" style="color:var(--color-danger)">Usuń zgłoszenia ({{ count($selectedIds) }})</button>
</div>
<a href="{{ route('operator.clients') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">person_search</span>
Klienci
</a>
<a href="{{ route('operator.stats') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">bar_chart</span>
Statystyki
@@ -208,6 +229,9 @@
</tbody>
</table>
</div>
<div style="margin-top:12px">
{{ $this->filteredTickets->links() }}
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">

View File

@@ -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

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);
});

View File

@@ -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`,

View File

@@ -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ć:

View File

@@ -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