Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90fae0a4de | |||
| 4e8f17189a |
@@ -24,12 +24,19 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Verify registry credentials are configured
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
|
||||||
|
echo "::error::Secret REGISTRY_TOKEN is not set, so login to gitea.kzbikowski.pl would fail. Create a Gitea access token with 'write:package' (and 'read:package') scope — user Settings > Applications > Generate New Token — then add it as an Actions secret named REGISTRY_TOKEN under this repo's Settings > Actions > Secrets. Aborting before attempting login." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Log in to Gitea container registry
|
- name: Log in to Gitea container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: gitea.kzbikowski.pl
|
registry: gitea.kzbikowski.pl
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
@@ -38,14 +38,23 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
|
|||||||
├──< TicketHistory
|
├──< TicketHistory
|
||||||
├── customer/assignee → User
|
├── customer/assignee → User
|
||||||
├── status → Status (fixed stages: new/open/closed)
|
├── 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 ─< 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)
|
ApiClient (Sanctum token owner, ability-scoped)
|
||||||
Setting (single-row-per-key config store, see below)
|
Setting (single-row-per-key config store, see below)
|
||||||
ReplyQuickAction, ResponseTemplate, EmailTemplate, NotificationSetting
|
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
|
`Ticket` (`app/Models/Ticket.php`) is the largest model — it owns SLA math
|
||||||
(`slaInfo()`, `isOverdue()`, `resolutionDeadline()`), status/priority display
|
(`slaInfo()`, `isOverdue()`, `resolutionDeadline()`), status/priority display
|
||||||
helpers (`statusLabel()`, `tagStyleFromColor()`), operator-visibility scoping
|
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/
|
documented in [install.md](install.md) — anything touching LDAP/mail/session/
|
||||||
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
|
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
|
## SLA
|
||||||
|
|
||||||
`SlaRule` holds per-priority response/resolution targets in minutes. The
|
`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
|
(`AppServiceProvider::configureApiRateLimiting()`). Interactive docs are
|
||||||
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
||||||
reference in-repo.
|
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.
|
||||||
|
|||||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -3,6 +3,79 @@
|
|||||||
All notable changes to this project are documented in this file. Format loosely
|
All notable changes to this project are documented in this file. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
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
|
||||||
|
`unauthorized` because Gitea's auto-injected `secrets.GITHUB_TOKEN` isn't
|
||||||
|
granted push access to its own container registry on this instance. Now
|
||||||
|
uses a dedicated `REGISTRY_TOKEN` secret (a Gitea access token with
|
||||||
|
`write:package`/`read:package` scope), and the workflow fails fast with a
|
||||||
|
clear `::error::` message before attempting login if that secret isn't
|
||||||
|
configured, instead of surfacing Docker's opaque `unauthorized` error.
|
||||||
|
- Fixed: opening or manually resuming a **closed** ticket no longer starts its
|
||||||
|
work timer, and closing a ticket (via the status dropdown, a reply "quick
|
||||||
|
action" transition, the REST API, or merge) now checkpoints and stops any
|
||||||
|
running timer. Time tracking only ever accrues while a ticket is open.
|
||||||
|
- Fixed: the login form accepted an empty username/password, submitting them
|
||||||
|
straight to the auth provider. The form fields are now `required` (blocks
|
||||||
|
submission client-side) and `Login::submit()` also rejects blank/
|
||||||
|
whitespace-only credentials server-side before attempting authentication,
|
||||||
|
showing "Podaj nazwę użytkownika i hasło." instead.
|
||||||
|
- Fixed: closing a ticket sent two separate notification e-mails
|
||||||
|
(`status_changed` and `ticket_closed`) for the same event. Closing now only
|
||||||
|
fires `ticket_closed`; every other status transition still fires
|
||||||
|
`status_changed` as before.
|
||||||
|
|
||||||
## [1.0.1] - 2026-07-22
|
## [1.0.1] - 2026-07-22
|
||||||
|
|
||||||
Documentation and deployment/CI overhaul — no application behavior changes.
|
Documentation and deployment/CI overhaul — no application behavior changes.
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -47,6 +47,18 @@ with no rebuild or restart:
|
|||||||
`view:cache`), clear them again afterward (`config:clear`/`view:clear`) — this
|
`view:cache`), clear them again afterward (`config:clear`/`view:clear`) — this
|
||||||
app normally runs uncached so edits apply live; leaving a cache on silently
|
app normally runs uncached so edits apply live; leaving a cache on silently
|
||||||
breaks that workflow.
|
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
|
## Apache `/icons/` alias trap
|
||||||
|
|
||||||
|
|||||||
26
README.md
26
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.
|
categories/statuses/priorities/teams — issued via admin-managed API clients.
|
||||||
Interactive docs (L5-Swagger) at `/admin/api-docs`.
|
Interactive docs (L5-Swagger) at `/admin/api-docs`.
|
||||||
- **PWA** — installable manifest + icons for the client-facing area.
|
- **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
|
## Tech stack
|
||||||
|
|
||||||
@@ -104,7 +128,7 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
|
|||||||
src/ Laravel application
|
src/ Laravel application
|
||||||
app/Livewire/ Client/Operator/Admin Livewire components
|
app/Livewire/ Client/Operator/Admin Livewire components
|
||||||
app/Models/ Eloquent models
|
app/Models/ Eloquent models
|
||||||
app/Services/ TicketService (ticket lifecycle + notifications)
|
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
||||||
app/Ldap/ LDAP user model + sync handlers
|
app/Ldap/ LDAP user model + sync handlers
|
||||||
database/migrations/ Schema (one file per table group, final shape)
|
database/migrations/ Schema (one file per table group, final shape)
|
||||||
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||||
|
|||||||
10
SECURITY.md
10
SECURITY.md
@@ -45,6 +45,16 @@ credential-equivalent.
|
|||||||
- **TLS**: production traffic terminates at Traefik with a private CA
|
- **TLS**: production traffic terminates at Traefik with a private CA
|
||||||
certificate (not publicly trusted) — this is expected for this deployment, not
|
certificate (not publicly trusted) — this is expected for this deployment, not
|
||||||
a misconfiguration.
|
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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
29
install.md
29
install.md
@@ -74,7 +74,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
||||||
VERSION=1.0.0 # 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_CONNECTION=mysql
|
||||||
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
||||||
@@ -136,6 +136,21 @@ kontenerów Gitea (`gitea.kzbikowski.pl/kzbkowski/servicedesk`) po każdym pushu
|
|||||||
przebudowa dla samego kodu byłaby marnowaniem czasu CI). Wypycha dwa tagi:
|
przebudowa dla samego kodu byłaby marnowaniem czasu CI). Wypycha dwa tagi:
|
||||||
`latest` i `<sha commita>`.
|
`latest` i `<sha commita>`.
|
||||||
|
|
||||||
|
Zanim to zadziała, workflow potrzebuje sekretu `REGISTRY_TOKEN` — Gitei **nie**
|
||||||
|
ufaj domyślnemu `secrets.GITHUB_TOKEN` do logowania w jej własnym rejestrze
|
||||||
|
kontenerów, w praktyce kończy się to błędem `unauthorized` przy
|
||||||
|
`docker login`. Zamiast tego:
|
||||||
|
|
||||||
|
1. Wygeneruj token: **Ustawienia użytkownika > Aplikacje > Generate New Token**,
|
||||||
|
z uprawnieniami co najmniej `write:package` i `read:package`.
|
||||||
|
2. Dodaj go jako sekret repo: **Ustawienia repo > Actions > Secrets** →
|
||||||
|
nazwa `REGISTRY_TOKEN`, wartość = wygenerowany token.
|
||||||
|
|
||||||
|
Jeśli ten sekret nie jest ustawiony, workflow celowo przerywa się **przed**
|
||||||
|
próbą logowania z czytelnym komunikatem błędu (`::error::`), zamiast wysyłać
|
||||||
|
puste/nieautoryzowane dane do rejestru i kończyć na niejasnym `unauthorized` z
|
||||||
|
demona Dockera.
|
||||||
|
|
||||||
To **tylko build + push** — świadomie bez auto-deployu na produkcję. Po tym jak
|
To **tylko build + push** — świadomie bez auto-deployu na produkcję. Po tym jak
|
||||||
CI skończy, wdrożenie nowego obrazu na serwerze wciąż jest ręcznym krokiem:
|
CI skończy, wdrożenie nowego obrazu na serwerze wciąż jest ręcznym krokiem:
|
||||||
|
|
||||||
@@ -219,6 +234,16 @@ użytku:
|
|||||||
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
||||||
polegać na logowaniu przez katalog.
|
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)
|
## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera)
|
||||||
@@ -260,7 +285,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
||||||
VERSION=1.0.0
|
VERSION=1.1.0
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ APP_DEBUG=true
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||||
VERSION=1.0.1
|
VERSION=1.1.0
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ use App\Models\Subcategory;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserField;
|
use App\Models\UserField;
|
||||||
|
use App\Services\BookStackClient;
|
||||||
use App\Services\LdapUserProvisioner;
|
use App\Services\LdapUserProvisioner;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
@@ -137,6 +139,12 @@ class Panel extends Component
|
|||||||
|
|
||||||
public ?string $mailTestResult = null;
|
public ?string $mailTestResult = null;
|
||||||
|
|
||||||
|
public array $bookstackConfig = [];
|
||||||
|
|
||||||
|
public ?string $bookstackTestResult = null;
|
||||||
|
|
||||||
|
public ?string $bookstackTestMessage = null;
|
||||||
|
|
||||||
// ---- generic pending-delete confirm ----
|
// ---- generic pending-delete confirm ----
|
||||||
public ?string $pendingDeleteType = null;
|
public ?string $pendingDeleteType = null;
|
||||||
|
|
||||||
@@ -187,6 +195,18 @@ class Panel extends Component
|
|||||||
'fromAddress' => Settings::get('mail_from_address'),
|
'fromAddress' => Settings::get('mail_from_address'),
|
||||||
'fromName' => Settings::get('mail_from_name'),
|
'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
|
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
|
* 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.
|
* this row's sort_order with its immediate neighbor in the given direction.
|
||||||
*
|
*
|
||||||
* @param \Illuminate\Support\Collection<int, Status|Priority> $ordered
|
* @param Collection<int, Status|Priority> $ordered
|
||||||
*/
|
*/
|
||||||
protected function swapAdjacentSortOrder($ordered, string $key, int $direction): void
|
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 =====================
|
// ===================== MAIL / SMTP CONFIG =====================
|
||||||
|
|
||||||
public function saveMailConfig(): void
|
public function saveMailConfig(): void
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ class Login extends Component
|
|||||||
{
|
{
|
||||||
$this->error = null;
|
$this->error = null;
|
||||||
|
|
||||||
|
// The form also has HTML `required` attributes so the browser blocks
|
||||||
|
// an empty submit before it ever reaches here, but that's only a UX
|
||||||
|
// nicety — nothing stops a request hitting this method directly, so
|
||||||
|
// it needs to fail closed on its own too.
|
||||||
|
if (trim($this->username) === '' || $this->password === '') {
|
||||||
|
$this->error = 'Podaj nazwę użytkownika i hasło.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$attribute = Settings::ldapUsernameAttribute();
|
$attribute = Settings::ldapUsernameAttribute();
|
||||||
|
|
||||||
// Local accounts (created with a password from the admin panel) don't
|
// Local accounts (created with a password from the admin panel) don't
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ class Dashboard extends Component
|
|||||||
{
|
{
|
||||||
public string $tab = 'current';
|
public string $tab = 'current';
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function tickets()
|
public function tickets()
|
||||||
{
|
{
|
||||||
return Auth::user()->ticketsAsCustomer()
|
return Auth::user()->ticketsAsCustomer()
|
||||||
|
->search($this->search)
|
||||||
->with('subcategory.category')
|
->with('subcategory.category')
|
||||||
->orderByDesc('updated_at')
|
->orderByDesc('updated_at')
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Client;
|
|||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Subcategory;
|
use App\Models\Subcategory;
|
||||||
|
use App\Services\BookStackClient;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -60,6 +61,17 @@ class NewTicket extends Component
|
|||||||
$this->step = 3;
|
$this->step = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{name: string, url: ?string}>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function suggestedArticles(): array
|
||||||
|
{
|
||||||
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
|
||||||
|
return app(BookStackClient::class)->search($query);
|
||||||
|
}
|
||||||
|
|
||||||
public function backToCategory(): void
|
public function backToCategory(): void
|
||||||
{
|
{
|
||||||
$this->step = 1;
|
$this->step = 1;
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public ?int $pendingDeleteMessageId = null;
|
public ?int $pendingDeleteMessageId = null;
|
||||||
|
|
||||||
|
public ?int $csatRating = null;
|
||||||
|
|
||||||
|
public string $csatComment = '';
|
||||||
|
|
||||||
public function mount(Ticket $ticket): void
|
public function mount(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
abort_unless($ticket->customer_id === Auth::id(), 403);
|
abort_unless($ticket->customer_id === Auth::id(), 403);
|
||||||
@@ -86,6 +90,14 @@ class TicketShow extends Component
|
|||||||
$this->ticket->refresh();
|
$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
|
public function startEdit(int $messageId, string $body): void
|
||||||
{
|
{
|
||||||
$message = TicketMessage::query()->findOrFail($messageId);
|
$message = TicketMessage::query()->findOrFail($messageId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\Category;
|
|||||||
use App\Models\Subcategory;
|
use App\Models\Subcategory;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\BookStackClient;
|
||||||
use App\Services\LdapUserProvisioner;
|
use App\Services\LdapUserProvisioner;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
@@ -72,6 +73,27 @@ class Landing extends Component
|
|||||||
$this->step = 3;
|
$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<int, array{name: string, url: ?string}>
|
||||||
|
*/
|
||||||
|
#[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
|
public function backToCategory(): void
|
||||||
{
|
{
|
||||||
$this->step = 1;
|
$this->step = 1;
|
||||||
|
|||||||
39
src/app/Livewire/NotificationBell.php
Normal file
39
src/app/Livewire/NotificationBell.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class NotificationBell extends Component
|
||||||
|
{
|
||||||
|
#[Computed]
|
||||||
|
public function notifications()
|
||||||
|
{
|
||||||
|
return Auth::user()->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Operator;
|
|||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Subcategory;
|
use App\Models\Subcategory;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\BookStackClient;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -69,6 +70,17 @@ class NewTicket extends Component
|
|||||||
$this->step = 3;
|
$this->step = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{name: string, url: ?string}>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function suggestedArticles(): array
|
||||||
|
{
|
||||||
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
|
||||||
|
return app(BookStackClient::class)->search($query);
|
||||||
|
}
|
||||||
|
|
||||||
public function backToCategory(): void
|
public function backToCategory(): void
|
||||||
{
|
{
|
||||||
$this->step = 1;
|
$this->step = 1;
|
||||||
|
|||||||
@@ -41,6 +41,125 @@ class Queue extends Component
|
|||||||
|
|
||||||
public bool $pendingDeleteSelected = false;
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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]
|
#[Computed]
|
||||||
public function statuses()
|
public function statuses()
|
||||||
{
|
{
|
||||||
@@ -55,9 +174,9 @@ class Queue extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function filterableStatuses()
|
public function filterableStatuses()
|
||||||
{
|
{
|
||||||
return $this->queue === 'all'
|
return $this->queue === 'closed'
|
||||||
? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed')
|
? $this->statuses
|
||||||
: $this->statuses;
|
: $this->statuses->reject(fn (Status $s) => $s->stage === 'closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -101,15 +220,15 @@ class Queue extends Component
|
|||||||
|
|
||||||
$defs = [
|
$defs = [
|
||||||
'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)],
|
'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())],
|
'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')],
|
'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)],
|
'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($this->teams as $team) {
|
foreach ($this->teams as $team) {
|
||||||
$defs['team:'.$team->id] = [
|
$defs['team:'.$team->id] = [
|
||||||
'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły',
|
'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);
|
$query->where('customer_id', $this->filterCustomerId);
|
||||||
}
|
}
|
||||||
if (trim($this->search) !== '') {
|
if (trim($this->search) !== '') {
|
||||||
$term = '%'.trim($this->search).'%';
|
$query->search($this->search);
|
||||||
$query->where(fn ($q) => $q->where('number', 'like', $term)
|
|
||||||
->orWhere('subject', 'like', $term)
|
|
||||||
->orWhere('name', 'like', $term)
|
|
||||||
->orWhere('email', 'like', $term));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
|
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
|
||||||
@@ -251,7 +366,11 @@ class Queue extends Component
|
|||||||
$this->queue = $key;
|
$this->queue = $key;
|
||||||
$this->selectedIds = [];
|
$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';
|
$this->filterStatus = 'all';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class Stats extends Component
|
class Stats extends Component
|
||||||
{
|
{
|
||||||
@@ -152,6 +153,23 @@ class Stats extends Component
|
|||||||
'avgFirstResponseHours' => $this->avgFirstResponseHours(),
|
'avgFirstResponseHours' => $this->avgFirstResponseHours(),
|
||||||
'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
|
'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
|
||||||
'sla' => $this->slaBreachStats($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;
|
$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()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.operator.stats');
|
return view('livewire.operator.stats');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Models\Team;
|
|||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketMessage;
|
use App\Models\TicketMessage;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\BookStackClient;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -185,6 +186,18 @@ class TicketShow extends Component
|
|||||||
return Category::query()->with('subcategories')->get();
|
return Category::query()->with('subcategories')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||||
|
*/
|
||||||
|
#[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
|
* A non-admin operator can only reassign a ticket to one of their own
|
||||||
* teams (mirrors the visibility scoping in Operator\Queue).
|
* teams (mirrors the visibility scoping in Operator\Queue).
|
||||||
|
|||||||
24
src/app/Models/SavedQueueView.php
Normal file
24
src/app/Models/SavedQueueView.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['user_id', 'name', 'filters', 'is_default'])]
|
||||||
|
class SavedQueueView extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'filters' => 'array',
|
||||||
|
'is_default' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
'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',
|
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
|
||||||
|
'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||||
])]
|
])]
|
||||||
class Ticket extends Model
|
class Ticket extends Model
|
||||||
{
|
{
|
||||||
@@ -23,6 +25,8 @@ class Ticket extends Model
|
|||||||
'sla_notified_at' => 'datetime',
|
'sla_notified_at' => 'datetime',
|
||||||
'time_spent_seconds' => 'integer',
|
'time_spent_seconds' => 'integer',
|
||||||
'timer_started_at' => 'datetime',
|
'timer_started_at' => 'datetime',
|
||||||
|
'csat_rating' => 'integer',
|
||||||
|
'csat_rated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +133,46 @@ class Ticket extends Model
|
|||||||
|| $this->assignee_id === $user->id;
|
|| $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
|
public function addHistory(string $text): TicketHistory
|
||||||
{
|
{
|
||||||
return $this->histories()->create(['text' => $text, 'created_at' => now()]);
|
return $this->histories()->create(['text' => $text, 'created_at' => now()]);
|
||||||
@@ -166,6 +210,21 @@ class Ticket extends Model
|
|||||||
return Status::stageFor($this->status_key) === 'closed';
|
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
|
* A resolution time of 0 minutes means "no SLA" for that priority, not
|
||||||
* "due instantly" — such tickets never count down and never breach.
|
* "due instantly" — such tickets never count down and never breach.
|
||||||
@@ -283,8 +342,17 @@ class Ticket extends Model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No-ops on a closed ticket — time tracking only applies to open work,
|
||||||
|
* so a closed ticket's timer should never start (whether via auto-resume
|
||||||
|
* on open or the manual "Wznów" button).
|
||||||
|
*/
|
||||||
public function resumeTimer(): void
|
public function resumeTimer(): void
|
||||||
{
|
{
|
||||||
|
if ($this->isClosed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->timer_started_at) {
|
if (! $this->timer_started_at) {
|
||||||
$this->update(['timer_started_at' => now()]);
|
$this->update(['timer_started_at' => now()]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ class User extends Authenticatable implements LdapAuthenticatable
|
|||||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
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
|
* The admin-defined "user field" values, stored one row per field in
|
||||||
* user_field_values — see the custom_field_values virtual attribute
|
* user_field_values — see the custom_field_values virtual attribute
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\EmailTemplate;
|
|||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\AnonymousNotifiable;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
@@ -13,11 +14,39 @@ class TicketNotification extends Notification
|
|||||||
{
|
{
|
||||||
use Queueable;
|
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
|
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
|
public function toMail(object $notifiable): MailMessage
|
||||||
@@ -35,7 +64,8 @@ class TicketNotification extends Notification
|
|||||||
'priorytet' => $this->ticket->priorityLabel(),
|
'priorytet' => $this->ticket->priorityLabel(),
|
||||||
'zespol' => $this->ticket->team?->name ?? 'Brak',
|
'zespol' => $this->ticket->team?->name ?? 'Brak',
|
||||||
'operator' => $this->ticket->assignee?->name ?? 'Nieprzypisane',
|
'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,
|
'subject' => 'Zgłoszenie #'.$this->ticket->number,
|
||||||
'body' => $this->ticket->subject,
|
'body' => $this->ticket->subject,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\ApiClient;
|
use App\Models\ApiClient;
|
||||||
|
use App\Models\User;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
@@ -35,7 +36,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->applyTimezoneSettingsOverride();
|
$this->applyTimezoneSettingsOverride();
|
||||||
$this->configureApiRateLimiting();
|
$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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
279
src/app/Services/BookStackClient.php
Normal file
279
src/app/Services/BookStackClient.php
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Support\Settings;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class BookStackClient
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Two independent allow-lists, since which shelves make sense to
|
||||||
|
* surface differs by where suggestions show up: 'creation' gates the
|
||||||
|
* ticket-wizard suggestions (client/operator/guest), 'ticket_view'
|
||||||
|
* gates the separate sidebar shown to an operator on an existing ticket.
|
||||||
|
*/
|
||||||
|
public const CONTEXT_CREATION = 'creation';
|
||||||
|
|
||||||
|
public const CONTEXT_TICKET_VIEW = 'ticket_view';
|
||||||
|
|
||||||
|
protected const CONTEXT_SETTINGS_KEYS = [
|
||||||
|
self::CONTEXT_CREATION => '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<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||||
|
*/
|
||||||
|
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<int, array{id: int, name: string}>
|
||||||
|
*/
|
||||||
|
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<int, array{name: string, bookIds: int[]}>
|
||||||
|
*/
|
||||||
|
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<int, array{name: string, bookIds: int[]}> $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<int, array{name: string, bookIds: int[]}> $shelfMap
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
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'), '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,13 +75,39 @@ class TicketService
|
|||||||
$ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]);
|
$ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]);
|
||||||
$ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey));
|
$ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey));
|
||||||
|
|
||||||
$this->notify($ticket, 'status_changed');
|
// A transition to "closed" fires its own dedicated notification
|
||||||
|
// instead of the generic status-changed one, so closing a ticket
|
||||||
if ($statusKey === 'closed') {
|
// doesn't send the customer/operator two emails for one event.
|
||||||
|
// 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');
|
$this->notify($ticket, 'ticket_closed');
|
||||||
|
|
||||||
|
// Time tracking only applies to open work — checkpoint and pause
|
||||||
|
// the running segment (if any) the moment a ticket is closed,
|
||||||
|
// regardless of which flow triggered the status change.
|
||||||
|
$ticket->stopTimer();
|
||||||
|
} else {
|
||||||
|
$this->notify($ticket, 'status_changed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
public function setPriority(Ticket $ticket, string $priorityKey): void
|
||||||
{
|
{
|
||||||
$ticket->update(['priority_key' => $priorityKey]);
|
$ticket->update(['priority_key' => $priorityKey]);
|
||||||
@@ -253,6 +279,7 @@ class TicketService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$other->update(['status_key' => 'closed']);
|
$other->update(['status_key' => 'closed']);
|
||||||
|
$other->stopTimer();
|
||||||
$note = $other->messages()->create([
|
$note = $other->messages()->create([
|
||||||
'author_name' => 'System',
|
'author_name' => 'System',
|
||||||
'internal' => true,
|
'internal' => true,
|
||||||
@@ -267,6 +294,12 @@ class TicketService
|
|||||||
/**
|
/**
|
||||||
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
|
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
|
||||||
* event raised from within this service) can trigger the same way.
|
* 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
|
public function notify(Ticket $ticket, string $triggerKey): void
|
||||||
{
|
{
|
||||||
@@ -276,6 +309,14 @@ class TicketService
|
|||||||
return;
|
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;
|
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
|
||||||
|
|
||||||
if (! $email) {
|
if (! $email) {
|
||||||
@@ -283,6 +324,6 @@ class TicketService
|
|||||||
}
|
}
|
||||||
|
|
||||||
Notification::route('mail', $email)
|
Notification::route('mail', $email)
|
||||||
->notify(new TicketNotification($ticket, $setting->email_template_id));
|
->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ class Settings
|
|||||||
'mail_smtp_encryption' => 'tls',
|
'mail_smtp_encryption' => 'tls',
|
||||||
'mail_from_address' => '',
|
'mail_from_address' => '',
|
||||||
'mail_from_name' => '',
|
'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' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
||||||
'accent_color' => '#7c6fd6',
|
'accent_color' => '#7c6fd6',
|
||||||
'login_notice_type' => 'info',
|
'login_notice_type' => 'info',
|
||||||
@@ -51,7 +60,7 @@ class Settings
|
|||||||
.'</div>',
|
.'</div>',
|
||||||
];
|
];
|
||||||
|
|
||||||
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
|
public static function get(string $key, ?string $default = null): ?string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ return [
|
|||||||
|
|
||||||
'author_contact' => env('AUTHOR_CONTACT'),
|
'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
|
| Application Environment
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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('notifications', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?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::table('tickets', function (Blueprint $table) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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('saved_queue_views', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Appends a "rate our support" CTA to the existing seeded ticket_closed
|
||||||
|
* e-mail template, matching what a fresh install's seeder now produces
|
||||||
|
* (see DatabaseSeeder::seedEmailTemplatesAndNotifications()). Guarded by
|
||||||
|
* a "does it already contain {ocena}" check so re-running (or a fresh
|
||||||
|
* seed that already has it) is a no-op, and skipped entirely if the
|
||||||
|
* admin has since customized the template away from the seeded wording.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$template = DB::table('email_templates')->where('key', 'tpl-closed')->first();
|
||||||
|
|
||||||
|
if (! $template || str_contains($template->body, '{ocena}')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seededBody = '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p><p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p>';
|
||||||
|
|
||||||
|
if (! str_starts_with($template->body, $seededBody)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$csatLink = '<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>';
|
||||||
|
$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('<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>', '', $template->body),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -287,6 +287,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$footer = '<p>Pozdrawiamy,<br>Zespół Wsparcia</p>';
|
$footer = '<p>Pozdrawiamy,<br>Zespół Wsparcia</p>';
|
||||||
$link = '<p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p>';
|
$link = '<p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p>';
|
||||||
|
$csatLink = '<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>';
|
||||||
|
|
||||||
$templates = [
|
$templates = [
|
||||||
'tpl-new' => [
|
'tpl-new' => [
|
||||||
@@ -322,7 +323,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'tpl-closed' => [
|
'tpl-closed' => [
|
||||||
'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte',
|
'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte',
|
||||||
'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
|
'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
|
||||||
'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$footer,
|
'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer,
|
||||||
],
|
],
|
||||||
'tpl-reply' => [
|
'tpl-reply' => [
|
||||||
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział',
|
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział',
|
||||||
|
|||||||
@@ -313,9 +313,16 @@ body {
|
|||||||
.queue-filters { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
|
.queue-filters { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
|
||||||
.queue-filters-search { width: 220px; }
|
.queue-filters-search { width: 220px; }
|
||||||
.queue-filters-columns { position: relative; margin-left: auto; }
|
.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; }
|
.main-col { flex: 1; min-width: 320px; }
|
||||||
.aside-col { width: 300px; flex: none; }
|
.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-step { width: 80px; flex: none; }
|
||||||
.wizard-connector { width: 56px; flex: none; }
|
.wizard-connector { width: 56px; flex: none; }
|
||||||
@@ -339,6 +346,7 @@ body {
|
|||||||
.profile-menu-name { display: none; }
|
.profile-menu-name { display: none; }
|
||||||
.main-col { min-width: 0; }
|
.main-col { min-width: 0; }
|
||||||
.aside-col { width: 100%; }
|
.aside-col { width: 100%; }
|
||||||
|
.aside-col-wide { width: 100%; }
|
||||||
.wizard-step { width: 58px; }
|
.wizard-step { width: 58px; }
|
||||||
.wizard-connector { width: 22px; }
|
.wizard-connector { width: 22px; }
|
||||||
.dialog { padding: 18px; }
|
.dialog { padding: 18px; }
|
||||||
|
|||||||
@@ -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))
|
||||||
|
<div class="card" style="{{ $isSidebar ? 'padding:16px;gap:8px' : 'padding:14px;gap:10px;background:color-mix(in srgb, var(--color-accent) 6%, transparent);border-color:color-mix(in srgb, var(--color-accent) 25%, var(--color-divider))' }}">
|
||||||
|
@if ($isSidebar)
|
||||||
|
<div class="card-kicker">{{ $title }}</div>
|
||||||
|
@else
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px">auto_awesome</span>
|
||||||
|
{{ $title }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px">
|
||||||
|
@foreach ($articles as $article)
|
||||||
|
<div
|
||||||
|
@if ($showCopy) x-data="{ copied: false }" @endif
|
||||||
|
style="display:flex;gap:6px;align-items:flex-start;padding:8px;border-radius:6px"
|
||||||
|
onmouseover="this.style.background='color-mix(in srgb, var(--color-accent) 8%, transparent)'"
|
||||||
|
onmouseout="this.style.background='transparent'"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ $article['url'] }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style="display:flex;gap:10px;align-items:flex-start;flex:1;min-width:0;text-decoration:none;color:inherit"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px;flex:none;margin-top:1px;color:var(--color-accent)">{{ $icons[$article['type']] ?? 'article' }}</span>
|
||||||
|
<span style="min-width:0;flex:1">
|
||||||
|
<span style="display:block;font-size:13px;font-weight:500;color:var(--color-accent)">{{ $article['name'] }}</span>
|
||||||
|
@if (! empty($article['book']) && $article['book'] !== $article['name'])
|
||||||
|
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:1px">
|
||||||
|
{{ ! empty($article['shelf']) ? $article['shelf'].' > '.$article['book'] : $article['book'] }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
@if ($showCopy)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
style="flex:none;font-size:11px;padding:4px 8px;white-space:nowrap"
|
||||||
|
x-on:click="navigator.clipboard.writeText(@js($article['url'])); copied = true; setTimeout(() => copied = false, 1500)"
|
||||||
|
x-text="copied ? 'Skopiowano!' : 'Kopiuj link'"
|
||||||
|
></button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
@props(['attachment'])
|
@props(['attachment'])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||||
|
$isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($isImage)
|
||||||
|
<a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
|
||||||
|
<img
|
||||||
|
src="{{ $url }}"
|
||||||
|
alt="{{ $attachment->original_name }}"
|
||||||
|
loading="lazy"
|
||||||
|
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
<a
|
<a
|
||||||
href="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path) }}"
|
href="{{ $url }}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
|
||||||
</a>
|
</a>
|
||||||
|
@endif
|
||||||
|
|||||||
@@ -18,5 +18,9 @@
|
|||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|
||||||
|
@auth
|
||||||
|
<livewire:notification-bell />
|
||||||
|
@endauth
|
||||||
|
|
||||||
<x-profile-menu />
|
<x-profile-menu />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -654,6 +654,86 @@ $tabGroups = [
|
|||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form wire:submit="saveBookstackConfig" class="card" style="padding:20px;gap:14px">
|
||||||
|
<h4 style="margin:0">Baza wiedzy BookStack</h4>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Po włączeniu, podczas tworzenia zgłoszenia klientom i operatorom podpowiadane będą pasujące artykuły z BookStack na podstawie wybranej kategorii/podkategorii.</span>
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację z BookStack</strong></label>
|
||||||
|
|
||||||
|
@if ($bookstackConfig['enabled'])
|
||||||
|
<div class="field"><label>Adres instancji BookStack</label><input class="input" placeholder="https://wiki.firma.pl" wire:model="bookstackConfig.baseUrl"></div>
|
||||||
|
<div class="field"><label>Token ID</label><input class="input" wire:model="bookstackConfig.tokenId"></div>
|
||||||
|
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
|
||||||
|
|
||||||
|
<div class="field"><label>Przeszukuj</label>
|
||||||
|
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes">
|
||||||
|
<option value="both">Strony i książki</option>
|
||||||
|
<option value="page">Tylko strony</option>
|
||||||
|
<option value="book">Tylko książki</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:flex-end">
|
||||||
|
<button type="button" class="btn btn-secondary" style="display:flex;align-items:center;gap:6px;font-size:12.5px" wire:click="refreshBookstackShelves" wire:loading.attr="disabled" wire:target="refreshBookstackShelves">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px" wire:loading.class="spin" wire:target="refreshBookstackShelves">refresh</span>
|
||||||
|
Odśwież listę półek
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Dozwolone półki — podpowiedzi przy tworzeniu zgłoszenia</label>
|
||||||
|
@if (count($this->bookstackShelves))
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px;border:1px solid var(--color-divider);border-radius:8px;padding:8px">
|
||||||
|
@foreach ($this->bookstackShelves as $shelf)
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:4px 6px;border-radius:5px;cursor:pointer">
|
||||||
|
<input type="checkbox" @checked(in_array($shelf['id'], $bookstackConfig['allowedShelfIdsCreation'])) wire:click="toggleBookstackAllowedShelf('allowedShelfIdsCreation', {{ $shelf['id'] }})">
|
||||||
|
{{ $shelf['name'] }}
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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ę.</p>
|
||||||
|
@endif
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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ą.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Dozwolone półki — panel operatora przy zgłoszeniu</label>
|
||||||
|
@if (count($this->bookstackShelves))
|
||||||
|
<div style="display:flex;flex-direction:column;gap:2px;border:1px solid var(--color-divider);border-radius:8px;padding:8px">
|
||||||
|
@foreach ($this->bookstackShelves as $shelf)
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:4px 6px;border-radius:5px;cursor:pointer">
|
||||||
|
<input type="checkbox" @checked(in_array($shelf['id'], $bookstackConfig['allowedShelfIdsTicketView'])) wire:click="toggleBookstackAllowedShelf('allowedShelfIdsTicketView', {{ $shelf['id'] }})">
|
||||||
|
{{ $shelf['name'] }}
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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ę.</p>
|
||||||
|
@endif
|
||||||
|
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">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.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.showToGuests" style="position:static;opacity:1;width:auto;height:auto">Pokazuj podpowiedzi także niezalogowanym (formularz zgłoszenia na stronie głównej)</label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez zaznaczenia podpowiedzi widoczne są tylko przy tworzeniu zgłoszenia przez zalogowanego klienta lub operatora.</span>
|
||||||
|
|
||||||
|
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label>
|
||||||
|
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||||
|
@if ($bookstackTestResult === 'ok')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
|
||||||
|
@elseif ($bookstackTestResult === 'error')
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $bookstackTestMessage ? ': '.$bookstackTestMessage : '' }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -667,7 +747,7 @@ $tabGroups = [
|
|||||||
<h3 style="margin:0 0 14px">O aplikacji</h3>
|
<h3 style="margin:0 0 14px">O aplikacji</h3>
|
||||||
<div class="card" style="padding:18px;gap:10px;max-width:420px">
|
<div class="card" style="padding:18px;gap:10px;max-width:420px">
|
||||||
<div style="display:flex;justify-content:space-between"><span class="text-muted">Aplikacja</span><span>{{ \App\Support\Settings::get('company_name') }}</span></div>
|
<div style="display:flex;justify-content:space-between"><span class="text-muted">Aplikacja</span><span>{{ \App\Support\Settings::get('company_name') }}</span></div>
|
||||||
<div style="display:flex;justify-content:space-between"><span class="text-muted">Wersja</span><span>1.0.0</span></div>
|
<div style="display:flex;justify-content:space-between"><span class="text-muted">Wersja</span><span>{{ config('app.version') ?: '—' }}</span></div>
|
||||||
<div style="display:flex;justify-content:space-between"><span class="text-muted">Kontakt wsparcia</span><span>{{ config('app.author_contact') ?: '—' }}</span></div>
|
<div style="display:flex;justify-content:space-between"><span class="text-muted">Kontakt wsparcia</span><span>{{ config('app.author_contact') ?: '—' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Nazwa użytkownika</label>
|
<label>Nazwa użytkownika</label>
|
||||||
<input class="input" wire:model="username" autofocus>
|
<input class="input" wire:model="username" autofocus required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Hasło</label>
|
<label>Hasło</label>
|
||||||
<input class="input" type="password" wire:model="password">
|
<input class="input" type="password" wire:model="password" required>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-block" type="submit">Zaloguj się</button>
|
<button class="btn btn-primary btn-block" type="submit">Zaloguj się</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
<a href="{{ route('client.new') }}" wire:navigate class="btn btn-primary">+ Nowe zgłoszenie</a>
|
<a href="{{ route('client.new') }}" wire:navigate class="btn btn-primary">+ Nowe zgłoszenie</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="seg" style="align-self:flex-start">
|
<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 === '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 === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->count() }})</label>
|
||||||
</div>
|
</div>
|
||||||
|
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;gap:10px">
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
|
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
|
||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
<input class="input" wire:model="subject">
|
<input class="input" wire:model="subject">
|
||||||
@@ -76,8 +79,16 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Załączniki</label>
|
<label>Załączniki</label>
|
||||||
<div style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px">
|
<div
|
||||||
|
x-data="{ dragging: false }"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
|
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
|
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||||
|
<span class="text-muted" style="font-size:12px">lub przeciągnij pliki tutaj</span>
|
||||||
@forelse ($attachments as $i => $file)
|
@forelse ($attachments as $i => $file)
|
||||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
|
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
|
||||||
{{ $file->getClientOriginalName() }}
|
{{ $file->getClientOriginalName() }}
|
||||||
|
|||||||
@@ -63,7 +63,14 @@
|
|||||||
<textarea class="input" placeholder="Napisz odpowiedź…" wire:model="reply"></textarea>
|
<textarea class="input" placeholder="Napisz odpowiedź…" wire:model="reply"></textarea>
|
||||||
@error('reply') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
@error('reply') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px">
|
<div
|
||||||
|
x-data="{ dragging: false }"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
|
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
|
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||||
@forelse ($attachments as $i => $file)
|
@forelse ($attachments as $i => $file)
|
||||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
||||||
@@ -99,6 +106,34 @@
|
|||||||
<button type="button" class="btn btn-secondary btn-block" wire:click="reopen">Otwórz ponownie</button>
|
<button type="button" class="btn btn-secondary btn-block" wire:click="reopen">Otwórz ponownie</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
<div id="csat" class="card" style="padding:16px;gap:10px">
|
||||||
|
<div class="card-kicker">Ocena obsługi</div>
|
||||||
|
@if ($ticket->hasCsatRating())
|
||||||
|
<div style="display:flex;gap:2px">
|
||||||
|
@for ($i = 1; $i <= 5; $i++)
|
||||||
|
<span class="material-symbols-outlined" style="font-size:20px;color:{{ $i <= $ticket->csat_rating ? 'var(--color-accent)' : 'var(--color-divider)' }}">star</span>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
@if ($ticket->csat_comment)
|
||||||
|
<p style="font-size:13px;margin:0;white-space:pre-wrap">{{ $ticket->csat_comment }}</p>
|
||||||
|
@endif
|
||||||
|
@elseif ($ticket->csatSubmittable())
|
||||||
|
<div style="display:flex;gap:4px" wire:key="csat-stars-{{ $csatRating }}">
|
||||||
|
@for ($i = 1; $i <= 5; $i++)
|
||||||
|
<span
|
||||||
|
class="material-symbols-outlined"
|
||||||
|
style="font-size:24px;cursor:pointer;color:{{ $csatRating && $i <= $csatRating ? 'var(--color-accent)' : 'var(--color-divider)' }}"
|
||||||
|
wire:click="$set('csatRating', {{ $i }})"
|
||||||
|
>star</span>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
@error('csatRating') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||||
|
<textarea class="input" placeholder="Komentarz (opcjonalnie)" wire:model="csatComment" style="min-height:60px"></textarea>
|
||||||
|
<button type="button" class="btn btn-primary btn-block" wire:click="submitCsat">Wyślij ocenę</button>
|
||||||
|
@else
|
||||||
|
<p class="text-muted" style="font-size:12px;margin:0">Ocena będzie dostępna po zamknięciu zgłoszenia.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
<div class="card" style="padding:16px;gap:8px">
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">Historia zmian</div>
|
<div class="card-kicker">Historia zmian</div>
|
||||||
@forelse ($ticket->histories as $h)
|
@forelse ($ticket->histories as $h)
|
||||||
|
|||||||
@@ -89,6 +89,9 @@
|
|||||||
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
|
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
|
||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
<input class="input" placeholder="Krótki opis problemu" wire:model="subject">
|
<input class="input" placeholder="Krótki opis problemu" wire:model="subject">
|
||||||
|
|||||||
36
src/resources/views/livewire/notification-bell.blade.php
Normal file
36
src/resources/views/livewire/notification-bell.blade.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<div x-data="{ open: false }" @click.outside="open = false" style="position:relative;display:inline-block" wire:poll.30s="$refresh">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
|
||||||
|
@if ($this->unreadCount)
|
||||||
|
<span style="position:absolute;top:2px;right:2px;min-width:16px;height:16px;padding:0 3px;border-radius:8px;background:var(--color-accent);color:#fff;font-size:10px;line-height:16px;text-align:center">{{ $this->unreadCount > 9 ? '9+' : $this->unreadCount }}</span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-cloak
|
||||||
|
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);width:320px;max-height:420px;overflow-y:auto;z-index:30"
|
||||||
|
>
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--color-divider)">
|
||||||
|
<span style="font-size:12.5px;font-weight:600">Powiadomienia</span>
|
||||||
|
@if ($this->unreadCount)
|
||||||
|
<button type="button" wire:click="markAllAsRead" style="font-size:11.5px;background:none;border:none;color:var(--color-accent);cursor:pointer;padding:0">Oznacz wszystkie jako przeczytane</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@forelse ($this->notifications as $notification)
|
||||||
|
<a
|
||||||
|
href="{{ $notification->data['url'] ?? '#' }}"
|
||||||
|
wire:navigate
|
||||||
|
wire:click="markAsRead('{{ $notification->id }}')"
|
||||||
|
@click="open = false"
|
||||||
|
style="display:block;padding:10px 14px;text-decoration:none;color:var(--color-text);border-bottom:1px solid var(--color-divider);font-size:12.5px;{{ $notification->read_at ? 'opacity:0.6' : 'background:color-mix(in srgb, var(--color-accent) 6%, transparent)' }}"
|
||||||
|
>
|
||||||
|
<div>{{ $notification->data['message'] ?? '' }}</div>
|
||||||
|
<div style="font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:2px">{{ $notification->created_at->diffForHumans() }}</div>
|
||||||
|
</a>
|
||||||
|
@empty
|
||||||
|
<div style="padding:20px 14px;text-align:center;font-size:12.5px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">Brak powiadomień</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -65,6 +65,9 @@
|
|||||||
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
|
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
|
||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
<input class="input" wire:model="subject">
|
<input class="input" wire:model="subject">
|
||||||
@@ -82,8 +85,16 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Załączniki</label>
|
<label>Załączniki</label>
|
||||||
<div style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px">
|
<div
|
||||||
|
x-data="{ dragging: false }"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
|
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
|
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
|
||||||
|
<span class="text-muted" style="font-size:12px">lub przeciągnij pliki tutaj</span>
|
||||||
@forelse ($attachments as $i => $file)
|
@forelse ($attachments as $i => $file)
|
||||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
|
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
|
||||||
{{ $file->getClientOriginalName() }}
|
{{ $file->getClientOriginalName() }}
|
||||||
|
|||||||
@@ -74,6 +74,34 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<div class="queue-filters-saved" x-data="{ open: false, adding: false }">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
|
||||||
|
Zapisane widoki
|
||||||
|
</button>
|
||||||
|
<div x-show="open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
|
||||||
|
@forelse ($this->savedViews as $view)
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}">
|
||||||
|
<button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button>
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;flex:none;opacity:{{ $view->is_default ? '1' : '0.4' }};color:{{ $view->is_default ? 'var(--color-accent)' : 'inherit' }}" title="Ustaw jako domyślny" wire:click="setDefaultView({{ $view->id }})">star</span>
|
||||||
|
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;flex:none;opacity:0.6" title="Usuń" wire:click="deleteSavedView({{ $view->id }})">delete</span>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-muted" style="font-size:12px;margin:2px 8px">Brak zapisanych widoków.</p>
|
||||||
|
@endforelse
|
||||||
|
|
||||||
|
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
|
||||||
|
|
||||||
|
<template x-if="! adding">
|
||||||
|
<button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
|
||||||
|
</template>
|
||||||
|
<div x-show="adding" style="display:flex;gap:6px;padding:4px 2px">
|
||||||
|
<input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false">
|
||||||
|
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="queue-filters-columns" x-data="{ open: false }">
|
<div class="queue-filters-columns" x-data="{ open: false }">
|
||||||
<button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px">
|
<button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px">
|
||||||
<span class="material-symbols-outlined" style="font-size:18px">view_column</span>
|
<span class="material-symbols-outlined" style="font-size:18px">view_column</span>
|
||||||
|
|||||||
@@ -52,6 +52,11 @@
|
|||||||
<option value="{{ $u->id }}">{{ $u->name }}</option>
|
<option value="{{ $u->id }}">{{ $u->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-secondary" style="margin-left:auto;display:flex;align-items:center;gap:6px" wire:click="export">
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px">download</span>
|
||||||
|
Eksportuj CSV
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- KPI tiles --}}
|
{{-- KPI tiles --}}
|
||||||
@@ -85,6 +90,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-tile-meta">{{ $kpis['sla']['breached'] }} / {{ $kpis['sla']['total'] }} zgłoszeń</div>
|
<div class="stat-tile-meta">{{ $kpis['sla']['breached'] }} / {{ $kpis['sla']['total'] }} zgłoszeń</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="stat-tile-label">Ocena obsługi (CSAT)</div>
|
||||||
|
<div class="stat-tile-value">{{ $kpis['csat']['avg'] !== null ? $kpis['csat']['avg'].' / 5' : '—' }}</div>
|
||||||
|
<div class="stat-tile-meta">{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<x-topbar />
|
<x-topbar />
|
||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
|
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
|
||||||
<div style="display:flex;flex-direction:column;gap:16px;max-width:1020px;margin:0 auto">
|
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
||||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
||||||
|
|
||||||
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||||
@@ -96,7 +96,14 @@
|
|||||||
@if ($addingNote)
|
@if ($addingNote)
|
||||||
<textarea class="input" placeholder="Dodaj notatkę widoczną tylko dla zespołu…" wire:model="noteDraft"></textarea>
|
<textarea class="input" placeholder="Dodaj notatkę widoczną tylko dla zespołu…" wire:model="noteDraft"></textarea>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px">
|
<div
|
||||||
|
x-data="{ dragging: false }"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
|
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
|
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label>
|
||||||
@forelse ($noteAttachments as $i => $file)
|
@forelse ($noteAttachments as $i => $file)
|
||||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
||||||
@@ -160,7 +167,14 @@
|
|||||||
</select>
|
</select>
|
||||||
<textarea class="input" placeholder="Napisz odpowiedź do klienta…" wire:model="reply"></textarea>
|
<textarea class="input" placeholder="Napisz odpowiedź do klienta…" wire:model="reply"></textarea>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px">
|
<div
|
||||||
|
x-data="{ dragging: false }"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
|
||||||
|
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
|
||||||
|
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
|
||||||
|
>
|
||||||
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label>
|
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label>
|
||||||
@forelse ($replyAttachments as $i => $file)
|
@forelse ($replyAttachments as $i => $file)
|
||||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
||||||
@@ -190,7 +204,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="aside-col" style="display:flex;flex-direction:column;gap:14px">
|
<div class="aside-col aside-col-wide" style="display:flex;flex-direction:column;gap:14px">
|
||||||
<div class="card" style="padding:16px;gap:6px">
|
<div class="card" style="padding:16px;gap:6px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||||
<div class="card-kicker">Zgłaszający</div>
|
<div class="card-kicker">Zgłaszający</div>
|
||||||
@@ -272,11 +286,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
|
||||||
|
|
||||||
<div class="card" style="padding:16px;gap:8px">
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">SLA</div>
|
<div class="card-kicker">SLA</div>
|
||||||
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div>
|
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if ($ticket->hasCsatRating())
|
||||||
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
|
<div class="card-kicker">Ocena obsługi</div>
|
||||||
|
<div style="display:flex;gap:2px">
|
||||||
|
@for ($i = 1; $i <= 5; $i++)
|
||||||
|
<span class="material-symbols-outlined" style="font-size:18px;color:{{ $i <= $ticket->csat_rating ? 'var(--color-accent)' : 'var(--color-divider)' }}">star</span>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
@if ($ticket->csat_comment)
|
||||||
|
<p style="font-size:12.5px;margin:0;white-space:pre-wrap">{{ $ticket->csat_comment }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="card" style="padding:16px;gap:8px">
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">MONITOR CZASU PRACY</div>
|
<div class="card-kicker">MONITOR CZASU PRACY</div>
|
||||||
<div
|
<div
|
||||||
@@ -344,6 +374,12 @@
|
|||||||
<span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span>
|
<span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span>
|
||||||
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
|
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if ($ticket->isClosed())
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-start">
|
||||||
|
<span style="font-size:12px;opacity:0.7">Zgłoszenie zamknięte — zliczanie wstrzymane</span>
|
||||||
|
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
@if ($ticket->timer_started_at)
|
@if ($ticket->timer_started_at)
|
||||||
<button type="button" class="btn btn-secondary" wire:click="stopTimer" @click="running = false; clearInterval(tick)">Zatrzymaj</button>
|
<button type="button" class="btn btn-secondary" wire:click="stopTimer" @click="running = false; clearInterval(tick)">Zatrzymaj</button>
|
||||||
@@ -353,6 +389,7 @@
|
|||||||
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
|
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -90,14 +90,14 @@ test('changing the subcategory fires category_changed, but re-saving details wit
|
|||||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing a ticket fires both status_changed and ticket_closed', function () {
|
test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
seedStatusesAndPriorities();
|
||||||
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
|
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
|
||||||
|
|
||||||
// status_changed ships enabled by default, but in a bare migrated (unseeded)
|
// status_changed ships enabled by default, but in a bare migrated (unseeded)
|
||||||
// database it has no template assigned yet — give it one so both triggers
|
// database it has no template assigned yet — give it one so it would have
|
||||||
// actually have something to send, isolating this test from seeding order.
|
// something to send if it (wrongly) fired, isolating this test from seeding order.
|
||||||
$statusTemplate = EmailTemplate::query()->create([
|
$statusTemplate = EmailTemplate::query()->create([
|
||||||
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||||
]);
|
]);
|
||||||
@@ -107,7 +107,23 @@ test('closing a ticket fires both status_changed and ticket_closed', function ()
|
|||||||
|
|
||||||
app(TicketService::class)->setStatus($ticket, 'closed');
|
app(TicketService::class)->setStatus($ticket, 'closed');
|
||||||
|
|
||||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 2);
|
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-closing status change still fires status_changed as usual', function () {
|
||||||
|
Notification::fake();
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
|
||||||
|
$statusTemplate = EmailTemplate::query()->create([
|
||||||
|
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||||
|
]);
|
||||||
|
NotificationSetting::query()->where('trigger_key', 'status_changed')->update(['email_template_id' => $statusTemplate->id]);
|
||||||
|
|
||||||
|
$ticket = makeTicket();
|
||||||
|
|
||||||
|
app(TicketService::class)->setStatus($ticket, 'open');
|
||||||
|
|
||||||
|
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
|
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Ldap\LldapUser;
|
use App\Ldap\LldapUser;
|
||||||
|
use App\Livewire\Auth\Login;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function () {
|
||||||
DirectoryEmulator::tearDown();
|
DirectoryEmulator::tearDown();
|
||||||
@@ -63,3 +65,22 @@ test('an unknown username does not authenticate', function () {
|
|||||||
|
|
||||||
expect(Auth::attempt(['uid' => 'someone.else', 'password' => 'whatever']))->toBeFalse();
|
expect(Auth::attempt(['uid' => 'someone.else', 'password' => 'whatever']))->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('submitting the login form with a blank username or password shows an error and never attempts to authenticate', function () {
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->set('username', '')
|
||||||
|
->set('password', '')
|
||||||
|
->call('submit')
|
||||||
|
->assertSet('error', 'Podaj nazwę użytkownika i hasło.');
|
||||||
|
|
||||||
|
expect(Auth::check())->toBeFalse();
|
||||||
|
|
||||||
|
// Whitespace-only counts as blank for the username too.
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->set('username', ' ')
|
||||||
|
->set('password', 'somepassword')
|
||||||
|
->call('submit')
|
||||||
|
->assertSet('error', 'Podaj nazwę użytkownika i hasło.');
|
||||||
|
|
||||||
|
expect(Auth::check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Operator\Queue;
|
use App\Livewire\Operator\Queue;
|
||||||
|
use App\Models\Team;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
test('the operator queue has a dedicated tab listing only closed tickets', function () {
|
test('the operator queue has a dedicated tab listing only closed tickets', function () {
|
||||||
@@ -31,34 +32,62 @@ test('the "Otwarte" tab never shows closed tickets', function () {
|
|||||||
->assertDontSee($closed->number);
|
->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();
|
seedStatusesAndPriorities();
|
||||||
$operator = operatorUser('all-no-closed-filter@example.com');
|
$operator = operatorUser('no-closed-filter-elsewhere@example.com');
|
||||||
|
|
||||||
|
foreach (['all', 'mine', 'unassigned'] as $queue) {
|
||||||
$keys = Livewire::actingAs($operator)->test(Queue::class)
|
$keys = Livewire::actingAs($operator)->test(Queue::class)
|
||||||
|
->call('setQueue', $queue)
|
||||||
->instance()->filterableStatuses->pluck('key')->all();
|
->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();
|
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)
|
$keys = Livewire::actingAs($operator)->test(Queue::class)
|
||||||
->call('setQueue', 'mine')
|
->call('setQueue', 'closed')
|
||||||
->instance()->filterableStatuses->pluck('key')->all();
|
->instance()->filterableStatuses->pluck('key')->all();
|
||||||
|
|
||||||
expect($keys)->toContain('closed');
|
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();
|
seedStatusesAndPriorities();
|
||||||
$operator = operatorUser('reset-filter@example.com');
|
$operator = operatorUser('reset-filter@example.com');
|
||||||
|
|
||||||
Livewire::actingAs($operator)->test(Queue::class)
|
Livewire::actingAs($operator)->test(Queue::class)
|
||||||
->call('setQueue', 'closed')
|
->call('setQueue', 'closed')
|
||||||
->set('filterStatus', 'closed')
|
->set('filterStatus', 'closed')
|
||||||
->call('setQueue', 'all')
|
->call('setQueue', 'mine')
|
||||||
->assertSet('filterStatus', 'all');
|
->assertSet('filterStatus', 'all');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||||
|
use App\Services\TicketService;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
test('opening a ticket for the first time auto-starts the timer', function () {
|
test('opening a ticket for the first time auto-starts the timer', function () {
|
||||||
@@ -186,6 +187,47 @@ test('the stop-timer beacon endpoint checkpoints and stops a running timer', fun
|
|||||||
->and($ticket->time_spent_seconds)->toBe(50);
|
->and($ticket->time_spent_seconds)->toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('opening a closed ticket does not auto-start the timer', function () {
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
$operator = operatorUser('timer-closed-open@example.com');
|
||||||
|
$ticket = makeTicket(['status_key' => 'closed', 'time_spent_seconds' => 30, 'timer_started_at' => null]);
|
||||||
|
|
||||||
|
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||||
|
|
||||||
|
$ticket->refresh();
|
||||||
|
expect($ticket->timer_started_at)->toBeNull()
|
||||||
|
->and($ticket->time_spent_seconds)->toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manually resuming a closed ticket does not start the timer', function () {
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
$operator = operatorUser('timer-closed-resume@example.com');
|
||||||
|
$ticket = makeTicket(['status_key' => 'closed', 'timer_started_at' => null]);
|
||||||
|
|
||||||
|
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
|
||||||
|
->call('resumeTimer');
|
||||||
|
|
||||||
|
expect($ticket->fresh()->timer_started_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing a ticket via TicketService::setStatus checkpoints and stops a running timer', function () {
|
||||||
|
seedStatusesAndPriorities();
|
||||||
|
$this->travelTo(now());
|
||||||
|
$operator = operatorUser('timer-close-via-status@example.com');
|
||||||
|
$ticket = makeTicket();
|
||||||
|
|
||||||
|
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
|
||||||
|
$ticket->refresh();
|
||||||
|
|
||||||
|
$this->travel(70)->seconds();
|
||||||
|
|
||||||
|
app(TicketService::class)->setStatus($ticket, 'closed');
|
||||||
|
|
||||||
|
$ticket->refresh();
|
||||||
|
expect($ticket->timer_started_at)->toBeNull()
|
||||||
|
->and($ticket->time_spent_seconds)->toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
test('cancelling the timer edit leaves the tracked time untouched', function () {
|
test('cancelling the timer edit leaves the tracked time untouched', function () {
|
||||||
seedStatusesAndPriorities();
|
seedStatusesAndPriorities();
|
||||||
$operator = operatorUser('timer-edit-cancel@example.com');
|
$operator = operatorUser('timer-edit-cancel@example.com');
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ więcej informacji”, „Restart usuwa problem”.
|
|||||||
odpowiedział, SLA przekroczone) — każde ma przełącznik włącz/wyłącz, odbiorcę
|
odpowiedział, SLA przekroczone) — każde ma przełącznik włącz/wyłącz, odbiorcę
|
||||||
(klient / operator) i przypisany szablon. Usunięcie przypisanego szablonu po
|
(klient / operator) i przypisany szablon. Usunięcie przypisanego szablonu po
|
||||||
prostu wyłącza wysyłkę tego powiadomienia, dopóki ktoś nie wybierze nowego.
|
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. **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
|
## Wygląd / Branding
|
||||||
|
|
||||||
@@ -107,6 +112,30 @@ ważne + treść HTML).
|
|||||||
> `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed
|
> `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed
|
||||||
> oddaniem systemu do użytku.
|
> 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
|
## API
|
||||||
|
|
||||||
Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API
|
Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API
|
||||||
|
|||||||
@@ -14,10 +14,15 @@ profilu w prawym górnym rogu).
|
|||||||
mogą pojawić się dodatkowe pola (np. numer inwentarzowy sprzętu, kwota,
|
mogą pojawić się dodatkowe pola (np. numer inwentarzowy sprzętu, kwota,
|
||||||
data potrzebna, zgoda przełożonego) — administrator skonfigurował je specjalnie
|
data potrzebna, zgoda przełożonego) — administrator skonfigurował je specjalnie
|
||||||
dla tej podkategorii.
|
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).
|
komunikat o błędzie poinformuje, jeśli coś przekracza limit).
|
||||||
5. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są
|
5. Jeśli administrator włączył podpowiedzi z bazy wiedzy, przy wyborze
|
||||||
włączone) z linkiem do podglądu.
|
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ć
|
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ł
|
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.
|
- **Bieżące** — zgłoszenia jeszcze nie zamknięte.
|
||||||
- **Archiwum** — zgłoszenia 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ć:
|
Otwórz dowolne zgłoszenie, by zobaczyć:
|
||||||
|
|
||||||
- aktualny **status** i **priorytet**,
|
- aktualny **status** i **priorytet**,
|
||||||
- pełną **historię wiadomości** (Twoje i operatora — notatki wewnętrzne operatora
|
- 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.
|
- **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy.
|
||||||
|
|
||||||
## Odpowiadanie
|
## 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ć
|
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
|
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
|
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
|
## Najczęstsze pytania
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
# Przewodnik — Operator
|
# Przewodnik — Operator
|
||||||
|
|
||||||
Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd,
|
Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd,
|
||||||
odpowiadanie, zmiana statusu/priorytetu/przypisania oraz — nowość — statystyki
|
odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu.
|
||||||
zespołu.
|
|
||||||
|
|
||||||
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
|
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 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ń
|
## Kolejka zgłoszeń
|
||||||
|
|
||||||
Panel główny (`/operator`) pokazuje listę zgłoszeń z zakładkami po lewej stronie:
|
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.
|
- **Otwarte** — wszystkie zgłoszenia jeszcze nie zamknięte, widoczne dla Ciebie.
|
||||||
- **Moje zgłoszenia** — przypisane do Ciebie.
|
- **Moje zgłoszenia** — przypisane do Ciebie (bez zamkniętych).
|
||||||
- **Nieprzypisane** — czekają na przejęcie przez kogoś z zespołu.
|
- **Nieprzypisane** — czekają na przejęcie przez kogoś z zespołu (bez zamkniętych).
|
||||||
- **Zamknięte** — archiwum.
|
- **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
|
- **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
|
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.
|
||||||
|
|
||||||
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
|
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
|
||||||
temacie/kliencie. **Kolumny** można dowolnie włączać/wyłączać przyciskiem
|
temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie
|
||||||
„Kolumny”, a nagłówki kolumn sortują listę.
|
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
|
**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
|
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
|
||||||
@@ -40,10 +48,20 @@ W widoku pojedynczego zgłoszenia:
|
|||||||
jedną **szybką akcją** (np. „Wyślij i zamknij”) zamiast dwóch osobnych kroków.
|
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
|
- **Notatka wewnętrzna** — widoczna tylko dla operatorów/adminów, np. do
|
||||||
przekazania kontekstu innemu operatorowi.
|
przekazania kontekstu innemu operatorowi.
|
||||||
- **Załączniki** — do odpowiedzi/notatki, w granicach limitów ustawionych przez
|
- **Załączniki** — do odpowiedzi/notatki, przeciągnij pliki na pole załączników
|
||||||
administratora.
|
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ę
|
- **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się
|
||||||
automatycznie nawet przy zamknięciu karty (mechanizm `sendBeacon`).
|
automatycznie nawet przy zamknięciu karty (mechanizm `sendBeacon`). Zliczanie
|
||||||
|
jest automatycznie wstrzymywane, gdy zgłoszenie ma status zamknięty — nie
|
||||||
|
uruchomi się przy otwarciu zamkniętego zgłoszenia ani nie będzie dalej biec
|
||||||
|
po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować.
|
||||||
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
|
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
|
||||||
zmiana kategorii może wysłać powiadomienie do klienta.
|
zmiana kategorii może wysłać powiadomienie do klienta.
|
||||||
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
|
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
|
||||||
@@ -71,6 +89,10 @@ zmianie filtra, bez przeładowania strony.
|
|||||||
| Śr. czas 1. odpowiedzi | średni czas od utworzenia do pierwszej odpowiedzi operatora |
|
| Ś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) |
|
| Ś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) |
|
| 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/
|
**Wykresy** (paski poziome, kolor = ta sama identyfikacja co w kolejce dla statusu/
|
||||||
priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
|
priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
|
||||||
|
|||||||
Reference in New Issue
Block a user