From 90fae0a4de10f1688d8fa593f13191939377cf5e Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 22 Jul 2026 15:18:09 +0200 Subject: [PATCH] v1.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In-app notifications: a bell in the top bar backed by Laravel's database notification channel, alongside existing e-mail notifications (same per-trigger toggle drives both; ticket links now correctly point into the recipient's own area instead of always linking to the client view). - Drag-and-drop attachments on every upload form, plus inline image thumbnails in the message thread instead of a plain download link. - Customer satisfaction (CSAT) rating: clients rate a closed ticket 1-5 stars with an optional comment; shown read-only to operators, surfaced as a KPI on the stats dashboard, and linked from the "ticket closed" e-mail. - Saved queue views: operators can save/apply/delete named filter+sort+ column presets in the ticket queue and mark one as their default. - Full-text search (MySQL FULLTEXT, portable LIKE fallback) across ticket subject/body and reply message bodies, now also on the client's own ticket list. - Stats CSV export for the currently filtered ticket set. - Optional BookStack knowledge-base integration (off by default): suggests articles by category/subcategory while creating a ticket and in a separate sidebar for operators on an existing ticket (with a copy-link button). Configurable connection/SSL bypass/search-type filter, plus two independent per-shelf allow-lists so nothing is ever searched until an admin opts specific shelves in. - Closed tickets no longer show in "Moje zgłoszenia"/"Nieprzypisane"/team queue tabs, only under "Zamknięte" (matching how "Otwarte" already worked). - Wired up the Admin > About "Wersja" field to config('app.version')/VERSION in .env instead of a stale hardcoded string. - Fixed: TicketService::setStatus() now checks a status's stage rather than the literal key 'closed' to decide whether to fire the "ticket closed" notification/stop the timer. - Updated README/ARCHITECTURE/CHANGELOG/install/SECURITY docs and all three wiki/ role guides for the above; documented a root-vs-www-data file ownership gotcha in CLAUDE.md (running artisan commands via a plain `docker exec` can leave root-owned Blade cache files that later break recompilation for the www-data Apache process). Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 53 +++- CHANGELOG.md | 50 ++++ CLAUDE.md | 12 + README.md | 26 +- SECURITY.md | 10 + install.md | 14 +- src/.env.example | 2 +- src/app/Livewire/Admin/Panel.php | 106 ++++++- src/app/Livewire/Client/Dashboard.php | 3 + src/app/Livewire/Client/NewTicket.php | 12 + src/app/Livewire/Client/TicketShow.php | 12 + src/app/Livewire/Landing.php | 22 ++ src/app/Livewire/NotificationBell.php | 39 +++ src/app/Livewire/Operator/NewTicket.php | 12 + src/app/Livewire/Operator/Queue.php | 143 ++++++++- src/app/Livewire/Operator/Stats.php | 52 ++++ src/app/Livewire/Operator/TicketShow.php | 13 + src/app/Models/SavedQueueView.php | 24 ++ src/app/Models/Ticket.php | 59 ++++ src/app/Models/User.php | 5 + src/app/Notifications/TicketNotification.php | 36 ++- src/app/Providers/AppServiceProvider.php | 5 +- src/app/Services/BookStackClient.php | 279 ++++++++++++++++++ src/app/Services/TicketService.php | 36 ++- src/app/Support/Settings.php | 11 +- src/config/app.php | 12 + ...7_22_000140_create_notifications_table.php | 25 ++ ...141_extend_tickets_for_csat_and_search.php | 45 +++ ..._000142_create_saved_queue_views_table.php | 25 ++ ...add_csat_link_to_closed_email_template.php | 52 ++++ src/database/seeders/DatabaseSeeder.php | 3 +- src/resources/css/app.css | 8 + .../bookstack-suggestions.blade.php | 55 ++++ .../components/message-attachment.blade.php | 30 +- .../views/components/topbar.blade.php | 4 + .../views/livewire/admin/panel.blade.php | 82 ++++- .../views/livewire/client/dashboard.blade.php | 9 +- .../livewire/client/new-ticket.blade.php | 13 +- .../livewire/client/ticket-show.blade.php | 37 ++- .../views/livewire/landing.blade.php | 3 + .../livewire/notification-bell.blade.php | 36 +++ .../livewire/operator/new-ticket.blade.php | 13 +- .../views/livewire/operator/queue.blade.php | 28 ++ .../views/livewire/operator/stats.blade.php | 10 + .../livewire/operator/ticket-show.blade.php | 61 +++- .../Feature/OperatorQueueClosedTabTest.php | 49 ++- wiki/admin/README.md | 28 +- wiki/client/README.md | 25 +- wiki/operator/README.md | 39 ++- 49 files changed, 1649 insertions(+), 79 deletions(-) create mode 100644 src/app/Livewire/NotificationBell.php create mode 100644 src/app/Models/SavedQueueView.php create mode 100644 src/app/Services/BookStackClient.php create mode 100644 src/database/migrations/2026_07_22_000140_create_notifications_table.php create mode 100644 src/database/migrations/2026_07_22_000141_extend_tickets_for_csat_and_search.php create mode 100644 src/database/migrations/2026_07_22_000142_create_saved_queue_views_table.php create mode 100644 src/database/migrations/2026_07_22_000143_add_csat_link_to_closed_email_template.php create mode 100644 src/resources/views/components/bookstack-suggestions.blade.php create mode 100644 src/resources/views/livewire/notification-bell.blade.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a4dd703..59321f0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,14 +38,23 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields ├──< TicketHistory ├── customer/assignee → User ├── status → Status (fixed stages: new/open/closed) - └── priority → Priority → SlaRule (response/resolution minutes) + ├── priority → Priority → SlaRule (response/resolution minutes) + └── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close) User ─< UserFieldValue >─ UserField +User ─< SavedQueueView (operator's own saved queue filter/sort/column presets) +User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user') ApiClient (Sanctum token owner, ability-scoped) Setting (single-row-per-key config store, see below) ReplyQuickAction, ResponseTemplate, EmailTemplate, NotificationSetting ``` +`tickets.subject`/`tickets.body` and `ticket_messages.body` carry a MySQL/MariaDB +`FULLTEXT` index (added in a later migration, MySQL-only — absent on the sqlite +connection the test suite runs on) — `Ticket::scopeSearch()` uses +`whereFullText()` when the active connection is `mysql` and falls back to a +portable `LIKE` otherwise, so the same call site works in both places. + `Ticket` (`app/Models/Ticket.php`) is the largest model — it owns SLA math (`slaInfo()`, `isOverdue()`, `resolutionDeadline()`), status/priority display helpers (`statusLabel()`, `tagStyleFromColor()`), operator-visibility scoping @@ -91,6 +100,24 @@ source of the "seeded placeholder overrides real `.env` values" gotcha documented in [install.md](install.md) — anything touching LDAP/mail/session/ timezone config should go through `Settings`, not raw `config()`/`.env` reads. +## Notifications + +`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single +fan-out point for every ticket lifecycle event (see the `NotificationSetting` +rows seeded per trigger key) — it resolves the configured recipient +(`$ticket->assignee` or `$ticket->customer`) to a real `User` when one exists +and calls `$user->notify(new TicketNotification(...))`, which fires **both** +the `mail` and `database` channels (`App\Notifications\TicketNotification`) — +there's no separate on/off switch for in-app vs. e-mail, the same +`NotificationSetting.enabled` flag gates both. A guest customer with no +account still gets routed anonymously (`Notification::route('mail', $email)`, +mail-only — the database channel needs a real notifiable to attach the row +to). `TicketNotification` is constructed with an explicit `$recipientRole` +('client'|'operator') rather than inferring it from the notifiable's roles, +since one account can hold both — this decides whether the ticket link (both +the e-mail body and the in-app notification's `url`) points into `/client/...` +or `/operator/...`. + ## SLA `SlaRule` holds per-priority response/resolution targets in minutes. The @@ -110,3 +137,27 @@ tighter per-IP limit for unauthenticated requests (`AppServiceProvider::configureApiRateLimiting()`). Interactive docs are generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API reference in-repo. + +## BookStack integration + +`App\Services\BookStackClient` is the only outbound HTTP client in the +codebase (Laravel's `Http` facade) — everything else here only ever receives +requests. It's entirely `Settings`-driven, no `.env`/`config()` involved: +`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/ +`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords), +`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and +**two independent** allow-lists of BookStack shelf IDs — +`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and +`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an +existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` / +`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list +means "search nothing"**, not "search everything" — nothing is ever +suggested until an admin explicitly opts shelves in, independently per +context. BookStack has no "which shelf is this book on" field in its own +search response, so `BookStackClient` fetches `/api/shelves` + +`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both +to resolve the allow-list to book IDs and to build the "Shelf > Book" +breadcrumb shown next to each suggestion. Per-query search results are cached +10 minutes, keyed on the query text **and** the active allow-list, so toggling +which shelves are allowed is reflected immediately instead of serving a +pre-change result for up to 10 minutes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b43c8..84cd210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,56 @@ 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.1.0] - 2026-07-22 + +### Added + +- **In-app notifications** — a bell in the top bar backed by Laravel's + database notification channel, alongside existing e-mail notifications + (same per-trigger toggle drives both; ticket links now correctly point into + the recipient's own area instead of always linking to the client view). +- **Drag-and-drop attachments** on every upload form (ticket creation, replies, + internal notes), plus inline image thumbnails in the message thread instead + of a plain download link. +- **Customer satisfaction (CSAT)** rating — clients rate a closed ticket 1–5 + stars with an optional comment; shown read-only to operators, surfaced as a + KPI on the stats dashboard, and linked from the "ticket closed" e-mail. +- **Saved queue views** — operators can save/apply/delete named filter+sort+ + column presets in the ticket queue and mark one as their default. +- **Full-text search** — MySQL/MariaDB `FULLTEXT` search (portable `LIKE` + fallback on sqlite) across ticket subject/body and reply message bodies, now + also available on the client's own ticket list (previously operator-only, + and previously subject/number/name/email only). +- **Stats CSV export** — exports the currently filtered ticket set from the + operator stats dashboard. +- **BookStack knowledge-base integration**, optional and off by default — + suggests relevant articles by category/subcategory while creating a ticket, + and in a separate sidebar on an existing ticket for operators (with a + copy-link button). Configurable from Admin > Konfiguracja: connection + API + token (encrypted), optional SSL-verification bypass for self-signed + instances, page/book search-type filter, and two independent per-shelf + allow-lists (nothing is ever searched until specific shelves are opted in, + separately for ticket-creation suggestions vs. the operator sidebar) with a + manual refresh button for the shelf list. + +### Changed + +- Closed tickets are no longer shown in the "Moje zgłoszenia" / "Nieprzypisane" + / per-team queue tabs — they now only ever appear under "Zamknięte", matching + how the "Otwarte" tab already worked. +- The operator ticket-view sidebar is ~50% wider (to fit the BookStack + suggestions panel); the page itself grew to match, so the ticket + content/thread column keeps its previous width. +- The "Resetuj" work-timer button sits below the "Zgłoszenie zamknięte — + zliczanie wstrzymane" notice instead of beside it. + +### Fixed + +- `TicketService::setStatus()` now checks a status's `stage` (via + `Status::stageFor()`) rather than the literal key `'closed'` to decide + whether to fire the "ticket closed" notification/stop the timer — correct + even if an admin renames or replaces which key maps to the closed stage. + ## [1.0.2] - 2026-07-22 - Fixed `.gitea/workflows/build.yml`: registry login was failing with diff --git a/CLAUDE.md b/CLAUDE.md index f3b4ff1..88ba348 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,18 @@ with no rebuild or restart: `view:cache`), clear them again afterward (`config:clear`/`view:clear`) — this app normally runs uncached so edits apply live; leaving a cache on silently breaks that workflow. +- **`sudo docker exec` runs as `root`, not `www-data`.** Apache's worker + processes run as `www-data`; anything you run via a plain `docker exec` + (`php artisan test`, `tinker`, `view:cache`, etc.) runs as `root`. On this + NFS-backed mount, a file Blade compiles/caches while running as `root` + (`storage/framework/views/*.php`) can't later be overwritten by `www-data` + when a real request needs to recompile it (the source changed) — this + 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 + 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. ## Apache `/icons/` alias trap diff --git a/README.md b/README.md index c4aed18..fd6d2f1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,30 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides. categories/statuses/priorities/teams — issued via admin-managed API clients. Interactive docs (L5-Swagger) at `/admin/api-docs`. - **PWA** — installable manifest + icons for the client-facing area. +- **In-app notifications** — a bell in the top bar (client/operator/admin areas) + backed by Laravel's database notification channel, alongside the existing + e-mail notifications (same per-trigger enable toggle drives both). +- **Attachments** — drag-and-drop upload (in addition to the file picker) and + inline image thumbnails in the message thread instead of a plain download link. +- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional + comment) once it's closed; average/response-rate surfaced as a KPI on the + operator stats dashboard, with a link in the "ticket closed" e-mail. +- **Saved queue views** — operators can save their current filter/sort/column + combination in the ticket queue, mark one as default, and switch between them. +- **Full-text search** — MySQL/MariaDB `FULLTEXT` search (with a portable `LIKE` + fallback) across ticket subject/body and reply message bodies, available in + both the operator queue and the client's own ticket list. +- **Stats export** — the operator stats dashboard can export the currently + filtered ticket set as CSV. +- **BookStack knowledge-base integration** *(optional, off by default)* — + suggests relevant BookStack articles by category/subcategory while a ticket + is being created, and in a separate sidebar panel on an existing ticket for + operators (with a copy-link button). Configured entirely from Admin > + Konfiguracja: connection + API token, optional SSL-verification bypass for + self-signed instances, page/book search-type filter, and two independent + per-shelf allow-lists (nothing is searched until an admin opts specific + shelves in, separately for ticket-creation suggestions vs. the operator + sidebar). ## Tech stack @@ -104,7 +128,7 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed. src/ Laravel application app/Livewire/ Client/Operator/Admin Livewire components app/Models/ Eloquent models - app/Services/ TicketService (ticket lifecycle + notifications) + app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient app/Ldap/ LDAP user model + sync handlers database/migrations/ Schema (one file per table group, final shape) database/seeders/ DatabaseSeeder — reference data, no ticket data diff --git a/SECURITY.md b/SECURITY.md index 32eb4b8..9c0c85a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,6 +45,16 @@ credential-equivalent. - **TLS**: production traffic terminates at Traefik with a private CA certificate (not publicly trusted) — this is expected for this deployment, not a misconfiguration. +- **BookStack integration** (`App\Services\BookStackClient`, optional, off by + default): the only outbound HTTP client in the codebase. The target + `bookstack_base_url` and the SSL-verification bypass (`bookstack_verify_ssl`) + are both admin-configurable — restrict Admin-role accounts accordingly, same + reasoning as the LDAP/SMTP settings override above (a compromised admin + account could point it at an arbitrary host, or disable TLS verification + against one). The API token secret is stored encrypted (same as the LDAP + bind/SMTP passwords). Nothing is ever searched/suggested until an admin + explicitly allow-lists specific BookStack shelves — the default (no shelves + allowed) returns no results without making any outbound request. ## Dependencies diff --git a/install.md b/install.md index 0c03363..b263bf5 100644 --- a/install.md +++ b/install.md @@ -74,7 +74,7 @@ APP_LOCALE=pl APP_FALLBACK_LOCALE=pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji -VERSION=1.0.2 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane +VERSION=1.1.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane DB_CONNECTION=mysql DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 @@ -234,6 +234,16 @@ użytku: 3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz polegać na logowaniu przez katalog. +### Integracje opcjonalne (BookStack) + +Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w +panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w +`.env` — całość konfiguruje się w **Admin > Konfiguracja**: adres instancji, +Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku +uprawnienie „Access System API”), oraz osobne listy dozwolonych półek dla +podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna +półka nie jest zaznaczona, wyszukiwanie nic nie zwraca. + --- ## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera) @@ -275,7 +285,7 @@ APP_LOCALE=pl APP_FALLBACK_LOCALE=pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl -VERSION=1.0.2 +VERSION=1.1.0 DB_CONNECTION=mysql DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB diff --git a/src/.env.example b/src/.env.example index e89b668..3417871 100644 --- a/src/.env.example +++ b/src/.env.example @@ -5,7 +5,7 @@ APP_DEBUG=true APP_URL=http://localhost AUTHOR_CONTACT=helpdesk@kzbikowski.pl -VERSION=1.0.2 +VERSION=1.1.0 APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/src/app/Livewire/Admin/Panel.php b/src/app/Livewire/Admin/Panel.php index 1d3869b..0559f54 100644 --- a/src/app/Livewire/Admin/Panel.php +++ b/src/app/Livewire/Admin/Panel.php @@ -15,8 +15,10 @@ use App\Models\Subcategory; use App\Models\Team; use App\Models\User; use App\Models\UserField; +use App\Services\BookStackClient; use App\Services\LdapUserProvisioner; use App\Support\Settings; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Mail; @@ -137,6 +139,12 @@ class Panel extends Component public ?string $mailTestResult = null; + public array $bookstackConfig = []; + + public ?string $bookstackTestResult = null; + + public ?string $bookstackTestMessage = null; + // ---- generic pending-delete confirm ---- public ?string $pendingDeleteType = null; @@ -187,6 +195,18 @@ class Panel extends Component 'fromAddress' => Settings::get('mail_from_address'), 'fromName' => Settings::get('mail_from_name'), ]; + + $this->bookstackConfig = [ + 'enabled' => Settings::bool('bookstack_enabled'), + 'baseUrl' => Settings::get('bookstack_base_url'), + 'tokenId' => Settings::get('bookstack_token_id'), + 'tokenSecret' => Settings::get('bookstack_token_secret'), + 'verifySsl' => Settings::bool('bookstack_verify_ssl'), + 'showToGuests' => Settings::bool('bookstack_show_to_guests'), + 'searchTypes' => Settings::get('bookstack_search_types', 'both'), + 'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')), + 'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')), + ]; } public function setTab(string $tab): void @@ -965,7 +985,7 @@ class Panel extends Component * key-primary-keyed, sort_order-ordered lists edited the same way: swap * this row's sort_order with its immediate neighbor in the given direction. * - * @param \Illuminate\Support\Collection $ordered + * @param Collection $ordered */ protected function swapAdjacentSortOrder($ordered, string $key, int $direction): void { @@ -1288,6 +1308,90 @@ class Panel extends Component } } + // ===================== BOOKSTACK CONFIG ===================== + + public function saveBookstackConfig(): void + { + Settings::set('bookstack_enabled', $this->bookstackConfig['enabled'] ? '1' : '0'); + Settings::set('bookstack_base_url', $this->bookstackConfig['baseUrl']); + Settings::set('bookstack_token_id', $this->bookstackConfig['tokenId']); + + if ($this->bookstackConfig['tokenSecret']) { + Settings::set('bookstack_token_secret', $this->bookstackConfig['tokenSecret']); + } + + Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0'); + Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0'); + + if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) { + Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']); + } + + Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation'])); + Settings::set('bookstack_allowed_shelf_ids_ticket_view', implode(',', $this->bookstackConfig['allowedShelfIdsTicketView'])); + + $this->bookstackTestResult = null; + $this->bookstackTestMessage = null; + } + + #[Computed] + public function bookstackShelves(): array + { + return app(BookStackClient::class)->shelves(); + } + + /** + * Drops the cached shelf list (and its dependent shelf>book map) so a + * shelf added/renamed/removed in BookStack shows up in both checklists + * right away, then re-fetches — shared by both "Dozwolone półki" + * checklists since they list the exact same shelves. + */ + public function refreshBookstackShelves(): void + { + app(BookStackClient::class)->clearShelfCache(); + unset($this->bookstackShelves); + } + + /** + * @return int[] + */ + protected function parseShelfIds(string $raw): array + { + return collect(explode(',', $raw))->map(fn ($v) => (int) trim($v))->filter()->values()->all(); + } + + /** + * $field is 'allowedShelfIdsCreation' or 'allowedShelfIdsTicketView' — + * the two independent allow-lists (ticket-creation suggestions vs. the + * operator ticket-view sidebar) toggled by their own checklist. + */ + public function toggleBookstackAllowedShelf(string $field, int $id): void + { + $ids = $this->bookstackConfig[$field]; + + $this->bookstackConfig[$field] = in_array($id, $ids, true) + ? array_values(array_diff($ids, [$id])) + : [...$ids, $id]; + } + + public function testBookstackConnection(): void + { + $cfg = $this->bookstackConfig; + + if (empty($cfg['baseUrl']) || empty($cfg['tokenId'])) { + $this->bookstackTestResult = 'error'; + $this->bookstackTestMessage = 'Uzupełnij adres instancji i Token ID.'; + + return; + } + + $tokenSecret = $cfg['tokenSecret'] ?: Settings::get('bookstack_token_secret'); + $result = app(BookStackClient::class)->testConnection($cfg['baseUrl'], $cfg['tokenId'], $tokenSecret ?? '', (bool) $cfg['verifySsl']); + + $this->bookstackTestResult = $result['ok'] ? 'ok' : 'error'; + $this->bookstackTestMessage = $result['message']; + } + // ===================== MAIL / SMTP CONFIG ===================== public function saveMailConfig(): void diff --git a/src/app/Livewire/Client/Dashboard.php b/src/app/Livewire/Client/Dashboard.php index 6797b55..33565ea 100644 --- a/src/app/Livewire/Client/Dashboard.php +++ b/src/app/Livewire/Client/Dashboard.php @@ -11,10 +11,13 @@ class Dashboard extends Component { public string $tab = 'current'; + public string $search = ''; + #[Computed] public function tickets() { return Auth::user()->ticketsAsCustomer() + ->search($this->search) ->with('subcategory.category') ->orderByDesc('updated_at') ->get(); diff --git a/src/app/Livewire/Client/NewTicket.php b/src/app/Livewire/Client/NewTicket.php index 65ac54b..7902c01 100644 --- a/src/app/Livewire/Client/NewTicket.php +++ b/src/app/Livewire/Client/NewTicket.php @@ -4,6 +4,7 @@ namespace App\Livewire\Client; use App\Models\Category; use App\Models\Subcategory; +use App\Services\BookStackClient; use App\Services\TicketService; use App\Support\Settings; use Illuminate\Support\Facades\Auth; @@ -60,6 +61,17 @@ class NewTicket extends Component $this->step = 3; } + /** + * @return array + */ + #[Computed] + public function suggestedArticles(): array + { + $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); + + return app(BookStackClient::class)->search($query); + } + public function backToCategory(): void { $this->step = 1; diff --git a/src/app/Livewire/Client/TicketShow.php b/src/app/Livewire/Client/TicketShow.php index 62e1a6d..e762273 100644 --- a/src/app/Livewire/Client/TicketShow.php +++ b/src/app/Livewire/Client/TicketShow.php @@ -27,6 +27,10 @@ class TicketShow extends Component public ?int $pendingDeleteMessageId = null; + public ?int $csatRating = null; + + public string $csatComment = ''; + public function mount(Ticket $ticket): void { abort_unless($ticket->customer_id === Auth::id(), 403); @@ -86,6 +90,14 @@ class TicketShow extends Component $this->ticket->refresh(); } + public function submitCsat(): void + { + $this->validate(['csatRating' => 'required|integer|between:1,5']); + + app(TicketService::class)->submitCsat($this->ticket, $this->csatRating, $this->csatComment ?: null); + $this->ticket->refresh(); + } + public function startEdit(int $messageId, string $body): void { $message = TicketMessage::query()->findOrFail($messageId); diff --git a/src/app/Livewire/Landing.php b/src/app/Livewire/Landing.php index d24ae74..5214623 100644 --- a/src/app/Livewire/Landing.php +++ b/src/app/Livewire/Landing.php @@ -6,6 +6,7 @@ use App\Models\Category; use App\Models\Subcategory; use App\Models\Ticket; use App\Models\User; +use App\Services\BookStackClient; use App\Services\LdapUserProvisioner; use App\Services\TicketService; use App\Support\Settings; @@ -72,6 +73,27 @@ class Landing extends Component $this->step = 3; } + /** + * Unlike the logged-in Client/Operator wizards, this guest-facing form + * only shows suggestions when the admin has explicitly opted into + * exposing them to anonymous visitors (bookstack_show_to_guests) — + * suggested KB article titles/links could otherwise leak internal + * content to the public. + * + * @return array + */ + #[Computed] + public function suggestedArticles(): array + { + if (! Settings::bool('bookstack_show_to_guests')) { + return []; + } + + $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); + + return app(BookStackClient::class)->search($query); + } + public function backToCategory(): void { $this->step = 1; diff --git a/src/app/Livewire/NotificationBell.php b/src/app/Livewire/NotificationBell.php new file mode 100644 index 0000000..9037a63 --- /dev/null +++ b/src/app/Livewire/NotificationBell.php @@ -0,0 +1,39 @@ +notifications()->latest()->limit(20)->get(); + } + + #[Computed] + public function unreadCount(): int + { + return Auth::user()->unreadNotifications()->count(); + } + + public function markAsRead(string $id): void + { + Auth::user()->notifications()->where('id', $id)->first()?->markAsRead(); + unset($this->notifications, $this->unreadCount); + } + + public function markAllAsRead(): void + { + Auth::user()->unreadNotifications->each->markAsRead(); + unset($this->notifications, $this->unreadCount); + } + + public function render() + { + return view('livewire.notification-bell'); + } +} diff --git a/src/app/Livewire/Operator/NewTicket.php b/src/app/Livewire/Operator/NewTicket.php index af51fd3..0be8ec0 100644 --- a/src/app/Livewire/Operator/NewTicket.php +++ b/src/app/Livewire/Operator/NewTicket.php @@ -5,6 +5,7 @@ namespace App\Livewire\Operator; use App\Models\Category; use App\Models\Subcategory; use App\Models\User; +use App\Services\BookStackClient; use App\Services\TicketService; use App\Support\Settings; use Illuminate\Support\Facades\Auth; @@ -69,6 +70,17 @@ class NewTicket extends Component $this->step = 3; } + /** + * @return array + */ + #[Computed] + public function suggestedArticles(): array + { + $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); + + return app(BookStackClient::class)->search($query); + } + public function backToCategory(): void { $this->step = 1; diff --git a/src/app/Livewire/Operator/Queue.php b/src/app/Livewire/Operator/Queue.php index aeb1f61..86c0efa 100644 --- a/src/app/Livewire/Operator/Queue.php +++ b/src/app/Livewire/Operator/Queue.php @@ -41,6 +41,125 @@ class Queue extends Component public bool $pendingDeleteSelected = false; + #[Url] + public ?int $savedViewId = null; + + public string $newViewName = ''; + + /** + * A bare visit (no explicit ?savedViewId=... in the URL, i.e. Livewire + * never bound one) auto-applies the operator's default saved view, if + * they have one — an explicit savedViewId in the URL always wins. + */ + public function mount(): void + { + if ($this->savedViewId !== null) { + return; + } + + $default = Auth::user()->savedQueueViews()->where('is_default', true)->first(); + + if ($default) { + $this->applyViewFilters($default->filters); + $this->savedViewId = $default->id; + } + } + + #[Computed] + public function savedViews() + { + return Auth::user()->savedQueueViews()->orderBy('name')->get(); + } + + /** + * @return array + */ + protected function snapshotFilters(): array + { + return [ + 'queue' => $this->queue, + 'filterStatus' => $this->filterStatus, + 'filterPriority' => $this->filterPriority, + 'filterCategory' => $this->filterCategory, + 'search' => $this->search, + 'sortBy' => $this->sortBy, + 'sortDir' => $this->sortDir, + 'visibleColumns' => $this->visibleColumns, + ]; + } + + protected function applyViewFilters(array $filters): void + { + $this->queue = $filters['queue'] ?? $this->queue; + $this->filterStatus = $filters['filterStatus'] ?? $this->filterStatus; + $this->filterPriority = $filters['filterPriority'] ?? $this->filterPriority; + $this->filterCategory = $filters['filterCategory'] ?? $this->filterCategory; + $this->search = $filters['search'] ?? $this->search; + $this->sortBy = $filters['sortBy'] ?? $this->sortBy; + $this->sortDir = $filters['sortDir'] ?? $this->sortDir; + $this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns; + $this->selectedIds = []; + } + + public function saveCurrentView(): void + { + $name = trim($this->newViewName); + + if ($name === '') { + return; + } + + $view = Auth::user()->savedQueueViews()->create([ + 'name' => $name, + 'filters' => $this->snapshotFilters(), + ]); + + $this->savedViewId = $view->id; + $this->newViewName = ''; + unset($this->savedViews); + } + + /** + * Always scoped to the current user (never a bare SavedQueueView::find()) + * — savedViewId/id args here are client-controllable, same defensive + * pattern as selectedIdsInScope() for bulk ticket actions. + */ + public function applySavedView(int $id): void + { + $view = Auth::user()->savedQueueViews()->find($id); + + if (! $view) { + return; + } + + $this->applyViewFilters($view->filters); + $this->savedViewId = $view->id; + } + + public function deleteSavedView(int $id): void + { + Auth::user()->savedQueueViews()->where('id', $id)->delete(); + + if ($this->savedViewId === $id) { + $this->savedViewId = null; + } + + unset($this->savedViews); + } + + public function setDefaultView(int $id): void + { + $view = Auth::user()->savedQueueViews()->find($id); + + if (! $view) { + return; + } + + Auth::user()->savedQueueViews()->where('id', '!=', $id)->update(['is_default' => false]); + $view->update(['is_default' => true]); + unset($this->savedViews); + } + #[Computed] public function statuses() { @@ -55,9 +174,9 @@ class Queue extends Component #[Computed] public function filterableStatuses() { - return $this->queue === 'all' - ? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed') - : $this->statuses; + return $this->queue === 'closed' + ? $this->statuses + : $this->statuses->reject(fn (Status $s) => $s->stage === 'closed'); } #[Computed] @@ -101,15 +220,15 @@ class Queue extends Component $defs = [ 'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)], - 'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())], - 'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')], + 'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())->whereNotIn('status_key', $closedKeys)], + 'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')->whereNotIn('status_key', $closedKeys)], 'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)], ]; foreach ($this->teams as $team) { $defs['team:'.$team->id] = [ 'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły', - 'filter' => fn ($q) => $q->where('team_id', $team->id), + 'filter' => fn ($q) => $q->where('team_id', $team->id)->whereNotIn('status_key', $closedKeys), ]; } @@ -155,11 +274,7 @@ class Queue extends Component $query->where('customer_id', $this->filterCustomerId); } if (trim($this->search) !== '') { - $term = '%'.trim($this->search).'%'; - $query->where(fn ($q) => $q->where('number', 'like', $term) - ->orWhere('subject', 'like', $term) - ->orWhere('name', 'like', $term) - ->orWhere('email', 'like', $term)); + $query->search($this->search); } $tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get(); @@ -251,7 +366,11 @@ class Queue extends Component $this->queue = $key; $this->selectedIds = []; - if ($key === 'all' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') { + // Every tab except "closed" now excludes closed-stage tickets (see + // queueDefs()), so a stale closed-stage status filter would silently + // zero out the list on any other tab — clear it on every tab switch + // away from "closed", not just when landing on "all". + if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') { $this->filterStatus = 'all'; } } diff --git a/src/app/Livewire/Operator/Stats.php b/src/app/Livewire/Operator/Stats.php index 62b848c..4ebfb5b 100644 --- a/src/app/Livewire/Operator/Stats.php +++ b/src/app/Livewire/Operator/Stats.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB; use Livewire\Attributes\Computed; use Livewire\Attributes\Url; use Livewire\Component; +use Symfony\Component\HttpFoundation\StreamedResponse; class Stats extends Component { @@ -152,6 +153,23 @@ class Stats extends Component 'avgFirstResponseHours' => $this->avgFirstResponseHours(), 'avgResolutionHours' => $this->avgResolutionHours($closedKeys), 'sla' => $this->slaBreachStats($closedKeys), + 'csat' => $this->csatStats($closedKeys), + ]; + } + + /** + * Response rate is against closed tickets (the only ones that can ever + * be rated — see Ticket::csatSubmittable()), not the whole filtered set. + */ + protected function csatStats(array $closedKeys): array + { + $closedTotal = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count(); + $rated = (clone $this->baseQuery)->whereNotNull('csat_rating')->get(['csat_rating']); + + return [ + 'avg' => $rated->isEmpty() ? null : round($rated->avg('csat_rating'), 2), + 'count' => $rated->count(), + 'responseRate' => $closedTotal > 0 ? round($rated->count() / $closedTotal * 100, 1) : null, ]; } @@ -387,6 +405,40 @@ class Stats extends Component $this->range = $range; } + /** + * Row-per-ticket CSV of everything the active filters/date range + * currently show — streamed directly, no temp file, no new dependency. + */ + public function export(): StreamedResponse + { + $tickets = (clone $this->baseQuery) + ->with(['subcategory.category', 'assignee', 'team', 'status', 'priority']) + ->orderBy('tickets.created_at') + ->get(); + + return response()->streamDownload(function () use ($tickets) { + $out = fopen('php://output', 'w'); + fputcsv($out, ['Numer', 'Temat', 'Status', 'Priorytet', 'Kategoria', 'Zespół', 'Operator', 'Utworzono', 'Zaktualizowano', 'Ocena CSAT'], escape: '\\'); + + foreach ($tickets as $ticket) { + fputcsv($out, [ + $ticket->number, + $ticket->subject, + $ticket->statusLabel(), + $ticket->priorityLabel(), + $ticket->categoryLabel(), + $ticket->team?->name, + $ticket->assignee?->name, + $ticket->created_at, + $ticket->updated_at, + $ticket->csat_rating, + ], escape: '\\'); + } + + fclose($out); + }, 'statystyki-'.now()->format('Y-m-d').'.csv'); + } + public function render() { return view('livewire.operator.stats'); diff --git a/src/app/Livewire/Operator/TicketShow.php b/src/app/Livewire/Operator/TicketShow.php index 7fac847..25bdde6 100644 --- a/src/app/Livewire/Operator/TicketShow.php +++ b/src/app/Livewire/Operator/TicketShow.php @@ -12,6 +12,7 @@ use App\Models\Team; use App\Models\Ticket; use App\Models\TicketMessage; use App\Models\User; +use App\Services\BookStackClient; use App\Services\TicketService; use App\Support\Settings; use Illuminate\Support\Facades\Auth; @@ -185,6 +186,18 @@ class TicketShow extends Component return Category::query()->with('subcategories')->get(); } + /** + * @return array + */ + #[Computed] + public function suggestedArticles(): array + { + $subcategory = $this->ticket->subcategory; + $query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? '')); + + return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW); + } + /** * A non-admin operator can only reassign a ticket to one of their own * teams (mirrors the visibility scoping in Operator\Queue). diff --git a/src/app/Models/SavedQueueView.php b/src/app/Models/SavedQueueView.php new file mode 100644 index 0000000..dcf6505 --- /dev/null +++ b/src/app/Models/SavedQueueView.php @@ -0,0 +1,24 @@ + 'array', + 'is_default' => 'boolean', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/src/app/Models/Ticket.php b/src/app/Models/Ticket.php index 5e50e5f..6619f02 100644 --- a/src/app/Models/Ticket.php +++ b/src/app/Models/Ticket.php @@ -8,11 +8,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; #[Fillable([ 'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body', 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at', + 'csat_rating', 'csat_comment', 'csat_rated_at', ])] class Ticket extends Model { @@ -23,6 +25,8 @@ class Ticket extends Model 'sla_notified_at' => 'datetime', 'time_spent_seconds' => 'integer', 'timer_started_at' => 'datetime', + 'csat_rating' => 'integer', + 'csat_rated_at' => 'datetime', ]; } @@ -129,6 +133,46 @@ class Ticket extends Model || $this->assignee_id === $user->id; } + /** + * Matches ticket number/subject/name/email plus subject/body and reply + * body text. Uses MySQL FULLTEXT (natural-language mode) on MySQL/MariaDB + * — matching the indexes added in the 2026_07_22_000141 migration — and + * falls back to plain LIKE on sqlite (used by the test suite), which has + * no FULLTEXT equivalent. + */ + public function scopeSearch(Builder $query, string $term): Builder + { + $term = trim($term); + + if ($term === '') { + return $query; + } + + $mysql = DB::connection()->getDriverName() === 'mysql'; + $like = '%'.$term.'%'; + + $messageTicketIds = DB::table('ticket_messages') + ->when( + $mysql, + fn ($q) => $q->whereFullText('body', $term), + fn ($q) => $q->where('body', 'like', $like), + ) + ->pluck('ticket_id'); + + return $query->where(function (Builder $q) use ($term, $like, $mysql, $messageTicketIds) { + if ($mysql) { + $q->whereFullText(['subject', 'body'], $term); + } else { + $q->where('subject', 'like', $like)->orWhere('body', 'like', $like); + } + + $q->orWhere('number', 'like', $like) + ->orWhere('name', 'like', $like) + ->orWhere('email', 'like', $like) + ->orWhereIn('id', $messageTicketIds); + }); + } + public function addHistory(string $text): TicketHistory { return $this->histories()->create(['text' => $text, 'created_at' => now()]); @@ -166,6 +210,21 @@ class Ticket extends Model return Status::stageFor($this->status_key) === 'closed'; } + public function hasCsatRating(): bool + { + return $this->csat_rating !== null; + } + + /** + * A client can rate a ticket once it's closed, and only until they do — + * there's no "change your rating" flow, mirroring how e.g. edit-message + * doesn't apply once the underlying thing is done. + */ + public function csatSubmittable(): bool + { + return $this->isClosed() && ! $this->hasCsatRating(); + } + /** * A resolution time of 0 minutes means "no SLA" for that priority, not * "due instantly" — such tickets never count down and never breach. diff --git a/src/app/Models/User.php b/src/app/Models/User.php index f092a1e..386f1c1 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -189,6 +189,11 @@ class User extends Authenticatable implements LdapAuthenticatable return $this->hasMany(Ticket::class, 'assignee_id'); } + public function savedQueueViews(): HasMany + { + return $this->hasMany(SavedQueueView::class); + } + /** * The admin-defined "user field" values, stored one row per field in * user_field_values — see the custom_field_values virtual attribute diff --git a/src/app/Notifications/TicketNotification.php b/src/app/Notifications/TicketNotification.php index f884d48..1ebfc28 100644 --- a/src/app/Notifications/TicketNotification.php +++ b/src/app/Notifications/TicketNotification.php @@ -6,6 +6,7 @@ use App\Models\EmailTemplate; use App\Models\Ticket; use App\Support\Settings; use Illuminate\Bus\Queueable; +use Illuminate\Notifications\AnonymousNotifiable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -13,11 +14,39 @@ class TicketNotification extends Notification { use Queueable; - public function __construct(protected Ticket $ticket, protected int $emailTemplateId) {} + /** + * $recipientRole is which area the notified person is being addressed in + * ('client' or 'operator', mirrors NotificationSetting::$recipient) — a + * user can hold both roles at once, so this can't be inferred from the + * notifiable itself; it decides which ticket URL (client vs operator + * area) both the e-mail link and the in-app notification point to. + */ + public function __construct(protected Ticket $ticket, protected int $emailTemplateId, protected string $recipientRole = 'client') {} + /** + * A guest customer with no account is routed anonymously (see + * TicketService::notify()) and can only ever receive mail — the + * "database" channel needs a real notifiable model to attach the row to. + */ public function via(object $notifiable): array { - return ['mail']; + return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['mail', 'database']; + } + + protected function ticketUrl(): string + { + return route($this->recipientRole === 'operator' ? 'operator.ticket' : 'client.ticket', $this->ticket); + } + + public function toDatabase(object $notifiable): array + { + return [ + 'ticket_id' => $this->ticket->id, + 'number' => $this->ticket->number, + 'subject' => $this->ticket->subject, + 'message' => 'Zgłoszenie #'.$this->ticket->number.' — '.$this->ticket->subject, + 'url' => $this->ticketUrl(), + ]; } public function toMail(object $notifiable): MailMessage @@ -35,7 +64,8 @@ class TicketNotification extends Notification 'priorytet' => $this->ticket->priorityLabel(), 'zespol' => $this->ticket->team?->name ?? 'Brak', 'operator' => $this->ticket->assignee?->name ?? 'Nieprzypisane', - 'link' => route('client.ticket', $this->ticket), + 'link' => $this->ticketUrl(), + 'ocena' => route('client.ticket', $this->ticket).'#csat', ]) ?? [ 'subject' => 'Zgłoszenie #'.$this->ticket->number, 'body' => $this->ticket->subject, diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 44dda10..72669bc 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Models\ApiClient; +use App\Models\User; use App\Support\Settings; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Relations\Relation; @@ -35,7 +36,9 @@ class AppServiceProvider extends ServiceProvider $this->applyTimezoneSettingsOverride(); $this->configureApiRateLimiting(); - Relation::enforceMorphMap(['api_client' => ApiClient::class]); + // 'user' backs the polymorphic notifiable_type column on the + // database-notifications table (in-app notification bell). + Relation::enforceMorphMap(['api_client' => ApiClient::class, 'user' => User::class]); } /** diff --git a/src/app/Services/BookStackClient.php b/src/app/Services/BookStackClient.php new file mode 100644 index 0000000..fd7b34f --- /dev/null +++ b/src/app/Services/BookStackClient.php @@ -0,0 +1,279 @@ + 'bookstack_allowed_shelf_ids_creation', + self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view', + ]; + + public function enabled(): bool + { + return Settings::bool('bookstack_enabled') + && Settings::get('bookstack_base_url') + && Settings::get('bookstack_token_id'); + } + + /** + * Suggested-article lookup, shared by the ticket-creation wizard and the + * operator ticket-view sidebar — returns [] whenever the integration is + * off/unconfigured, the query is empty, or no shelf has been allow-listed + * yet for the given $context (an empty allow-list means "search nothing", + * not "search everything" — an admin has to opt specific shelves in + * before any content is ever suggested, independently per context). + * Cached briefly since the same category/subcategory query repeats + * across every ticket created/viewed with that combination. Respects the + * admin-configured bookstack_search_types setting ('both'|'page'|'book') + * via BookStack's own `{type:x}` query syntax. The cache key folds in the + * allowed-shelf list so changing it in Admin > Konfiguracja is reflected + * immediately, instead of possibly serving a pre-change result for up to + * 10 minutes. + * + * @return array + */ + public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array + { + $query = trim($query); + $allowedShelfIds = $this->allowedShelfIds($context); + + if (! $this->enabled() || $query === '' || ! $allowedShelfIds) { + return []; + } + + $typeFilter = Settings::get('bookstack_search_types', 'both'); + + if (in_array($typeFilter, ['page', 'book'], true)) { + $query .= " {type:{$typeFilter}}"; + } + + $cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds)); + + return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) { + try { + $response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]); + + if (! $response->successful()) { + return []; + } + + $shelfMap = $this->shelfBookMap(); + $allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds); + $bookShelfNames = $this->bookShelfNames($shelfMap); + + return collect($response->json('data', [])) + ->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) { + $type = $item['type'] ?? null; + + if ($type === 'bookshelf') { + return in_array($item['id'] ?? null, $allowedShelfIds, true); + } + + if ($type === 'book') { + return in_array($item['id'] ?? null, $allowedBookIds, true); + } + + // pages/chapters carry the id of the book they live in + return isset($item['book_id']) && in_array($item['book_id'], $allowedBookIds, true); + }) + ->map(function (array $item) use ($bookShelfNames) { + $bookId = $item['book_id'] ?? (($item['type'] ?? null) === 'book' ? $item['id'] : null); + + return [ + 'name' => $item['name'] ?? '', + 'url' => $item['url'] ?? null, + 'type' => $item['type'] ?? 'page', + 'book' => $item['book']['name'] ?? null, + 'shelf' => $bookId ? ($bookShelfNames[$bookId] ?? null) : null, + ]; + }) + ->filter(fn (array $item) => $item['name'] !== '') + ->values() + ->all(); + } catch (\Throwable) { + return []; + } + }); + } + + /** + * Drops the cached shelf list and shelf>book membership map — used by + * the admin's "Odśwież listę półek" button so a shelf renamed/added/ + * removed in BookStack shows up immediately instead of after up to 30 + * minutes. Doesn't touch the per-query search-result cache (10 min TTL, + * self-invalidates on the next config save via the allow-list in its key). + */ + public function clearShelfCache(): void + { + Cache::forget('bookstack:shelves'); + Cache::forget('bookstack:shelf-book-map'); + } + + /** + * Bookshelves for the admin's two "dozwolone półki" checklists — cached + * since shelf structure changes rarely and this is fetched on every + * Admin > Konfiguracja page load while the BookStack section is expanded. + * + * @return array + */ + public function shelves(): array + { + if (! $this->enabled()) { + return []; + } + + return Cache::remember('bookstack:shelves', now()->addMinutes(30), function () { + try { + $response = $this->client()->get('/api/shelves', ['count' => 200]); + + if (! $response->successful()) { + return []; + } + + return collect($response->json('data', [])) + ->map(fn (array $s) => ['id' => $s['id'], 'name' => $s['name']]) + ->values() + ->all(); + } catch (\Throwable) { + return []; + } + }); + } + + /** + * @return int[] + */ + protected function allowedShelfIds(string $context): array + { + $key = self::CONTEXT_SETTINGS_KEYS[$context] ?? self::CONTEXT_SETTINGS_KEYS[self::CONTEXT_CREATION]; + $raw = Settings::get($key, ''); + + return collect(explode(',', (string) $raw)) + ->map(fn ($v) => (int) trim($v)) + ->filter() + ->values() + ->all(); + } + + /** + * Every shelf's book membership, fetched once and cached — the single + * source both shelf-exclusion and the "Shelf > Book" breadcrumb are + * derived from, so there's only one place that talks to /api/shelves/{id}. + * + * @return array + */ + protected function shelfBookMap(): array + { + return Cache::remember('bookstack:shelf-book-map', now()->addMinutes(30), function () { + $map = []; + + foreach ($this->shelves() as $shelf) { + $bookIds = []; + + try { + $response = $this->client()->get("/api/shelves/{$shelf['id']}"); + + if ($response->successful()) { + $bookIds = collect($response->json('books', []))->pluck('id')->all(); + } + } catch (\Throwable) { + // Skip an unreachable/deleted shelf rather than failing the whole search. + } + + $map[$shelf['id']] = ['name' => $shelf['name'], 'bookIds' => $bookIds]; + } + + return $map; + }); + } + + /** + * @param array $shelfMap + * @param int[] $shelfIds + * @return int[] + */ + protected function bookIdsForShelves(array $shelfMap, array $shelfIds): array + { + $ids = []; + + foreach ($shelfIds as $shelfId) { + $ids = [...$ids, ...($shelfMap[$shelfId]['bookIds'] ?? [])]; + } + + return $ids; + } + + /** + * Book id -> owning shelf name, for the suggestion list's breadcrumb. A + * book that sits on more than one shelf just shows whichever is last in + * the map — there's no single "correct" shelf to prefer in that case. + * + * @param array $shelfMap + * @return array + */ + protected function bookShelfNames(array $shelfMap): array + { + $names = []; + + foreach ($shelfMap as $shelf) { + foreach ($shelf['bookIds'] as $bookId) { + $names[$bookId] = $shelf['name']; + } + } + + return $names; + } + + /** + * Tests unsaved admin-form values directly, rather than whatever's + * currently stored — mirrors testLdapConnection()/testMailConnection() + * in Admin\Panel. Returns a message alongside the ok/error flag (BookStack's + * API returns a specific, useful reason — e.g. missing "Access System API" + * role permission — that a plain boolean would hide from the admin. + * + * @return array{ok: bool, message: ?string} + */ + public function testConnection(string $baseUrl, string $tokenId, string $tokenSecret, bool $verifySsl = true): array + { + try { + $response = Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"]) + ->withOptions(['verify' => $verifySsl]) + ->timeout(6) + ->get(rtrim($baseUrl, '/').'/api/search', ['query' => 'test', 'count' => 1]); + + if ($response->successful()) { + return ['ok' => true, 'message' => null]; + } + + return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())]; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => $e->getMessage()]; + } + } + + protected function client() + { + $tokenId = Settings::get('bookstack_token_id'); + $tokenSecret = Settings::get('bookstack_token_secret'); + + return Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"]) + ->withOptions(['verify' => Settings::bool('bookstack_verify_ssl')]) + ->timeout(4) + ->baseUrl(rtrim(Settings::get('bookstack_base_url'), '/')); + } +} diff --git a/src/app/Services/TicketService.php b/src/app/Services/TicketService.php index 6af67c3..c082fd7 100644 --- a/src/app/Services/TicketService.php +++ b/src/app/Services/TicketService.php @@ -78,7 +78,9 @@ class TicketService // A transition to "closed" fires its own dedicated notification // instead of the generic status-changed one, so closing a ticket // doesn't send the customer/operator two emails for one event. - if ($statusKey === 'closed') { + // Checked via the status's stage (not the literal key) since admins + // can rename/replace which key maps to the "closed" stage. + if (Status::stageFor($statusKey) === 'closed') { $this->notify($ticket, 'ticket_closed'); // Time tracking only applies to open work — checkpoint and pause @@ -90,6 +92,22 @@ class TicketService } } + public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void + { + if (! $ticket->csatSubmittable()) { + return; + } + + $rating = max(1, min(5, $rating)); + + $ticket->update([ + 'csat_rating' => $rating, + 'csat_comment' => $comment, + 'csat_rated_at' => now(), + ]); + $ticket->addHistory('Klient ocenił obsługę: '.$rating.'/5'); + } + public function setPriority(Ticket $ticket, string $priorityKey): void { $ticket->update(['priority_key' => $priorityKey]); @@ -276,6 +294,12 @@ class TicketService /** * Public so the scheduled SLA-breach check (which isn't a ticket lifecycle * event raised from within this service) can trigger the same way. + * + * Routes through the recipient's own User model (so it lands in the + * in-app notification bell in addition to e-mail) whenever one exists; + * falls back to an anonymous mail-only route for a guest customer with + * no account. One shared NotificationSetting.enabled flag gates both + * channels — there's no separate in-app on/off switch. */ public function notify(Ticket $ticket, string $triggerKey): void { @@ -285,6 +309,14 @@ class TicketService return; } + $notifiable = $setting->recipient === 'operator' ? $ticket->assignee : $ticket->customer; + + if ($notifiable) { + $notifiable->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient)); + + return; + } + $email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email; if (! $email) { @@ -292,6 +324,6 @@ class TicketService } Notification::route('mail', $email) - ->notify(new TicketNotification($ticket, $setting->email_template_id)); + ->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient)); } } diff --git a/src/app/Support/Settings.php b/src/app/Support/Settings.php index 94484e2..76448a0 100644 --- a/src/app/Support/Settings.php +++ b/src/app/Support/Settings.php @@ -39,6 +39,15 @@ class Settings 'mail_smtp_encryption' => 'tls', 'mail_from_address' => '', 'mail_from_name' => '', + 'bookstack_enabled' => '0', + 'bookstack_base_url' => '', + 'bookstack_token_id' => '', + 'bookstack_token_secret' => '', + 'bookstack_verify_ssl' => '1', + 'bookstack_show_to_guests' => '0', + 'bookstack_search_types' => 'both', + 'bookstack_allowed_shelf_ids_creation' => '', + 'bookstack_allowed_shelf_ids_ticket_view' => '', 'email_footer' => '

Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.

', 'accent_color' => '#7c6fd6', 'login_notice_type' => 'info', @@ -51,7 +60,7 @@ class Settings .'', ]; - protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password']; + protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret']; public static function get(string $key, ?string $default = null): ?string { diff --git a/src/config/app.php b/src/config/app.php index 9eb63b8..be63573 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -27,6 +27,18 @@ return [ 'author_contact' => env('AUTHOR_CONTACT'), + /* + |-------------------------------------------------------------------------- + | Version + |-------------------------------------------------------------------------- + | + | Shown on the Admin > About tab. Not a framework setting — set VERSION in + | .env, bump it alongside the CHANGELOG.md entry/git tag on each release. + | + */ + + 'version' => env('VERSION'), + /* |-------------------------------------------------------------------------- | Application Environment diff --git a/src/database/migrations/2026_07_22_000140_create_notifications_table.php b/src/database/migrations/2026_07_22_000140_create_notifications_table.php new file mode 100644 index 0000000..52e3b00 --- /dev/null +++ b/src/database/migrations/2026_07_22_000140_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/src/database/migrations/2026_07_22_000141_extend_tickets_for_csat_and_search.php b/src/database/migrations/2026_07_22_000141_extend_tickets_for_csat_and_search.php new file mode 100644 index 0000000..dfa7a4d --- /dev/null +++ b/src/database/migrations/2026_07_22_000141_extend_tickets_for_csat_and_search.php @@ -0,0 +1,45 @@ +unsignedTinyInteger('csat_rating')->nullable()->after('time_spent_seconds'); + $table->text('csat_comment')->nullable()->after('csat_rating'); + $table->timestamp('csat_rated_at')->nullable()->after('csat_comment'); + }); + + // FULLTEXT indexes power full-text ticket search — MariaDB/MySQL only, + // the sqlite driver used by tests has no equivalent (search falls back + // to LIKE there, see Ticket::scopeSearch()). + if (Schema::getConnection()->getDriverName() === 'mysql') { + Schema::table('tickets', function (Blueprint $table) { + $table->fullText(['subject', 'body']); + }); + Schema::table('ticket_messages', function (Blueprint $table) { + $table->fullText('body'); + }); + } + } + + public function down(): void + { + if (Schema::getConnection()->getDriverName() === 'mysql') { + Schema::table('tickets', function (Blueprint $table) { + $table->dropFullText(['subject', 'body']); + }); + Schema::table('ticket_messages', function (Blueprint $table) { + $table->dropFullText(['body']); + }); + } + + Schema::table('tickets', function (Blueprint $table) { + $table->dropColumn(['csat_rating', 'csat_comment', 'csat_rated_at']); + }); + } +}; diff --git a/src/database/migrations/2026_07_22_000142_create_saved_queue_views_table.php b/src/database/migrations/2026_07_22_000142_create_saved_queue_views_table.php new file mode 100644 index 0000000..6c3e54f --- /dev/null +++ b/src/database/migrations/2026_07_22_000142_create_saved_queue_views_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->json('filters'); + $table->boolean('is_default')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('saved_queue_views'); + } +}; diff --git a/src/database/migrations/2026_07_22_000143_add_csat_link_to_closed_email_template.php b/src/database/migrations/2026_07_22_000143_add_csat_link_to_closed_email_template.php new file mode 100644 index 0000000..a30d8b2 --- /dev/null +++ b/src/database/migrations/2026_07_22_000143_add_csat_link_to_closed_email_template.php @@ -0,0 +1,52 @@ +where('key', 'tpl-closed')->first(); + + if (! $template || str_contains($template->body, '{ocena}')) { + return; + } + + $seededBody = '

Cześć {imie},

Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.

Podgląd zgłoszenia: Kliknij tu

'; + + if (! str_starts_with($template->body, $seededBody)) { + return; + } + + $csatLink = '

Oceń naszą obsługę

'; + $rest = substr($template->body, strlen($seededBody)); + + DB::table('email_templates')->where('id', $template->id)->update([ + 'body' => $seededBody.$csatLink.$rest, + 'updated_at' => now(), + ]); + } + + public function down(): void + { + $template = DB::table('email_templates')->where('key', 'tpl-closed')->first(); + + if (! $template) { + return; + } + + DB::table('email_templates')->where('id', $template->id)->update([ + 'body' => str_replace('

Oceń naszą obsługę

', '', $template->body), + 'updated_at' => now(), + ]); + } +}; diff --git a/src/database/seeders/DatabaseSeeder.php b/src/database/seeders/DatabaseSeeder.php index f209525..598dc99 100644 --- a/src/database/seeders/DatabaseSeeder.php +++ b/src/database/seeders/DatabaseSeeder.php @@ -287,6 +287,7 @@ class DatabaseSeeder extends Seeder { $footer = '

Pozdrawiamy,
Zespół Wsparcia

'; $link = '

Podgląd zgłoszenia: Kliknij tu

'; + $csatLink = '

Oceń naszą obsługę

'; $templates = [ 'tpl-new' => [ @@ -322,7 +323,7 @@ class DatabaseSeeder extends Seeder 'tpl-closed' => [ 'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte', 'subject' => 'Zgłoszenie #{numer} zostało zamknięte', - 'body' => '

Cześć {imie},

Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.

'.$link.$footer, + 'body' => '

Cześć {imie},

Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.

'.$link.$csatLink.$footer, ], 'tpl-reply' => [ 'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział', diff --git a/src/resources/css/app.css b/src/resources/css/app.css index 57922d0..c9b3333 100644 --- a/src/resources/css/app.css +++ b/src/resources/css/app.css @@ -313,9 +313,16 @@ body { .queue-filters { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; } .queue-filters-search { width: 220px; } .queue-filters-columns { position: relative; margin-left: auto; } +.queue-filters-saved { position: relative; } + +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } +.spin { display: inline-block; animation: spin 0.8s linear infinite; } .main-col { flex: 1; min-width: 320px; } .aside-col { width: 300px; flex: none; } +/* Opt-in wider sidebar (operator ticket view, which also shows BookStack + suggestions) — at least 50% wider than the default .aside-col. */ +.aside-col-wide { width: 460px; } .wizard-step { width: 80px; flex: none; } .wizard-connector { width: 56px; flex: none; } @@ -339,6 +346,7 @@ body { .profile-menu-name { display: none; } .main-col { min-width: 0; } .aside-col { width: 100%; } + .aside-col-wide { width: 100%; } .wizard-step { width: 58px; } .wizard-connector { width: 22px; } .dialog { padding: 18px; } diff --git a/src/resources/views/components/bookstack-suggestions.blade.php b/src/resources/views/components/bookstack-suggestions.blade.php new file mode 100644 index 0000000..0af73b8 --- /dev/null +++ b/src/resources/views/components/bookstack-suggestions.blade.php @@ -0,0 +1,55 @@ +@props(['articles', 'variant' => 'banner', 'title' => 'Może pomogą te artykuły z bazy wiedzy', 'showCopy' => false]) + +@php + $icons = ['book' => 'menu_book', 'chapter' => 'bookmark', 'bookshelf' => 'library_books', 'page' => 'article']; + $isSidebar = $variant === 'sidebar'; +@endphp + +@if (count($articles)) + +@endif diff --git a/src/resources/views/components/message-attachment.blade.php b/src/resources/views/components/message-attachment.blade.php index 42215d3..7479182 100644 --- a/src/resources/views/components/message-attachment.blade.php +++ b/src/resources/views/components/message-attachment.blade.php @@ -1,9 +1,25 @@ @props(['attachment']) - - attach_file{{ $attachment->original_name }} - +@php + $url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path); + $isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/'); +@endphp + +@if ($isImage) + + {{ $attachment->original_name }} + +@else + + attach_file{{ $attachment->original_name }} + +@endif diff --git a/src/resources/views/components/topbar.blade.php b/src/resources/views/components/topbar.blade.php index e8b5dce..4bfb3ab 100644 --- a/src/resources/views/components/topbar.blade.php +++ b/src/resources/views/components/topbar.blade.php @@ -18,5 +18,9 @@ {{ $slot }} + @auth + + @endauth + diff --git a/src/resources/views/livewire/admin/panel.blade.php b/src/resources/views/livewire/admin/panel.blade.php index 13c0f11..d88a4aa 100644 --- a/src/resources/views/livewire/admin/panel.blade.php +++ b/src/resources/views/livewire/admin/panel.blade.php @@ -654,6 +654,86 @@ $tabGroups = [ @endif +
+

Baza wiedzy BookStack

+ Po włączeniu, podczas tworzenia zgłoszenia klientom i operatorom podpowiadane będą pasujące artykuły z BookStack na podstawie wybranej kategorii/podkategorii. + + + @if ($bookstackConfig['enabled']) +
+
+
+

Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.

+ +
+ +
+ +
+ +
+ +
+ + @if (count($this->bookstackShelves)) +
+ @foreach ($this->bookstackShelves as $shelf) + + @endforeach +
+ @else +

Brak półek do wyświetlenia — sprawdź, czy połączenie działa (przycisk „Testuj połączenie” niżej), albo zapisz konfigurację, żeby odświeżyć listę.

+ @endif +

Tylko książki/strony z zaznaczonych półek mogą pojawić się jako podpowiedzi podczas tworzenia zgłoszenia (klient, operator, formularz gościa). Jeśli żadna półka nie jest zaznaczona, podpowiedzi się nie pojawią.

+
+ +
+ + @if (count($this->bookstackShelves)) +
+ @foreach ($this->bookstackShelves as $shelf) + + @endforeach +
+ @else +

Brak półek do wyświetlenia — sprawdź, czy połączenie działa (przycisk „Testuj połączenie” niżej), albo zapisz konfigurację, żeby odświeżyć listę.

+ @endif +

Niezależna lista — kontroluje, co widzi operator w bocznym panelu po otwarciu istniejącego zgłoszenia (link do artykułu lub przycisk kopiowania linku). Jeśli żadna półka nie jest zaznaczona, panel się nie pokaże.

+
+ + + Bez zaznaczenia podpowiedzi widoczne są tylko przy tworzeniu zgłoszenia przez zalogowanego klienta lub operatora. + + + Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA. + +
+ + + @if ($bookstackTestResult === 'ok') +
check_circlePołączenie OK
+ @elseif ($bookstackTestResult === 'error') +
errorBłąd połączenia{{ $bookstackTestMessage ? ': '.$bookstackTestMessage : '' }}
+ @endif +
+ @else + + @endif +
+ @endif @@ -667,7 +747,7 @@ $tabGroups = [

O aplikacji

Aplikacja{{ \App\Support\Settings::get('company_name') }}
-
Wersja1.0.0
+
Wersja{{ config('app.version') ?: '—' }}
Kontakt wsparcia{{ config('app.author_contact') ?: '—' }}
@endif diff --git a/src/resources/views/livewire/client/dashboard.blade.php b/src/resources/views/livewire/client/dashboard.blade.php index 575a292..e30732e 100644 --- a/src/resources/views/livewire/client/dashboard.blade.php +++ b/src/resources/views/livewire/client/dashboard.blade.php @@ -7,9 +7,12 @@ + Nowe zgłoszenie -
- - +
+
+ + +
+
diff --git a/src/resources/views/livewire/client/new-ticket.blade.php b/src/resources/views/livewire/client/new-ticket.blade.php index ac5a80e..e94eebf 100644 --- a/src/resources/views/livewire/client/new-ticket.blade.php +++ b/src/resources/views/livewire/client/new-ticket.blade.php @@ -59,6 +59,9 @@ {{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}
+ + +
@@ -76,8 +79,16 @@
-
+
+ lub przeciągnij pliki tutaj @forelse ($attachments as $i => $file) {{ $file->getClientOriginalName() }} diff --git a/src/resources/views/livewire/client/ticket-show.blade.php b/src/resources/views/livewire/client/ticket-show.blade.php index b39a1bb..c8de634 100644 --- a/src/resources/views/livewire/client/ticket-show.blade.php +++ b/src/resources/views/livewire/client/ticket-show.blade.php @@ -63,7 +63,14 @@ @error('reply') {{ $message }} @enderror
-
+
@forelse ($attachments as $i => $file) @@ -99,6 +106,34 @@ @endif
+
+
Ocena obsługi
+ @if ($ticket->hasCsatRating()) +
+ @for ($i = 1; $i <= 5; $i++) + star + @endfor +
+ @if ($ticket->csat_comment) +

{{ $ticket->csat_comment }}

+ @endif + @elseif ($ticket->csatSubmittable()) +
+ @for ($i = 1; $i <= 5; $i++) + star + @endfor +
+ @error('csatRating') {{ $message }} @enderror + + + @else +

Ocena będzie dostępna po zamknięciu zgłoszenia.

+ @endif +
Historia zmian
@forelse ($ticket->histories as $h) diff --git a/src/resources/views/livewire/landing.blade.php b/src/resources/views/livewire/landing.blade.php index cfbf42d..88d739f 100644 --- a/src/resources/views/livewire/landing.blade.php +++ b/src/resources/views/livewire/landing.blade.php @@ -89,6 +89,9 @@ {{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}
+ + +
diff --git a/src/resources/views/livewire/notification-bell.blade.php b/src/resources/views/livewire/notification-bell.blade.php new file mode 100644 index 0000000..e2eac3b --- /dev/null +++ b/src/resources/views/livewire/notification-bell.blade.php @@ -0,0 +1,36 @@ +
+ + +
+
+ Powiadomienia + @if ($this->unreadCount) + + @endif +
+ + @forelse ($this->notifications as $notification) + +
{{ $notification->data['message'] ?? '' }}
+
{{ $notification->created_at->diffForHumans() }}
+
+ @empty +
Brak powiadomień
+ @endforelse +
+
diff --git a/src/resources/views/livewire/operator/new-ticket.blade.php b/src/resources/views/livewire/operator/new-ticket.blade.php index d37abbc..0dc40cd 100644 --- a/src/resources/views/livewire/operator/new-ticket.blade.php +++ b/src/resources/views/livewire/operator/new-ticket.blade.php @@ -65,6 +65,9 @@ {{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}
+ + +
@@ -82,8 +85,16 @@
-
+
+ lub przeciągnij pliki tutaj @forelse ($attachments as $i => $file) {{ $file->getClientOriginalName() }} diff --git a/src/resources/views/livewire/operator/queue.blade.php b/src/resources/views/livewire/operator/queue.blade.php index fc789e0..9b72826 100644 --- a/src/resources/views/livewire/operator/queue.blade.php +++ b/src/resources/views/livewire/operator/queue.blade.php @@ -74,6 +74,34 @@ @endforeach +
+ +
+ @forelse ($this->savedViews as $view) +
+ + star + delete +
+ @empty +

Brak zapisanych widoków.

+ @endforelse + +
+ + +
+ + +
+
+
+
{{-- KPI tiles --}} @@ -85,6 +90,11 @@
{{ $kpis['sla']['breached'] }} / {{ $kpis['sla']['total'] }} zgłoszeń
+
+
Ocena obsługi (CSAT)
+
{{ $kpis['csat']['avg'] !== null ? $kpis['csat']['avg'].' / 5' : '—' }}
+
{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}
+
diff --git a/src/resources/views/livewire/operator/ticket-show.blade.php b/src/resources/views/livewire/operator/ticket-show.blade.php index 62d0e0d..4f2f721 100644 --- a/src/resources/views/livewire/operator/ticket-show.blade.php +++ b/src/resources/views/livewire/operator/ticket-show.blade.php @@ -2,7 +2,7 @@
-
+
← Wróć do listy
@@ -96,7 +96,14 @@ @if ($addingNote)
-
+
@forelse ($noteAttachments as $i => $file) @@ -160,7 +167,14 @@
-
+
@forelse ($replyAttachments as $i => $file) @@ -190,7 +204,7 @@
-
+
Zgłaszający
@@ -272,11 +286,27 @@
+ +
SLA
{{ $ticket->slaInfo()['text'] }}
+ @if ($ticket->hasCsatRating()) +
+
Ocena obsługi
+
+ @for ($i = 1; $i <= 5; $i++) + star + @endfor +
+ @if ($ticket->csat_comment) +

{{ $ticket->csat_comment }}

+ @endif +
+ @endif +
MONITOR CZASU PRACY
Czas w zgłoszeniu: edit
-
- @if ($ticket->isClosed()) + @if ($ticket->isClosed()) +
Zgłoszenie zamknięte — zliczanie wstrzymane - @elseif ($ticket->timer_started_at) - - @else - - @endif - -
+ +
+ @else +
+ @if ($ticket->timer_started_at) + + @else + + @endif + +
+ @endif @endif
diff --git a/src/tests/Feature/OperatorQueueClosedTabTest.php b/src/tests/Feature/OperatorQueueClosedTabTest.php index ca87ff2..a83efd6 100644 --- a/src/tests/Feature/OperatorQueueClosedTabTest.php +++ b/src/tests/Feature/OperatorQueueClosedTabTest.php @@ -1,6 +1,7 @@ assertDontSee($closed->number); }); -test('the "Otwarte" tab does not offer "Zamknięte" as a status filter option', function () { +test('no tab other than "Zamknięte" offers "Zamknięte" as a status filter option', function () { seedStatusesAndPriorities(); - $operator = operatorUser('all-no-closed-filter@example.com'); + $operator = operatorUser('no-closed-filter-elsewhere@example.com'); - $keys = Livewire::actingAs($operator)->test(Queue::class) - ->instance()->filterableStatuses->pluck('key')->all(); + foreach (['all', 'mine', 'unassigned'] as $queue) { + $keys = Livewire::actingAs($operator)->test(Queue::class) + ->call('setQueue', $queue) + ->instance()->filterableStatuses->pluck('key')->all(); - expect($keys)->not->toContain('closed'); + expect($keys)->not->toContain('closed'); + } }); -test('other tabs still offer "Zamknięte" as a status filter option', function () { +test('the "Zamknięte" tab offers "Zamknięte" as a status filter option', function () { seedStatusesAndPriorities(); - $operator = operatorUser('mine-has-closed-filter@example.com'); + $operator = operatorUser('closed-tab-has-closed-filter@example.com'); $keys = Livewire::actingAs($operator)->test(Queue::class) - ->call('setQueue', 'mine') + ->call('setQueue', 'closed') ->instance()->filterableStatuses->pluck('key')->all(); expect($keys)->toContain('closed'); }); -test('switching to "Otwarte" resets an active "Zamknięte" status filter, since it would always be empty there', function () { +test('"Moje zgłoszenia", "Nieprzypisane" and team tabs never show closed tickets, only "Zamknięte" does', function () { + seedStatusesAndPriorities(); + $team = Team::query()->create(['name' => 'Support']); + $operator = operatorUser('closed-excluded-everywhere@example.com'); + $operator->teams()->attach($team->id); + + $mine = makeTicket(['number' => '3001', 'status_key' => 'closed', 'assignee_id' => $operator->id]); + $unassigned = makeTicket(['number' => '3002', 'status_key' => 'closed', 'assignee_id' => null]); + $teamTicket = makeTicket(['number' => '3003', 'status_key' => 'closed', 'team_id' => $team->id]); + + foreach (['mine', 'unassigned', 'team:'.$team->id] as $queue) { + Livewire::actingAs($operator)->test(Queue::class) + ->call('setQueue', $queue) + ->assertDontSee($mine->number) + ->assertDontSee($unassigned->number) + ->assertDontSee($teamTicket->number); + } + + Livewire::actingAs($operator)->test(Queue::class) + ->call('setQueue', 'closed') + ->assertSee($mine->number) + ->assertSee($unassigned->number) + ->assertSee($teamTicket->number); +}); + +test('switching away from "Zamknięte" resets an active "Zamknięte" status filter, since it would always be empty elsewhere', function () { seedStatusesAndPriorities(); $operator = operatorUser('reset-filter@example.com'); Livewire::actingAs($operator)->test(Queue::class) ->call('setQueue', 'closed') ->set('filterStatus', 'closed') - ->call('setQueue', 'all') + ->call('setQueue', 'mine') ->assertSet('filterStatus', 'all'); }); diff --git a/wiki/admin/README.md b/wiki/admin/README.md index 34a6305..c7acf0d 100644 --- a/wiki/admin/README.md +++ b/wiki/admin/README.md @@ -85,7 +85,9 @@ więcej informacji”, „Restart usuwa problem”. prostu wyłącza wysyłkę tego powiadomienia, dopóki ktoś nie wybierze nowego. „Zmiana statusu” i „zgłoszenie zamknięte” się wzajemnie wykluczają dla tej samej zmiany — zamknięcie zgłoszenia wysyła wyłącznie powiadomienie - „zgłoszenie zamknięte”, żeby nie dublować maila. + „zgłoszenie zamknięte”, żeby nie dublować maila. **Ten sam przełącznik + kontroluje zarówno e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — + nie ma osobnego ustawienia dla powiadomień w apce. ## Wygląd / Branding @@ -110,6 +112,30 @@ ważne + treść HTML). > `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed > oddaniem systemu do użytku. +- **Baza wiedzy BookStack** — opcjonalna integracja, **domyślnie wyłączona**. + Po włączeniu: + - **Adres instancji, Token ID, Token Secret** — token API generuje się w + BookStacku: Profil → Ustawienia API. Rola/użytkownik właściciela tokenu + musi mieć w BookStacku uprawnienie **„Access System API”**, inaczej + zapytania kończą się błędem 403 mimo poprawnych danych logowania. + - **Weryfikuj certyfikat SSL** — włączone domyślnie; wyłącz tylko jeśli + instancja BookStack korzysta z certyfikatu self-signed/prywatnego CA. + - **Przeszukuj** — strony i książki / tylko strony / tylko książki. + - **Dozwolone półki** — dwie **niezależne** checklisty: jedna dla podpowiedzi + przy tworzeniu zgłoszenia (klient, operator, formularz gościa na stronie + głównej), druga dla panelu bocznego operatora na widoku istniejącego + zgłoszenia. **Dopóki żadna półka nie jest zaznaczona w danej liście, + wyszukiwanie w tym kontekście nic nie zwraca** — trzeba świadomie + wskazać, które półki wolno przeszukiwać. Przycisk **„Odśwież listę + półek”** wymusza ponowne pobranie listy z BookStacka (inaczej może się + odświeżyć samoistnie po do 30 minutach po zmianie w BookStacku). + - **Pokazuj podpowiedzi także niezalogowanym** — domyślnie wyłączone; bez + zaznaczenia podpowiedzi przy tworzeniu zgłoszenia widzą tylko zalogowani + klienci/operatorzy, nie formularz gościa na stronie głównej. + - Przycisk **„Testuj połączenie”** sprawdza niezapisane wartości formularza + (analogicznie do LDAP/SMTP) i pokazuje dokładny komunikat błędu z + BookStacka, jeśli połączenie się nie powiedzie. + ## API Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API diff --git a/wiki/client/README.md b/wiki/client/README.md index 7dba864..26c6ea8 100644 --- a/wiki/client/README.md +++ b/wiki/client/README.md @@ -14,10 +14,15 @@ profilu w prawym górnym rogu). mogą pojawić się dodatkowe pola (np. numer inwentarzowy sprzętu, kwota, data potrzebna, zgoda przełożonego) — administrator skonfigurował je specjalnie dla tej podkategorii. -4. Opcjonalnie dodaj **załączniki** (limit rozmiaru/liczby/plików ustala admin — +4. Opcjonalnie dodaj **załączniki** — przeciągnij pliki na pole załączników albo + kliknij, żeby wybrać je z dysku (limit rozmiaru/liczby/plików ustala admin — komunikat o błędzie poinformuje, jeśli coś przekracza limit). -5. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są - włączone) z linkiem do podglądu. +5. Jeśli administrator włączył podpowiedzi z bazy wiedzy, przy wyborze + kategorii/podkategorii może pojawić się lista pasujących artykułów — warto + je sprawdzić, zanim wyślesz zgłoszenie. +6. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są + włączone) z linkiem do podglądu, oraz — jeśli jesteś zalogowany — powiadomienie + w dzwoneczku w górnym pasku. Zgłoszenie można też wysłać **bez logowania** ze strony głównej — wystarczy podać e-mail; konto zostanie założone automatycznie (jeśli administrator włączył @@ -31,11 +36,14 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki: - **Bieżące** — zgłoszenia jeszcze nie zamknięte. - **Archiwum** — zgłoszenia zamknięte. +Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz +odpowiedzi w wątku). + Otwórz dowolne zgłoszenie, by zobaczyć: - aktualny **status** i **priorytet**, - pełną **historię wiadomości** (Twoje i operatora — notatki wewnętrzne operatora - nie są widoczne dla klienta), + nie są widoczne dla klienta); załączone obrazy pokazują się jako miniatury, - **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy. ## Odpowiadanie @@ -43,7 +51,14 @@ Otwórz dowolne zgłoszenie, by zobaczyć: W widoku zgłoszenia można dopisać kolejną wiadomość w dowolnym momencie (np. dodać brakujące informacje albo potwierdzić rozwiązanie) — operator zobaczy ją i, jeśli powiadomienia są włączone, dostaniesz e-mail przy każdej zmianie statusu lub nowej -odpowiedzi operatora. +odpowiedzi operatora oraz powiadomienie w dzwoneczku w górnym pasku (kliknięcie +przenosi od razu do zgłoszenia). + +## Ocena obsługi + +Po zamknięciu zgłoszenia w panelu bocznym pojawia się prośba o ocenę (1–5 gwiazdek ++ opcjonalny komentarz) — jednorazowa, bez możliwości zmiany po wysłaniu. Mail o +zamknięciu zgłoszenia zawiera też bezpośredni link do oceny. ## Najczęstsze pytania diff --git a/wiki/operator/README.md b/wiki/operator/README.md index e1b71d3..868081f 100644 --- a/wiki/operator/README.md +++ b/wiki/operator/README.md @@ -1,29 +1,37 @@ # Przewodnik — Operator Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd, -odpowiadanie, zmiana statusu/priorytetu/przypisania oraz — nowość — statystyki -zespołu. +odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu. Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę. +Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje +zdarzenia na Twoich zgłoszeniach na bieżąco, bez odświeżania strony. ## Kolejka zgłoszeń Panel główny (`/operator`) pokazuje listę zgłoszeń z zakładkami po lewej stronie: - **Otwarte** — wszystkie zgłoszenia jeszcze nie zamknięte, widoczne dla Ciebie. -- **Moje zgłoszenia** — przypisane do Ciebie. -- **Nieprzypisane** — czekają na przejęcie przez kogoś z zespołu. -- **Zamknięte** — archiwum. +- **Moje zgłoszenia** — przypisane do Ciebie (bez zamkniętych). +- **Nieprzypisane** — czekają na przejęcie przez kogoś z zespołu (bez zamkniętych). +- **Zamknięte** — archiwum; jedyna zakładka, w której pojawiają się zamknięte + zgłoszenia. - **Zespoły** — osobna zakładka na każdy zespół, do którego należysz (administrator - widzi wszystkie zespoły). + 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. **Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/ -temacie/kliencie. **Kolumny** można dowolnie włączać/wyłączać przyciskiem -„Kolumny”, a nagłówki kolumn sortują listę. +temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie +włączać/wyłączać przyciskiem „Kolumny”, a nagłówki kolumn sortują listę. + +**Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą +kombinację zakładki/filtrów/sortowania/kolumn pod własną nazwą, oznaczyć jeden z +zapisanych widoków jako domyślny (ładuje się automatycznie przy wejściu do +kolejki) i usuwać niepotrzebne. Widoki są prywatne — każdy operator widzi tylko +swoje. **Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje @@ -40,8 +48,15 @@ W widoku pojedynczego zgłoszenia: jedną **szybką akcją** (np. „Wyślij i zamknij”) zamiast dwóch osobnych kroków. - **Notatka wewnętrzna** — widoczna tylko dla operatorów/adminów, np. do przekazania kontekstu innemu operatorowi. -- **Załączniki** — do odpowiedzi/notatki, w granicach limitów ustawionych przez - administratora. +- **Załączniki** — do odpowiedzi/notatki, przeciągnij pliki na pole załączników + albo kliknij, żeby wybrać je z dysku, w granicach limitów ustawionych przez + administratora; załączone obrazy pokazują się jako miniatury w wątku. +- **Baza wiedzy** (jeśli administrator włączył integrację z BookStack) — panel + boczny podpowiada artykuły pasujące do kategorii/podkategorii zgłoszenia; + kliknięcie otwiera artykuł, przycisk „Kopiuj link" kopiuje adres bez + wychodzenia ze zgłoszenia (np. do wklejenia w odpowiedzi). +- **Ocena obsługi** — jeśli klient już ocenił zgłoszenie, ocena (gwiazdki + + ewentualny komentarz) pokazuje się w panelu bocznym, tylko do odczytu. - **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się automatycznie nawet przy zamknięciu karty (mechanizm `sendBeacon`). Zliczanie jest automatycznie wstrzymywane, gdy zgłoszenie ma status zamknięty — nie @@ -74,6 +89,10 @@ zmianie filtra, bez przeładowania strony. | Śr. czas 1. odpowiedzi | średni czas od utworzenia do pierwszej odpowiedzi operatora | | Śr. czas rozwiązania | średni czas od utworzenia do zamknięcia (przybliżony — brak osobnej daty "rozwiązano", liczony do ostatniej aktualizacji zamkniętego zgłoszenia) | | Naruszenia SLA | % zgłoszeń, które przekroczyły czas rozwiązania wg priorytetu (zgodnie z regułami SLA w Admin > Statusy/Priorytety) | +| Ocena obsługi (CSAT) | średnia ocen klientów (1–5) w wybranym zakresie/filtrach + % zamkniętych zgłoszeń, które zostały ocenione | + +Przycisk **„Eksportuj CSV"** pobiera listę zgłoszeń (jeden wiersz na zgłoszenie) z +uwzględnieniem aktualnie wybranego zakresu dat i filtrów. **Wykresy** (paski poziome, kolor = ta sama identyfikacja co w kolejce dla statusu/ priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):