From 03c6ec7cae52d9086fc66ad393e8ab5b9adbcd43 Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 5 Aug 2026 00:58:35 +0200 Subject: [PATCH] v1.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 +- ARCHITECTURE.md | 10 + CHANGELOG.md | 60 +++ CLAUDE.md | 46 +- CONTRIBUTING.md | 4 +- README.md | 46 +- TESTING.md | 10 +- install.md | 79 ++- scripts/hesk-import/.env.example | 5 + scripts/hesk-import/README.md | 83 +++ scripts/hesk-import/hesk-import.sh | 49 ++ src/.env.example | 2 +- .../Console/Commands/ImportHeskTickets.php | 504 ++++++++++++++++++ src/app/Ldap/AdUser.php | 18 + src/app/Livewire/Admin/Panel.php | 33 +- src/app/Livewire/Auth/Login.php | 2 +- src/app/Livewire/Client/Dashboard.php | 30 +- src/app/Livewire/Client/NewTicket.php | 2 +- src/app/Livewire/Client/TicketShow.php | 22 +- src/app/Livewire/GlobalSearch.php | 141 +++++ src/app/Livewire/Landing.php | 12 +- src/app/Livewire/Operator/ClientSearch.php | 51 ++ src/app/Livewire/Operator/NewTicket.php | 2 +- src/app/Livewire/Operator/Queue.php | 73 ++- src/app/Livewire/Operator/Stats.php | 3 +- src/app/Livewire/Operator/TicketShow.php | 4 +- .../Settings/NotificationPreferences.php | 4 +- src/app/Models/Ticket.php | 20 + src/app/Models/User.php | 7 + src/app/Providers/AppServiceProvider.php | 6 + src/app/Services/LdapUserProvisioner.php | 28 +- src/app/Support/Settings.php | 49 +- ...08_04_000159_create_ticket_views_table.php | 29 + src/lang/pl/pagination.php | 8 + src/resources/css/app.css | 79 ++- .../views/components/panel-switcher.blade.php | 50 ++ .../views/components/profile-menu.blade.php | 45 +- .../views/components/topbar.blade.php | 14 +- .../views/livewire/admin/panel.blade.php | 22 +- .../views/livewire/client/dashboard.blade.php | 10 +- .../livewire/client/new-ticket.blade.php | 2 +- .../livewire/client/ticket-show.blade.php | 11 +- .../views/livewire/global-search.blade.php | 51 ++ .../livewire/operator/client-search.blade.php | 53 ++ .../livewire/operator/new-ticket.blade.php | 2 +- .../views/livewire/operator/queue.blade.php | 26 +- .../views/livewire/operator/stats.blade.php | 2 +- .../livewire/operator/ticket-show.blade.php | 2 +- src/routes/web.php | 2 + .../GlobalSearchAndRecentlyViewedTest.php | 147 +++++ .../Feature/OperatorClientSearchTest.php | 47 ++ src/tests/Feature/PageTitleTest.php | 46 ++ src/tests/Feature/SmokeTest.php | 14 +- .../Feature/TicketListPaginationTest.php | 80 +++ wiki/admin/README.md | 13 +- wiki/client/README.md | 10 +- wiki/operator/README.md | 18 +- 57 files changed, 2029 insertions(+), 162 deletions(-) create mode 100644 scripts/hesk-import/.env.example create mode 100644 scripts/hesk-import/README.md create mode 100644 scripts/hesk-import/hesk-import.sh create mode 100644 src/app/Console/Commands/ImportHeskTickets.php create mode 100644 src/app/Ldap/AdUser.php create mode 100644 src/app/Livewire/GlobalSearch.php create mode 100644 src/app/Livewire/Operator/ClientSearch.php create mode 100644 src/database/migrations/2026_08_04_000159_create_ticket_views_table.php create mode 100644 src/lang/pl/pagination.php create mode 100644 src/resources/views/components/panel-switcher.blade.php create mode 100644 src/resources/views/livewire/global-search.blade.php create mode 100644 src/resources/views/livewire/operator/client-search.blade.php create mode 100644 src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php create mode 100644 src/tests/Feature/OperatorClientSearchTest.php create mode 100644 src/tests/Feature/PageTitleTest.php create mode 100644 src/tests/Feature/TicketListPaginationTest.php diff --git a/.gitignore b/.gitignore index 7c772cb..9eb9755 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /mysql/ .env -compose.yaml \ No newline at end of file +compose.yaml +scripts/hesk-import/.env \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3497e02..14c7bbf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c979df5..75e13f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` + (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 diff --git a/CLAUDE.md b/CLAUDE.md index 876606f..f3cbf07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4515546..f75b02e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index f23fde6..36cd19c 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/TESTING.md b/TESTING.md index e71455f..a61fbc1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 ``` diff --git a/install.md b/install.md index f620d38..0242643 100644 --- a/install.md +++ b/install.md @@ -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) diff --git a/scripts/hesk-import/.env.example b/scripts/hesk-import/.env.example new file mode 100644 index 0000000..9357207 --- /dev/null +++ b/scripts/hesk-import/.env.example @@ -0,0 +1,5 @@ +HESK_DB_HOST= +HESK_DB_PORT=3306 +HESK_DB_DATABASE= +HESK_DB_USERNAME= +HESK_DB_PASSWORD= diff --git a/scripts/hesk-import/README.md b/scripts/hesk-import/README.md new file mode 100644 index 0000000..05aa3a5 --- /dev/null +++ b/scripts/hesk-import/README.md @@ -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). diff --git a/scripts/hesk-import/hesk-import.sh b/scripts/hesk-import/hesk-import.sh new file mode 100644 index 0000000..bac030e --- /dev/null +++ b/scripts/hesk-import/hesk-import.sh @@ -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 "$@" diff --git a/src/.env.example b/src/.env.example index 2b4189a..91e001d 100644 --- a/src/.env.example +++ b/src/.env.example @@ -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 diff --git a/src/app/Console/Commands/ImportHeskTickets.php b/src/app/Console/Commands/ImportHeskTickets.php new file mode 100644 index 0000000..fd9265e --- /dev/null +++ b/src/app/Console/Commands/ImportHeskTickets.php @@ -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)); + } +} diff --git a/src/app/Ldap/AdUser.php b/src/app/Ldap/AdUser.php new file mode 100644 index 0000000..59b8a07 --- /dev/null +++ b/src/app/Ldap/AdUser.php @@ -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 {} diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php index cd6f02c..b0ac349 100644 --- a/src/app/Livewire/Admin/Panel.php +++ b/src/app/Livewire/Admin/Panel.php @@ -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)); } } diff --git a/src/app/Livewire/Auth/Login.php b/src/app/Livewire/Auth/Login.php index 38f7ce1..10f301f 100644 --- a/src/app/Livewire/Auth/Login.php +++ b/src/app/Livewire/Auth/Login.php @@ -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')); } } diff --git a/src/app/Livewire/Client/Dashboard.php b/src/app/Livewire/Client/Dashboard.php index 33565ea..787eb7d 100644 --- a/src/app/Livewire/Client/Dashboard.php +++ b/src/app/Livewire/Client/Dashboard.php @@ -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')); } } diff --git a/src/app/Livewire/Client/NewTicket.php b/src/app/Livewire/Client/NewTicket.php index 32aabb8..fc025e8 100644 --- a/src/app/Livewire/Client/NewTicket.php +++ b/src/app/Livewire/Client/NewTicket.php @@ -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')); } } diff --git a/src/app/Livewire/Client/TicketShow.php b/src/app/Livewire/Client/TicketShow.php index 9548f71..3fe38d9 100644 --- a/src/app/Livewire/Client/TicketShow.php +++ b/src/app/Livewire/Client/TicketShow.php @@ -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)); } } diff --git a/src/app/Livewire/GlobalSearch.php b/src/app/Livewire/GlobalSearch.php new file mode 100644 index 0000000..84adaf4 --- /dev/null +++ b/src/app/Livewire/GlobalSearch.php @@ -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'); + } +} diff --git a/src/app/Livewire/Landing.php b/src/app/Livewire/Landing.php index 71f5740..467243c 100644 --- a/src/app/Livewire/Landing.php +++ b/src/app/Livewire/Landing.php @@ -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)); } } diff --git a/src/app/Livewire/Operator/ClientSearch.php b/src/app/Livewire/Operator/ClientSearch.php new file mode 100644 index 0000000..daaf80b --- /dev/null +++ b/src/app/Livewire/Operator/ClientSearch.php @@ -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')); + } +} diff --git a/src/app/Livewire/Operator/NewTicket.php b/src/app/Livewire/Operator/NewTicket.php index 81ffe9e..2678799 100644 --- a/src/app/Livewire/Operator/NewTicket.php +++ b/src/app/Livewire/Operator/NewTicket.php @@ -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')); } } diff --git a/src/app/Livewire/Operator/Queue.php b/src/app/Livewire/Operator/Queue.php index 66e2ac0..307f9ac 100644 --- a/src/app/Livewire/Operator/Queue.php +++ b/src/app/Livewire/Operator/Queue.php @@ -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)); } } diff --git a/src/app/Livewire/Operator/Stats.php b/src/app/Livewire/Operator/Stats.php index 0696fc5..4157050 100644 --- a/src/app/Livewire/Operator/Stats.php +++ b/src/app/Livewire/Operator/Stats.php @@ -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')); } } diff --git a/src/app/Livewire/Operator/TicketShow.php b/src/app/Livewire/Operator/TicketShow.php index d7de0f6..08d85c9 100644 --- a/src/app/Livewire/Operator/TicketShow.php +++ b/src/app/Livewire/Operator/TicketShow.php @@ -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)); } } diff --git a/src/app/Livewire/Settings/NotificationPreferences.php b/src/app/Livewire/Settings/NotificationPreferences.php index c8d19d7..143e122 100644 --- a/src/app/Livewire/Settings/NotificationPreferences.php +++ b/src/app/Livewire/Settings/NotificationPreferences.php @@ -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')); } } diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index 833234b..151c27f 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -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'); diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 5e81ac7..f88d9dc 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -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'); diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 4817946..3f27636 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -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()); } /** diff --git a/src/app/Services/LdapUserProvisioner.php b/src/app/Services/LdapUserProvisioner.php index e28f284..10c7893 100644 --- a/src/app/Services/LdapUserProvisioner.php +++ b/src/app/Services/LdapUserProvisioner.php @@ -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 ?? []; diff --git a/src/app/Support/Settings.php b/src/app/Support/Settings.php index 7adc24e..06e8f84 100644 --- a/src/app/Support/Settings.php +++ b/src/app/Support/Settings.php @@ -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'; } /** diff --git a/src/database/migrations/2026_08_04_000159_create_ticket_views_table.php b/src/database/migrations/2026_08_04_000159_create_ticket_views_table.php new file mode 100644 index 0000000..411d57e --- /dev/null +++ b/src/database/migrations/2026_08_04_000159_create_ticket_views_table.php @@ -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'); + } +}; diff --git a/src/lang/pl/pagination.php b/src/lang/pl/pagination.php new file mode 100644 index 0000000..5cf57a3 --- /dev/null +++ b/src/lang/pl/pagination.php @@ -0,0 +1,8 @@ +<?php + +return [ + + 'previous' => '« Poprzednia', + 'next' => 'Następna »', + +]; diff --git a/src/resources/css/app.css b/src/resources/css/app.css index 245786c..a612235 100644 --- a/src/resources/css/app.css +++ b/src/resources/css/app.css @@ -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 diff --git a/src/resources/views/components/panel-switcher.blade.php b/src/resources/views/components/panel-switcher.blade.php new file mode 100644 index 0000000..ef1d6c0 --- /dev/null +++ b/src/resources/views/components/panel-switcher.blade.php @@ -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 diff --git a/src/resources/views/components/profile-menu.blade.php b/src/resources/views/components/profile-menu.blade.php index 124c8f0..d62dfd6 100644 --- a/src/resources/views/components/profile-menu.blade.php +++ b/src/resources/views/components/profile-menu.blade.php @@ -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 diff --git a/src/resources/views/components/topbar.blade.php b/src/resources/views/components/topbar.blade.php index 4bfb3ab..2ea3ef0 100644 --- a/src/resources/views/components/topbar.blade.php +++ b/src/resources/views/components/topbar.blade.php @@ -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 /> diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php index 0fd94d7..937ce05 100644 --- a/src/resources/views/livewire/admin/panel.blade.php +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -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> diff --git a/src/resources/views/livewire/client/dashboard.blade.php b/src/resources/views/livewire/client/dashboard.blade.php index 390c5d7..03748b8 100644 --- a/src/resources/views/livewire/client/dashboard.blade.php +++ b/src/resources/views/livewire/client/dashboard.blade.php @@ -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> diff --git a/src/resources/views/livewire/client/new-ticket.blade.php b/src/resources/views/livewire/client/new-ticket.blade.php index 5cfcc32..6b4690c 100644 --- a/src/resources/views/livewire/client/new-ticket.blade.php +++ b/src/resources/views/livewire/client/new-ticket.blade.php @@ -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"> diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index ea57404..bd68a84 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -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> diff --git a/src/resources/views/livewire/global-search.blade.php b/src/resources/views/livewire/global-search.blade.php new file mode 100644 index 0000000..c6f6d24 --- /dev/null +++ b/src/resources/views/livewire/global-search.blade.php @@ -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() }} · {{ $ticket->name }} · {{ \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> diff --git a/src/resources/views/livewire/operator/client-search.blade.php b/src/resources/views/livewire/operator/client-search.blade.php new file mode 100644 index 0000000..c05593f --- /dev/null +++ b/src/resources/views/livewire/operator/client-search.blade.php @@ -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">← 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> diff --git a/src/resources/views/livewire/operator/new-ticket.blade.php b/src/resources/views/livewire/operator/new-ticket.blade.php index bb3e2e2..6def039 100644 --- a/src/resources/views/livewire/operator/new-ticket.blade.php +++ b/src/resources/views/livewire/operator/new-ticket.blade.php @@ -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"> diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php index a803dd9..90a85d4 100644 --- a/src/resources/views/livewire/operator/queue.blade.php +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -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> diff --git a/src/resources/views/livewire/operator/stats.blade.php b/src/resources/views/livewire/operator/stats.blade.php index 344d6c4..e6af61f 100644 --- a/src/resources/views/livewire/operator/stats.blade.php +++ b/src/resources/views/livewire/operator/stats.blade.php @@ -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"> diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index c0a828a..fe37d20 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -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"> diff --git a/src/routes/web.php b/src/routes/web.php index 3446930..2fe2c07 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -6,6 +6,7 @@ use App\Livewire\Client\Dashboard as ClientDashboard; use App\Livewire\Client\NewTicket as ClientNewTicket; use App\Livewire\Client\TicketShow as ClientTicketShow; use App\Livewire\Landing; +use App\Livewire\Operator\ClientSearch as OperatorClientSearch; use App\Livewire\Operator\NewTicket as OperatorNewTicket; use App\Livewire\Operator\Queue as OperatorQueue; use App\Livewire\Operator\Stats as OperatorStats; @@ -56,6 +57,7 @@ Route::middleware(['auth', 'role:operator'])->prefix('operator')->name('operator Route::get('/', OperatorQueue::class)->name('queue'); Route::get('/new', OperatorNewTicket::class)->name('new'); Route::get('/stats', OperatorStats::class)->name('stats'); + Route::get('/clients', OperatorClientSearch::class)->name('clients'); Route::get('/tickets/{ticket}', OperatorTicketShow::class)->name('ticket'); // Fired via navigator.sendBeacon on tab/browser close (see the diff --git a/src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php b/src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php new file mode 100644 index 0000000..013bc77 --- /dev/null +++ b/src/tests/Feature/GlobalSearchAndRecentlyViewedTest.php @@ -0,0 +1,147 @@ +<?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]); +}); diff --git a/src/tests/Feature/OperatorClientSearchTest.php b/src/tests/Feature/OperatorClientSearchTest.php new file mode 100644 index 0000000..dc71911 --- /dev/null +++ b/src/tests/Feature/OperatorClientSearchTest.php @@ -0,0 +1,47 @@ +<?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(); +}); diff --git a/src/tests/Feature/PageTitleTest.php b/src/tests/Feature/PageTitleTest.php new file mode 100644 index 0000000..9d71acd --- /dev/null +++ b/src/tests/Feature/PageTitleTest.php @@ -0,0 +1,46 @@ +<?php + +use App\Models\User; +use Illuminate\Testing\TestResponse; + +function assertTitle(TestResponse $response, string $expectedContext): void +{ + $response->assertOk(); + preg_match('#<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'); +}); diff --git a/src/tests/Feature/SmokeTest.php b/src/tests/Feature/SmokeTest.php index 89ff253..3c3657f 100644 --- a/src/tests/Feature/SmokeTest.php +++ b/src/tests/Feature/SmokeTest.php @@ -31,16 +31,16 @@ test('every page renders without error against fully seeded data', function () { $this->get('/')->assertOk()->assertSee('Jak możemy pomóc'); $this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania'); - $this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Panel Klienta'); - $this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Panel Klienta'); - $this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Panel Klienta'); + $this->actingAs($client)->get('/client')->assertOk()->assertSee('Moje zgłoszenia')->assertSee('Klient'); + $this->actingAs($client)->get('/client/new')->assertOk()->assertSee('Klient'); + $this->actingAs($client)->get(route('client.ticket', $clientTicket))->assertOk()->assertSee($clientTicket->subject)->assertSee('Klient'); - $this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Panel Operatora'); - $this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Panel Operatora'); - $this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Panel Operatora'); + $this->actingAs($operator)->get('/operator')->assertOk()->assertSee('Otwarte')->assertSee('Operator'); + $this->actingAs($operator)->get('/operator/new')->assertOk()->assertSee($client->email)->assertSee('Operator'); + $this->actingAs($operator)->get(route('operator.ticket', $anyTicket))->assertOk()->assertSee($anyTicket->subject)->assertSee('Operator'); foreach (['categories', 'fields', 'users', 'teams', 'user-fields', 'reply-quick-actions', 'response-templates', 'statuses', 'priorities', 'templates', 'branding', 'config', 'about'] as $tab) { - $this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Panel Administratora'); + $this->actingAs($admin)->get('/admin')->assertOk()->assertSee('Administrator'); Livewire::actingAs($admin)->test(Panel::class) ->call('setTab', $tab) ->assertOk(); diff --git a/src/tests/Feature/TicketListPaginationTest.php b/src/tests/Feature/TicketListPaginationTest.php new file mode 100644 index 0000000..61b1ece --- /dev/null +++ b/src/tests/Feature/TicketListPaginationTest.php @@ -0,0 +1,80 @@ + str_pad($i, 4, '0', STR_PAD_LEFT)]); + } + + $component = Livewire::actingAs($operator)->test(Queue::class); + + expect($component->instance()->filteredTickets)->toHaveCount(50) + ->and($component->instance()->filteredTickets->total())->toBe(55); + + $component->call('gotoPage', 2); + + expect($component->instance()->filteredTickets)->toHaveCount(5); +}); + +test('changing the queue search resets the operator to page 1', function () { + seedStatusesAndPriorities(); + $operator = operatorUser('queue-page-reset@example.com'); + + foreach (range(1, 55) as $i) { + makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'subject' => 'Zgłoszenie']); + } + makeTicket(['number' => '9999', 'subject' => 'Unikalny temat']); + + $component = Livewire::actingAs($operator)->test(Queue::class)->call('gotoPage', 2); + expect($component->instance()->filteredTickets->currentPage())->toBe(2); + + $component->set('search', 'Unikalny'); + + expect($component->instance()->filteredTickets->currentPage())->toBe(1) + ->and($component->instance()->filteredTickets)->toHaveCount(1); +}); + +test('the client dashboard paginates current/archive tickets separately', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Client', 'email' => 'dash-page@example.com', 'roles' => ['client']]); + + foreach (range(1, 25) as $i) { + makeTicket(['number' => str_pad($i, 4, '0', STR_PAD_LEFT), 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'new']); + } + foreach (range(1, 3) as $i) { + makeTicket(['number' => '90'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name, 'status_key' => 'closed']); + } + + $component = Livewire::actingAs($client)->test(Dashboard::class); + + expect($component->instance()->currentTickets)->toHaveCount(20) + ->and($component->instance()->currentTickets->total())->toBe(25) + ->and($component->instance()->archiveTickets)->toHaveCount(3); +}); + +test('"Inne Twoje zgłoszenia" shows only a handful until "Pokaż wszystkie" is clicked', function () { + seedStatusesAndPriorities(); + $client = User::query()->create(['name' => 'Client', 'email' => 'other-tickets@example.com', 'roles' => ['client']]); + $viewed = makeTicket(['number' => '5000', 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + + foreach (range(1, 8) as $i) { + makeTicket(['number' => '60'.$i, 'customer_id' => $client->id, 'email' => $client->email, 'name' => $client->name]); + } + + $component = Livewire::actingAs($client)->test(TicketShow::class, ['ticket' => $viewed]); + + expect($component->instance()->otherTickets)->toHaveCount(5) + ->and($component->instance()->otherTicketsCount)->toBe(8); + + $component->call('revealAllOtherTickets'); + + expect($component->instance()->otherTickets)->toHaveCount(8); +}); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index c894723..879b2b0 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -7,7 +7,8 @@ treści (szablony, szybkie akcje, e-maile), wygląd/branding oraz integracje (LDAP, poczta SMTP/IMAP, BookStack, AI, API). Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do -panelu Administratora przez menu profilu (prawy górny róg). +panelu Administratora suwakiem Klient/Operator/Administrator na środku +górnego paska. ## Kategorie i pola dodatkowe @@ -238,10 +239,12 @@ razem, zamiast być rozrzucone po różnych zakładkach. ## Integracje -- **LDAP** — host, port, base DN, bind DN + hasło, SSL, filtr użytkownika - (`(uid={0})` domyślnie), auto-provisioning gości, ograniczenie tworzenia - kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** sprawdza - bind bez zapisywania zmian. +- **LDAP** — **Typ katalogu** (LLDAP/OpenLDAP domyślnie, albo Active + Directory — zmienia oczekiwany schemat i domyślny atrybut logowania), host, + port, base DN, bind DN + hasło, SSL, filtr użytkownika (`(uid={0})` dla + LLDAP, `(sAMAccountName={0})` dla AD), auto-provisioning gości, ograniczenie + tworzenia kont/zgłaszania tylko przez LDAP. Przycisk **„Testuj połączenie”** + sprawdza bind bez zapisywania zmian. > Po świeżej instalacji (`migrate:fresh --seed`) LDAP i SMTP zawierają > **przykładowe wartości** (`ldap.example.com`, `smtp.example.com`, diff --git a/wiki/client/README.md b/wiki/client/README.md index 0d3c6cd..263c998 100644 --- a/wiki/client/README.md +++ b/wiki/client/README.md @@ -2,8 +2,9 @@ Panel klienta (`/client`) służy do zgłaszania problemów/próśb i śledzenia ich rozwiązania. Po zalogowaniu każde konto domyślnie ląduje właśnie tutaj — nawet jeśli -posiada też uprawnienia operatora lub administratora (przełączysz się przez menu -profilu w prawym górnym rogu). Dzwoneczek powiadomień w górnym pasku pokazuje +posiada też uprawnienia operatora lub administratora (przełączysz się suwakiem +Klient/Operator/Administrator na środku górnego paska — pokazuje tylko te panele, +do których masz dostęp). Dzwoneczek powiadomień w górnym pasku pokazuje tylko **nieprzeczytane** powiadomienia — kliknięcie usuwa je z listy. ## Zgłaszanie nowej sprawy @@ -43,7 +44,10 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki: - **Archiwum** — zgłoszenia zamknięte. Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz -odpowiedzi w wątku). +odpowiedzi w wątku). Obie zakładki są stronicowane (20 zgłoszeń na stronę), +niezależnie od siebie — przełączenie zakładki nie cofa Cię na pierwszą stronę +drugiej. **Ctrl+K**/**Cmd+K** otwiera też globalną wyszukiwarkę zgłoszeń z +dowolnego miejsca w aplikacji (tylko Twoich zgłoszeń). Otwórz dowolne zgłoszenie, by zobaczyć: diff --git a/wiki/operator/README.md b/wiki/operator/README.md index e54af4e..0a14942 100644 --- a/wiki/operator/README.md +++ b/wiki/operator/README.md @@ -4,7 +4,8 @@ Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd, odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu. Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do -panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę. +panelu Operatora suwakiem Klient/Operator/Administrator na środku górnego +paska, jeśli konto ma tę rolę. Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje Twoje **nieprzeczytane** powiadomienia, aktualizowane **na żywo** w chwili ich utworzenia (niezależny od tego 30-sekundowy fallback dogrywa to, co ominęłoby @@ -31,7 +32,20 @@ Panel główny (`/operator`) pokazuje listę zgłoszeń z zakładkami po lewej s widzi wszystkie zespoły; też bez zamkniętych). Nie-administratorzy widzą tylko zgłoszenia swoich zespołów, zgłoszenia bez -przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich. +przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich. Lista +jest stronicowana (50 zgłoszeń na stronę) — zmiana filtra/wyszukiwania/ +zakładki wraca automatycznie na pierwszą stronę. + +Na pasku bocznym, nad zakładkami: przycisk **„Szukaj”** otwiera globalną +wyszukiwarkę zgłoszeń (dostępną z każdej strony też pod **Ctrl+K**/**Cmd+K**) +— oprócz zwykłego szukania po treści rozumie też operatory `od:`, `temat:`, +`treść:` i `numer:` (np. `od:kowalski temat:drukarka`), które można łączyć. +Niżej — **„Ostatnio przeglądane”**: ostatnie 6 zgłoszeń, które faktycznie +otworzyłeś, najnowsze na górze. + +**Klienci** (przycisk nad tabelą) — osobna wyszukiwarka po imieniu/nazwisku/ +e-mailu dowolnego konta (nie tylko klientów), pokazuje liczbę jego zgłoszeń i +prowadzi od razu do kolejki przefiltrowanej do tego klienta. **Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/ temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie