- Real-time updates (Laravel Reverb): live operator queue, live ticket
  chat/detail updates for operator and client, periodic fallback refresh
  with a visible countdown as a backstop for dropped websocket connections.
- SLA automation rules (Admin > Automatyzacja SLA): act on a ticket after
  N minutes of customer silence (change priority/status/team/assignee),
  evaluated every 15 minutes, reusing TicketService's own setters so
  automated changes get the same history/notification/broadcast a manual
  change would.
- New notification: every operator on a matching team gets notified when
  a new ticket lands in one of their subcategories.
- BookStack knowledge-base sidebar now also shown on the client's own
  ticket view (previously operator-only); suggestions everywhere now load
  in after first paint instead of blocking it.
- Client ticket view: shows assigned operator + team; page widened to
  match the operator's.
- Notification bell shows unread only; read notifications disappear
  instead of just dimming.
- Stats dashboard: sectioned layout, new breakdowns (by subcategory, CSAT
  by team/operator, top clients, client x subcategory cross-tab).
- Mobile: nav dropdowns (theme/notifications/profile) now expand full
  width instead of overflowing off-screen below 640px.
- Fixed two bugs that silently disabled all real-time updates (missing
  CSRF header on Echo's private-channel auth; a script-load-order race
  that could miss the livewire:init event) and the mariadb healthcheck
  (world-writable credentials file on this stack's NFS mount).
- Assorted test-suite fixes (roles virtual attribute needs the roles
  table seeded; a few missing seeds/wrong assertions found along the way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:43:05 +02:00
parent def7c70887
commit 0cd8d9b312
64 changed files with 3613 additions and 168 deletions

View File

@@ -65,11 +65,19 @@ rather than spreading it across Livewire components.
## Roles & permissions
Roles are a plain array on the user (`$user->roles`), not a separate pivot-backed
package — checked via `EnsureRole` at the route level. Every account gets
`client` by default (`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned
accounts); staff switch areas via the header role switcher, but always land on
`/client` first after login.
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
it's a **virtual attribute** (`User::getAttribute()`/`setAttribute()`
overrides) backed by a real `roles` lookup table + `role_user` pivot, not an
actual column — assigning `'roles' => [...]` on create/update stashes the keys
until the model's `saved` hook resolves them against `roles.key` and syncs the
pivot. This matters for tests/seeders: a role key must exist in the `roles`
table *before* it can be assigned this way, or the assignment silently becomes
a no-op (`Tests\TestCase::setUp()` seeds the 3 fixed roles for exactly this
reason, since almost every test creates a role-bearing user). Checked via
`EnsureRole` at the route level. Every account gets `client` by default
(`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned accounts); staff
switch areas via the header role switcher, but always land on `/client` first
after login.
## Authentication
@@ -118,6 +126,72 @@ 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/...`.
## Real-time broadcasting (Reverb)
Two private channels, authorized in `routes/channels.php`:
- **`operator.queue`** — one shared channel for every operator/admin (not
scoped per team/ticket), so the receiving `Operator\Queue` component just
re-queries through its own already-correct `Ticket::scopeVisibleToOperator()`
on any event instead of the channel-auth callback needing to duplicate that
ACL logic. Payloads stay minimal (ticket id + reason + actor id) for the
same reason.
- **`ticket.{id}`** — per-ticket channel for the message thread and detail
changes, authorized for an operator with `isVisibleToOperator()` **or** the
ticket's own customer (OR, not else-if — the one real account in this app
holds both roles at once). An internal note broadcasts on the same channel
a client can subscribe to, but the payload never carries the message body —
each side's Livewire component only ever re-queries whatever its own
already-authorized computed property returns, so there's nothing to leak.
Two events, both `App\Events\TicketQueueChanged` (broadcasts on **both**
channels above — a status/priority/team/assignee change needs to reach a
client watching their own ticket too) and `App\Events\TicketMessagePosted`
(broadcasts on `ticket.{id}` only). Both implement `ShouldBroadcastNow`, not
`ShouldBroadcast` — this app runs with no queue worker by design (see
`TicketNotification`), so broadcasting happens synchronously within the
request like everything else here. `TicketService` dispatches both from every
ticket-mutating method (create/setStatus/setPriority/setAssignee/setTeam/
operatorReply/operatorNote/clientReply/apiMessage/merge); the two ad hoc
delete call sites (`Operator\Queue::confirmDeleteSelected()`,
`Operator\TicketShow::confirmDeleteTicket()`) dispatch `TicketQueueChanged`
directly since there's no `TicketService::delete()` to hook into.
Browser side, `resources/js/echo.js` bridges Reverb events into plain
Livewire events (`Livewire.dispatch('queue-changed', ...)` /
`'ticket-message-posted'`) rather than using the `#[On('echo-private:...')]`
attribute directly — version-agnostic, and each Livewire component just
declares a plain `#[On(...)]` listener that no-ops if the payload's `actorId`
matches the viewer's own id (self-echo suppression) or the ticket id doesn't
match the component's own ticket. Two easy-to-reintroduce bugs to know about
if "nothing updates live" ever comes back:
1. **CSRF on `/broadcasting/auth`.** Echo's private-channel subscription
POSTs there under the app's normal CSRF middleware; the `Echo` constructor
must pass `auth.headers['X-CSRF-TOKEN']` (read from the `<meta
name="csrf-token">` tag in `layouts/app.blade.php`) or every subscription
attempt is silently rejected.
2. **Script load order.** `resources/js/app.js` (which imports `echo.js`)
loads via `@vite` as `type="module"`, which the HTML spec defers until
after the document is parsed — meaning Livewire's own bootstrap script
(`@livewireScripts`, a plain synchronous `<script>` near the end of
`<body>`) has already run by the time `echo.js` executes. Don't gate
anything in `echo.js` behind `document.addEventListener('livewire:init',
...)` — that event fires as part of Livewire's own (earlier) script, so a
listener registered this late permanently misses it. The one place this
still matters is the per-ticket subscription triggered from a Livewire
`@script` block in the ticket-show views, which can run before or after
`echo.js` depending on exactly when Livewire processes it — it queues the
ticket id onto `window.__pendingTicketChannelIds` if `echo.js` hasn't
defined `window.subscribeToTicketChannel` yet, and `echo.js` flushes that
queue once it has.
As a defense against a dropped websocket connection (backgrounded tab,
network blip), the operator queue and both ticket-detail views also poll
themselves every 3060 seconds via a small Alpine countdown calling
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
best-effort, not the only way these views ever update.
## SLA
`SlaRule` holds per-priority response/resolution targets in minutes. The
@@ -127,6 +201,28 @@ tickets and can notify the assigned operator — see [install.md](install.md) fo
why this requires an external cron entry (the Docker image ships no
cron/supervisor of its own).
## SLA automation rules
`AutomationRule` (label, `condition_minutes`, optional `scope_priority_key`/
`scope_subcategory_id`/`scope_team_id`, `action_type` + `action_value`) lets an
admin configure "if a ticket has been silent for N minutes, change its
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
scheduled command `automation:run-rules` (also every 15 minutes) evaluates
every enabled rule against `Ticket.last_customer_activity_at` (falling back to
`created_at` if never set — mirrors how `resolutionDeadline()` treats a
missing `SlaRule` as "no SLA" rather than backfilling one), and applies a
match through the same `TicketService` setters a manual operator action would
use, so the automated change gets the same history entry, notification, and
broadcast for free. Idempotency is a per-(rule, ticket) row in
`automation_rule_ticket_logs`, cleared by `TicketService` whenever the silence
that triggered it is broken (a fresh `clientReply()`) or the ticket
closes/reopens (`setStatus()`) — so a rule can fire again after a new period
of silence instead of being permanently latched. Multiple matching rules on
the same ticket in the same run all fire independently, in `id` order; a rule
that closes the ticket doesn't block earlier-ordered rules already applied
this run, but a later rule's own query naturally excludes an already-closed
ticket.
## API
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST

View File

@@ -3,6 +3,91 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.1.2] - 2026-07-22
### Added
- **Real-time updates (Laravel Reverb)** — the operator ticket queue now
updates live: new tickets appear, status/priority/team/assignee changes
and new replies re-sort/refresh the affected row, and closed/deleted/
reassigned-away tickets disappear, all without a manual refresh. The
ticket message thread is now "live chat": a reply from either side
appears for the other party instantly. Clients also see status/priority/
team/assignee changes and new history entries on their own ticket live.
Backed by two private channels (`operator.queue`, `ticket.{id}`) and two
broadcast events (`TicketQueueChanged`, `TicketMessagePosted`), sent
synchronously (no queue worker needed, consistent with how e-mail
notifications already work in this app).
- **Periodic fallback refresh** — since a websocket connection can drop
silently (backgrounded tab, network blip), the operator queue and both
ticket-detail views also poll themselves every 3060 seconds regardless
of broadcasting, with a small visible countdown badge so it's clear the
page is still refreshing on its own.
- **SLA automation rules** (Admin > Automatyzacja SLA) — configurable rules
that act on a ticket after N minutes of customer silence (optionally
scoped to a priority/category/team): change priority, status, team, or
assignee. Reuses the same `TicketService` setters a manual operator
action would, so automated changes get the same history entry,
notification, and (now) live broadcast as a human doing it. A new
scheduled command (`automation:run-rules`, every 15 minutes) evaluates
all enabled rules; each rule only fires once per ticket until a fresh
reply or a close/reopen resets it.
- **New notification: ticket landed in your team** — every operator on a
team whose subcategories match a newly created ticket now gets notified
(enabled by default; toggle like any other trigger in Admin > Szablony
e-mail).
- **BookStack knowledge-base sidebar on the client's own ticket view** —
previously only shown to operators; clients now see the same
category/subcategory-matched suggestions on their ticket page that they
saw while creating it.
- **Operator ticket view / client ticket view now show the assigned
operator and team** in the "Status i priorytet" card (client side).
- **Stats dashboard**: new breakdowns — tickets by subcategory, CSAT
average by team and by operator, top 10 clients by ticket volume (+ one
"Goście" bucket for guest submissions), and a client × subcategory
cross-tab (top 10 clients × top 5 subcategories, rest folded into
"Inne"). The whole dashboard is now organized into labeled sections
(Podsumowanie / Rozkład zgłoszeń / Obciążenie / Klienci / Ocena obsługi /
Trend) instead of one flat wall of cards.
### Changed
- BookStack suggestions (ticket-creation wizards, guest landing page,
operator/client ticket views) now load in a beat after the page's first
paint (`wire:init`) instead of blocking the initial render on BookStack's
API response.
- The notification bell shows **unread notifications only** — reading one
(by clicking it or "mark all as read") now removes it from the list
instead of just dimming it.
- The client ticket-view page is now the same width as the operator's
(1180px, up from 920px) — the main message-thread column is unaffected.
- Mobile: the theme/notifications/profile-menu dropdowns in the top nav
now expand to the full screen width below 640px instead of a fixed
narrow width that could overflow off-screen.
### Fixed
- Two bugs that silently disabled all real-time updates from the moment
Reverb was first wired in: (1) the Echo client never sent a CSRF token
when authorizing private channels, so every subscription attempt was
rejected by the app's own CSRF middleware; (2) the Echo setup script
(loaded as a deferred ES module) raced against Livewire's own
synchronously-loaded bootstrap script and could miss the `livewire:init`
event entirely, silently skipping the operator-queue subscription.
- The `mariadb` container's healthcheck was failing (`Access denied ...
using password: NO`) because its auto-generated credentials file had
world-writable permissions (an artifact of this stack's NFS-backed bind
mount) and MariaDB's client refuses to read credentials from a file
anyone can modify — the healthcheck now passes.
- Several pre-existing test-suite gaps found while working in this area:
`User::roles` (a virtual attribute backed by the `roles`/`role_user`
pivot, not a plain column) needs the `roles` table seeded before
assigning a role by key — now seeded automatically for every test in
`Tests\TestCase`. A handful of other tests were missing
`NotificationSetting`/`ReplyQuickAction` seed data, asserting a stale set
of default-enabled notification triggers, or using the wrong Notification
Fake assertion for a real (non-guest) recipient.
## [1.1.0] - 2026-07-22
### Added

View File

@@ -29,6 +29,18 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **SLA** — per-priority response/resolution time targets; a scheduled command
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
the assigned operator.
- **SLA automation rules** (Admin > Automatyzacja SLA) — configurable rules that
change a ticket's priority/status/team/assignee after N minutes of customer
silence (optionally scoped to a priority/category/team), evaluated every 15
minutes (`automation:run-rules`). Reuses the same `TicketService` setters a
manual operator action would, so an automated change gets the same history
entry, notification, and live broadcast as a human doing it; each rule fires
once per ticket until a fresh customer reply or a close/reopen resets it.
- **Real-time updates** — the operator ticket queue and both ticket-detail views
(operator and client) update live over WebSockets (Laravel Reverb): new
tickets, status/priority/team/assignee changes, and new replies ("live chat")
all show up without a manual refresh. A periodic fallback refresh (with a
visible countdown) covers a dropped websocket connection.
- **Categories & custom fields** — admin-defined categories/subcategories, each with
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
optional default priority.
@@ -41,9 +53,12 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
team changed, closed, operator replied, SLA breached), each independently
enable/disable-able.
- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range,
team, priority, category, assignee) with KPI tiles (volume, SLA breach rate,
average first-response/resolution time) and breakdowns by status, priority,
category, team and operator workload, plus a daily created-vs-closed trend.
team, priority, category, assignee) organized into sections: KPI tiles (volume,
SLA breach rate, average first-response/resolution time, CSAT); breakdowns by
status, priority, category and subcategory; team/operator workload; top 10
clients by ticket volume plus a client × subcategory cross-tab (top 5
subcategories, rest folded into "Inne"); CSAT average by team and by operator;
and a daily created-vs-closed trend.
- **Branding & config** — company name/logo/favicon/accent color, login notice,
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
limits, session lifetime, timezone — all editable from Admin > Konfiguracja.
@@ -57,7 +72,10 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **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).
e-mail notifications (same per-trigger enable toggle drives both); shows
unread notifications only — reading one removes it from the list. Includes a
dedicated trigger notifying every operator on a team whose subcategories
match a newly created ticket.
- **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 15 stars (+ optional
@@ -73,29 +91,34 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **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).
both operators and clients (with a copy-link button for operators). Loads in
after the page's first paint rather than blocking it. 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/client ticket-view sidebar).
## Tech stack
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
API docs.
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)).
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
v4 via Vite for `resources/css/app.css`. No JS charting library — the statistics
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
(`resources/js/echo.js`) for Reverb. No JS charting library — the statistics
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
build step beyond the CSS bundle.
- **Database**: MariaDB.
- **Deployment**: `compose.yaml``servicedesk` (source bind-mounted from `./src`,
no image rebuild needed for PHP/Blade/route changes) + `mariadb`, fronted by
Traefik with a private-CA TLS cert. The `servicedesk` image itself is built and
pushed by Gitea Actions (`.gitea/workflows/build.yml`) to the Gitea container
registry whenever `Dockerfile` changes — `compose.yaml` just pulls a tag, it
never builds locally.
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA
TLS cert (the websocket path is routed to `reverb` by a higher-priority
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
just pulls a tag, it never builds locally.
See **[install.md](install.md)** for full step-by-step deployment instructions —
both via Docker Compose (this stack) and directly on a server with Apache/Nginx,
@@ -128,14 +151,18 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
src/ Laravel application
app/Livewire/ Client/Operator/Admin Livewire components
app/Models/ Eloquent models
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
app/Ldap/ LDAP user model + sync handlers
database/migrations/ Schema (one file per table group, final shape)
database/seeders/ DatabaseSeeder — reference data, no ticket data
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
resources/js/echo.js Laravel Echo/Reverb client + broadcast → Livewire event bridge
resources/views/ Blade templates
routes/web.php Client/Operator/Admin routes (role-gated)
routes/api.php REST API (Sanctum, ability-gated)
routes/channels.php Broadcasting channel authorization (operator.queue, ticket.{id})
wiki/
client/ How-to guide for the Client role
operator/ How-to guide for the Operator role

View File

@@ -74,7 +74,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
VERSION=1.1.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane
VERSION=1.1.2 # widoczne w Admin > O aplikacji
DB_CONNECTION=mysql
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
@@ -173,6 +173,70 @@ obrazu `servicedesk`) — poczekaj, aż workflow CI przejdzie choć raz (np. prz
push/PR zmieniający `Dockerfile`, albo ręczne odpalenie z zakładki Actions w
Gitea), zanim spróbujesz `docker compose pull` na serwerze.
### 1.3b. Real-time (Laravel Reverb)
Realtime (kolejka operatora, czat na żywo) wymaga trzeciej usługi w
`compose.yaml`, `reverb` — tego samego obrazu `servicedesk`, tylko z innym
`command: php artisan reverb:start --host=0.0.0.0 --port=8080`. Obraz musi
mieć rozszerzenia PHP `pcntl`/`posix` (potrzebne serwerowi Reverb do obsługi
sygnałów) — jeśli `Dockerfile` ich nie instaluje, `reverb` będzie się zapętlać
w restartach z błędem `Undefined constant "...SIGINT"`; dodaj `pcntl posix` do
listy w `docker-php-ext-install` i poczekaj na przebudowanie obrazu przez CI.
Traefik musi kierować ścieżkę websocketu (`/app*`) do `reverb`, a resztę do
`servicedesk` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
```yaml
reverb:
image: gitea.kzbikowski.pl/kzbkowski/servicedesk:${IMAGE_TAG:-latest}
command: php artisan reverb:start --host=0.0.0.0 --port=8080
volumes:
- ./src:/var/www/html
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
networks:
- internal
- traefik_public
labels:
- traefik.enable=true
- traefik.docker.network=traefik_public
- traefik.http.routers.reverb.rule=Host(`${HOSTNAME}`) && PathPrefix(`/app`)
- traefik.http.routers.reverb.priority=1000
- traefik.http.routers.reverb.entrypoints=websecure
- traefik.http.routers.reverb.tls=true
- traefik.http.services.reverb.loadbalancer.server.port=8080
```
`priority=1000` jest ważne — Traefik domyślnie liczy priorytet reguły na
podstawie długości jej zapisu, co może dać samemu `Host(...)` z `servicedesk`
wyższy priorytet niż oczekiwano, przez co ścieżkowa reguła `reverb` przegrywa i
nic nie działa mimo poprawnej konfiguracji.
W `src/.env``BROADCAST_CONNECTION=reverb` plus:
```env
REVERB_APP_ID=wygeneruj-losowy-id
REVERB_APP_KEY=wygeneruj-losowy-klucz
REVERB_APP_SECRET=wygeneruj-losowy-sekret
REVERB_HOST=reverb # nazwa usługi compose — ruch serwer-serwer po sieci internal
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=servicedesk.twoja-domena.pl # publiczna domena — to, z czym łączy się przeglądarka
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
```
`REVERB_HOST` (serwer→serwer, wewnętrzna sieć Docker) i `VITE_REVERB_HOST`
(przeglądarka→Traefik, publiczna domena) to celowo dwie różne wartości —
pomylenie ich to najczęstszy błąd przy pierwszym wdrożeniu tej funkcji.
Po zmianie zmiennych `VITE_REVERB_*` trzeba przebudować front-end (krok 1.5) —
te wartości są wypiekane w zbudowany bundle JS, nie czytane w runtime.
### 1.4. Instalacja aplikacji wewnątrz kontenera
```bash
@@ -285,7 +349,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
VERSION=1.1.0
VERSION=1.1.2
DB_CONNECTION=mysql
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
@@ -405,6 +469,36 @@ Jak w wersji Docker — kolejka (`php artisan queue:work`) nie jest obowiązkowa
skoro powiadomienia wysyłają się synchronicznie; zostaw `QUEUE_CONNECTION=database`
jako bezpieczny domyślny driver na przyszłość.
Realtime (patrz 1.3b) potrzebuje tu **długo działającego procesu**
`php artisan reverb:start` — PHP musi mieć rozszerzenia `pcntl`/`posix`
(standardowo dostępne, ale sprawdź `php -m`). Najprościej pod systemd:
```ini
# /etc/systemd/system/servicedesk-reverb.service
[Unit]
Description=Servicedesk Reverb websocket server
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/servicedesk/src
ExecStart=/usr/bin/php artisan reverb:start --host=0.0.0.0 --port=8080
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
systemctl enable --now servicedesk-reverb
```
Ustaw też te same zmienne `.env` co w 1.3b (`BROADCAST_CONNECTION=reverb`,
`REVERB_*`, `VITE_REVERB_*` — tu `REVERB_HOST` to po prostu `127.0.0.1`, nie
nazwa usługi compose) i skieruj serwer WWW tak, by ścieżka `/app*` trafiała do
portu 8080 zamiast do PHP-FPM/Apache (osobny `location`/`VirtualHost` dla tej
jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
### 2.7. Pierwsze logowanie i dalsza konfiguracja
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień

View File

@@ -5,7 +5,7 @@ APP_DEBUG=true
APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.1.0
VERSION=1.1.2
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -40,6 +40,24 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
# Reverb (real-time broadcasting) — set BROADCAST_CONNECTION=reverb to enable.
# REVERB_HOST/PORT/SCHEME are server-to-server (this container calling the
# "reverb" compose service over the internal docker network) — REVERB_HOST
# should be the compose service name ("reverb"), not a public hostname.
# VITE_REVERB_* is what gets baked into the built frontend bundle and is what
# the browser connects to — same public hostname/TLS as the rest of the app.
REVERB_APP_ID=wygeneruj-losowy-id
REVERB_APP_KEY=wygeneruj-losowy-klucz
REVERB_APP_SECRET=wygeneruj-losowy-sekret
REVERB_HOST=reverb
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=servicedesk.twoja-domena.pl
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
CACHE_STORE=database
# CACHE_PREFIX=

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Console\Commands;
use App\Models\AutomationRule;
use App\Models\AutomationRuleTicketLog;
use App\Models\Status;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use App\Services\TicketService;
use Illuminate\Console\Command;
class RunAutomationRules extends Command
{
protected $signature = 'automation:run-rules';
protected $description = 'Apply enabled SLA automation rules to tickets that have been silent past their condition threshold';
public function handle(TicketService $tickets): int
{
$rules = AutomationRule::query()->where('enabled', true)->orderBy('id')->get();
$fired = 0;
foreach ($rules as $rule) {
$query = Ticket::query()->whereNotIn('status_key', Status::closedKeys());
if ($rule->scope_priority_key) {
$query->where('priority_key', $rule->scope_priority_key);
}
if ($rule->scope_subcategory_id) {
$query->where('subcategory_id', $rule->scope_subcategory_id);
}
if ($rule->scope_team_id) {
$query->where('team_id', $rule->scope_team_id);
}
$candidates = $query->get()->filter(function (Ticket $ticket) use ($rule) {
if ($rule->hasFiredFor($ticket)) {
return false;
}
$since = $ticket->last_customer_activity_at ?? $ticket->created_at;
// Carbon 3 defaults diffInMinutes() to a signed result (negative
// when $since is in the past) rather than always-absolute — be
// explicit, same trap as Ticket::secondsSinceTimerStarted().
return now()->diffInMinutes($since, absolute: true) >= $rule->condition_minutes;
});
foreach ($candidates as $ticket) {
$this->apply($tickets, $rule, $ticket);
$fired++;
}
}
$this->info("Ran automation rules: {$fired} action(s) applied.");
return self::SUCCESS;
}
protected function apply(TicketService $tickets, AutomationRule $rule, Ticket $ticket): void
{
match ($rule->action_type) {
'change_priority' => $tickets->setPriority($ticket, $rule->action_value),
'change_status' => $tickets->setStatus($ticket, $rule->action_value),
'change_team' => $tickets->setTeam($ticket, Team::query()->find($rule->action_value)),
'change_assignee' => $tickets->setAssignee($ticket, User::query()->find($rule->action_value)),
default => null,
};
$ticket->addHistory("Automatyzacja: {$rule->label}");
AutomationRuleTicketLog::query()->create([
'automation_rule_id' => $rule->id,
'ticket_id' => $ticket->id,
'triggered_at' => now(),
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Fired on ticket.{id} whenever a new message (public reply, internal note,
* or API message) is posted drives the live message thread ("live chat")
* for both operator and client views of the same ticket.
*
* The payload deliberately never carries the message body, only metadata
* both public and internal messages broadcast on the same per-ticket
* channel, and a client subscriber must never be able to read an internal
* note's content from the socket frame itself. Each side's Livewire
* component only ever re-queries whatever its own already-authorized
* computed property returns (the client's never touches internalMessages()
* regardless of which event arrived), so this is safe by construction.
*/
class TicketMessagePosted implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(
public int $ticketId,
public int $messageId,
public bool $internal,
public ?int $actorId,
) {}
/**
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [new PrivateChannel('ticket.'.$this->ticketId)];
}
public function broadcastWith(): array
{
return [
'ticketId' => $this->ticketId,
'messageId' => $this->messageId,
'internal' => $this->internal,
'actorId' => $this->actorId,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Fired on operator.queue whenever a ticket is created, changes in a way
* that affects the operator queue table (status/priority/team/assignee, or
* a new message bumping its updated_at, which the queue sorts by), or is
* deleted. Payload stays minimal the receiving Queue component just
* re-queries through its own already-correct visibleToOperator() scope
* rather than this event needing to encode per-viewer visibility itself.
*
* Also broadcast on the ticket's own channel, so a client (who can't
* subscribe to operator.queue at all see routes/channels.php) still sees
* status/priority/team/assignee changes live while viewing that one ticket.
*
* ShouldBroadcastNow (not ShouldBroadcast) this app runs with no queue
* worker by design (see TicketNotification), so broadcasts happen
* synchronously within the request like everything else here.
*/
class TicketQueueChanged implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(
public int $ticketId,
public string $reason,
public ?int $actorId,
) {}
/**
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('operator.queue'),
new PrivateChannel('ticket.'.$this->ticketId),
];
}
public function broadcastWith(): array
{
return [
'ticketId' => $this->ticketId,
'reason' => $this->reason,
'actorId' => $this->actorId,
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Admin;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\CustomField;
use App\Models\EmailTemplate;
@@ -100,6 +101,15 @@ class Panel extends Component
public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => ''];
// ---- automation rules ----
public bool $automationRuleFormOpen = false;
public array $automationRuleForm = [
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
'action_type' => 'change_priority', 'action_value' => '',
];
// ---- templates ----
public ?int $editingTemplateId = null;
@@ -854,6 +864,96 @@ class Panel extends Component
$this->requestDelete('response-template', $id, 'Szablon odpowiedzi zostanie usunięty z listy dostępnej operatorom.');
}
// ===================== AUTOMATION RULES =====================
#[Computed]
public function automationRules()
{
return AutomationRule::query()->orderBy('id')->get();
}
public function openAutomationRuleForm(): void
{
$this->automationRuleForm = [
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
'action_type' => 'change_priority', 'action_value' => '',
];
$this->automationRuleFormOpen = true;
}
public function editAutomationRule(int $id): void
{
$rule = AutomationRule::query()->findOrFail($id);
$this->automationRuleForm = [
'id' => $rule->id,
'label' => $rule->label,
'enabled' => $rule->enabled,
'condition_minutes' => $rule->condition_minutes,
'scope_priority_key' => $rule->scope_priority_key ?? '',
'scope_subcategory_id' => $rule->scope_subcategory_id ?? '',
'scope_team_id' => $rule->scope_team_id ?? '',
'action_type' => $rule->action_type,
'action_value' => $rule->action_value,
];
$this->automationRuleFormOpen = true;
}
public function closeAutomationRuleForm(): void
{
$this->automationRuleFormOpen = false;
}
// Switching action_type invalidates whichever action_value was picked for
// the previous type (e.g. a priority key isn't a valid team id).
public function updatedAutomationRuleFormActionType(): void
{
$this->automationRuleForm['action_value'] = '';
}
public function submitAutomationRule(): void
{
$this->validate([
'automationRuleForm.label' => 'required|string|max:255',
'automationRuleForm.condition_minutes' => 'required|integer|min:1',
'automationRuleForm.action_type' => 'required|in:'.implode(',', AutomationRule::ACTION_TYPES),
'automationRuleForm.action_value' => 'required|string',
]);
$data = [
'label' => $this->automationRuleForm['label'],
'enabled' => (bool) $this->automationRuleForm['enabled'],
'condition_minutes' => (int) $this->automationRuleForm['condition_minutes'],
'scope_priority_key' => $this->automationRuleForm['scope_priority_key'] ?: null,
'scope_subcategory_id' => $this->automationRuleForm['scope_subcategory_id'] ?: null,
'scope_team_id' => $this->automationRuleForm['scope_team_id'] ?: null,
'action_type' => $this->automationRuleForm['action_type'],
'action_value' => $this->automationRuleForm['action_value'],
];
if ($this->automationRuleForm['id']) {
AutomationRule::query()->find($this->automationRuleForm['id'])?->update($data);
} else {
AutomationRule::query()->create($data);
}
$this->automationRuleFormOpen = false;
unset($this->automationRules);
}
public function toggleAutomationRuleEnabled(int $id): void
{
$rule = AutomationRule::query()->find($id);
$rule?->update(['enabled' => ! $rule->enabled]);
unset($this->automationRules);
}
public function removeAutomationRule(int $id): void
{
$this->requestDelete('automation-rule', $id, 'Reguła automatyzacji zostanie usunięta.');
}
// ===================== STATUSES / PRIORITIES =====================
#[Computed]
@@ -1491,10 +1591,11 @@ class Panel extends Component
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
'automation-rule' => AutomationRule::query()->find($this->pendingDeleteId)?->delete(),
default => null,
};
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings);
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings, $this->automationRules);
$this->cancelPendingDelete();
}

View File

@@ -30,6 +30,16 @@ class NewTicket extends Component
public array $attachments = [];
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
#[Computed]
public function categories()
{
@@ -67,6 +77,10 @@ class NewTicket extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);

View File

@@ -4,10 +4,12 @@ namespace App\Livewire\Client;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Services\BookStackClient;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -31,6 +33,16 @@ class TicketShow extends Component
public string $csatComment = '';
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the ticket page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
public function mount(Ticket $ticket): void
{
abort_unless($ticket->customer_id === Auth::id(), 403);
@@ -44,12 +56,76 @@ class TicketShow extends Component
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
}
/**
* Bridged from a TicketMessagePosted broadcast on this ticket's own
* channel (see resources/js/echo.js) an operator's reply appears
* without the client refreshing, the "live chat" effect. Ignores events
* for any other ticket id since Livewire dispatches are page-wide.
*/
#[On('ticket-message-posted')]
public function onTicketMessagePosted(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
unset($this->ticketMessages);
$this->ticket->refresh();
}
/**
* Bridged from a TicketQueueChanged broadcast on this ticket's own
* channel lets the client see a status/priority/team/assignee change
* (and the resulting history entry) made by an operator, or by an
* automation rule firing in the background, without refreshing.
*/
#[On('queue-changed')]
public function onQueueChanged(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
$this->ticket->refresh();
}
/**
* Periodic fallback refresh (see the countdown badge in the blade view)
* broadcasting is best-effort, a dropped websocket connection
* shouldn't mean the thread/ticket silently stops updating.
*/
public function refreshTicketData(): void
{
unset($this->ticketMessages);
$this->ticket->refresh();
}
#[Computed]
public function otherTickets()
{
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
}
/**
* Same allow-listed shelves (CONTEXT_CREATION) and category/subcategory
* query the ticket-creation wizard used, so the client sees the same
* suggestions here as they did while writing the ticket.
*
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function updatedAttachments(): void
{
if (! $this->attachments) {

View File

@@ -36,6 +36,16 @@ class Landing extends Component
public ?int $submittedTicketId = null;
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
#[Computed]
public function categories()
{
@@ -85,7 +95,7 @@ class Landing extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! Settings::bool('bookstack_show_to_guests')) {
if (! $this->suggestedArticlesLoaded || ! Settings::bool('bookstack_show_to_guests')) {
return [];
}

View File

@@ -8,10 +8,16 @@ use Livewire\Component;
class NotificationBell extends Component
{
/**
* Only unread once a notification is read (clicked through, or via
* "mark all as read"), it disappears from the bell rather than staying
* listed dimmed. The full history still lives in the notifications
* table for anyone querying it directly, just not surfaced here.
*/
#[Computed]
public function notifications()
{
return Auth::user()->notifications()->latest()->limit(20)->get();
return Auth::user()->unreadNotifications()->latest()->limit(20)->get();
}
#[Computed]

View File

@@ -33,6 +33,16 @@ class NewTicket extends Component
public array $attachments = [];
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
#[Computed]
public function clients()
{
@@ -76,6 +86,10 @@ class NewTicket extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Operator;
use App\Events\TicketQueueChanged;
use App\Models\Category;
use App\Models\Priority;
use App\Models\Status;
@@ -11,6 +12,7 @@ use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
@@ -65,6 +67,33 @@ class Queue extends Component
}
}
/**
* Bridged from a TicketQueueChanged broadcast via resources/js/echo.js
* see that file for why this is a plain Livewire event rather than an
* `#[On('echo-private:...')]` attribute. Re-queries through the same
* visibleToOperator()-scoped computed property a manual refresh would
* use, so a ticket that just became invisible to this operator (closed,
* reassigned away, moved to another team) simply won't come back, and
* its id is pruned from the current selection so a stale checkbox
* doesn't linger for a row that's no longer on screen.
*/
#[On('queue-changed')]
public function onQueueChanged(int $ticketId = 0): void
{
$this->refreshQueue();
}
/**
* Periodic fallback refresh (see the countdown badge next to "Kolumny"
* in the blade view) broadcasting is best-effort, a dropped websocket
* connection shouldn't mean the queue silently stops updating.
*/
public function refreshQueue(): void
{
unset($this->filteredTickets);
$this->selectedIds = array_values(array_intersect($this->selectedIds, $this->filteredTickets->pluck('id')->all()));
}
#[Computed]
public function savedViews()
{
@@ -417,7 +446,13 @@ class Queue extends Component
public function confirmDeleteSelected(): void
{
Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete();
$ids = Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id');
Ticket::query()->whereIn('id', $ids)->delete();
foreach ($ids as $id) {
TicketQueueChanged::dispatch($id, 'deleted', Auth::id());
}
$this->selectedIds = [];
$this->pendingDeleteSelected = false;
}

View File

@@ -312,6 +312,25 @@ class Stats extends Component
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
}
/**
* One level deeper than byCategory() same shape, but grouped by the
* actual subcategory, labeled "Category / Subcategory" to disambiguate
* subcategories that share a name across different parent categories.
*/
#[Computed]
public function bySubcategory()
{
return (clone $this->baseQuery)
->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id')
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
->orderByDesc('count')
->get()
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
}
#[Computed]
public function byTeam()
{
@@ -351,6 +370,170 @@ class Stats extends Component
return $rows;
}
/**
* Unlike teams/operators (small, fixed sets), the customer list is
* unbounded capped to the top 10 by ticket volume in the current
* filtered range rather than listing every client who ever wrote in.
* Guest submissions (no account) are summed into one "Goście" bucket
* rather than grouped by e-mail, since a guest has no stable identity
* to rank against registered clients.
*/
#[Computed]
public function byCustomer()
{
$rows = (clone $this->baseQuery)
->whereNotNull('tickets.customer_id')
->join('users', 'users.id', '=', 'tickets.customer_id')
->select('users.id', 'users.name as label', DB::raw('count(*) as count'))
->groupBy('users.id', 'users.name')
->orderByDesc('count')
->limit(10)
->get()
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
$guestCount = (clone $this->baseQuery)->whereNull('tickets.customer_id')->count();
if ($guestCount > 0) {
$rows->push(['label' => 'Goście (bez konta)', 'count' => $guestCount]);
}
return $rows->sortByDesc('count')->values();
}
/**
* Client × subcategory cross-tab which clients' tickets fall into
* which kind of subcategory. Both dimensions are unbounded (unlike
* teams/operators), so this caps to the top 10 clients by overall
* volume (rows, mirroring byCustomer()) and the top 5 subcategories by
* overall volume (columns, mirroring assigneeSubcategoryMatrix()'s
* "Inne" folding) otherwise the table could grow arbitrarily in both
* directions. Guest tickets (no customer_id) are excluded entirely
* rather than folded into one "guest" row, since mixing a real client's
* per-subcategory pattern with an anonymous aggregate wouldn't mean
* anything.
*
* @return array{columns: array<int, string>, hasOther: bool, rows: array<int, array{label: string, cells: array<int, int>, other: ?int, total: int}>}
*/
#[Computed]
public function customerSubcategoryMatrix(): array
{
$raw = (clone $this->baseQuery)
->whereNotNull('tickets.customer_id')
->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id')
->join('users', 'users.id', '=', 'tickets.customer_id')
->select(
'tickets.customer_id',
'users.name as customer_name',
'subcategories.id as subcategory_id',
'categories.name as category_name',
'subcategories.name as subcategory_name',
DB::raw('count(*) as total'),
)
->groupBy('tickets.customer_id', 'users.name', 'subcategories.id', 'categories.name', 'subcategories.name')
->get();
if ($raw->isEmpty()) {
return ['columns' => [], 'hasOther' => false, 'rows' => []];
}
$subcategoryTotals = $raw->groupBy('subcategory_id')->map(fn ($g) => $g->sum('total'));
$topSubcategoryIds = $subcategoryTotals->sortDesc()->keys()->take(5);
$subcategoryLabels = $raw->unique('subcategory_id')->keyBy('subcategory_id')
->map(fn ($r) => $r->category_name.' / '.$r->subcategory_name);
$columns = $topSubcategoryIds->map(fn ($id) => $subcategoryLabels[$id])->values()->all();
$hasOther = $subcategoryTotals->keys()->diff($topSubcategoryIds)->isNotEmpty();
$byCustomer = $raw->groupBy('customer_id');
$customerNames = $raw->unique('customer_id')->keyBy('customer_id')->map(fn ($r) => $r->customer_name);
$topCustomerIds = $byCustomer->map(fn ($g) => $g->sum('total'))->sortDesc()->keys()->take(10);
$rows = $topCustomerIds
->map(function ($customerId) use ($byCustomer, $customerNames, $topSubcategoryIds, $hasOther) {
$entries = $byCustomer->get($customerId, collect());
$bySubcategory = $entries->keyBy('subcategory_id');
return [
'label' => $customerNames[$customerId],
'cells' => $topSubcategoryIds->map(fn ($id) => (int) ($bySubcategory[$id]->total ?? 0))->values()->all(),
'other' => $hasOther ? (int) $entries->whereNotIn('subcategory_id', $topSubcategoryIds->all())->sum('total') : null,
'total' => (int) $entries->sum('total'),
];
})
->values()
->all();
return ['columns' => $columns, 'hasOther' => $hasOther, 'rows' => $rows];
}
/**
* Average CSAT rating per team, only among rated tickets in the current
* filtered range mirrors byTeam()'s "Bez zespołu" bucket handling, but
* teams/buckets with zero ratings are dropped entirely (an average of
* nothing isn't a meaningful bar to draw).
*/
#[Computed]
public function csatByTeam()
{
$stats = (clone $this->baseQuery)
->whereNotNull('csat_rating')
->select('team_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
->groupBy('team_id')
->get();
$avgs = $stats->pluck('avg_rating', 'team_id');
$counts = $stats->pluck('rated_count', 'team_id');
$rows = $this->teams
->map(fn (Team $t) => [
'label' => $t->name,
'avg' => isset($avgs[$t->id]) ? round((float) $avgs[$t->id], 2) : null,
'count' => (int) ($counts[$t->id] ?? 0),
])
->filter(fn ($row) => $row['count'] > 0)
->values();
if ($counts->get(null, 0)) {
$rows->push(['label' => 'Bez zespołu', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
}
return $rows->sortByDesc('avg')->values();
}
/**
* Average CSAT rating per assignee, same shape/semantics as csatByTeam().
*/
#[Computed]
public function csatByAssignee()
{
$stats = (clone $this->baseQuery)
->whereNotNull('csat_rating')
->select('assignee_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
->groupBy('assignee_id')
->get();
$avgs = $stats->pluck('avg_rating', 'assignee_id');
$counts = $stats->pluck('rated_count', 'assignee_id');
$rows = $this->operators
->map(fn (User $u) => [
'label' => $u->name,
'avg' => isset($avgs[$u->id]) ? round((float) $avgs[$u->id], 2) : null,
'count' => (int) ($counts[$u->id] ?? 0),
])
->filter(fn ($row) => $row['count'] > 0)
->values();
if ($counts->get(null, 0)) {
$rows->push(['label' => 'Nieprzypisane', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
}
return $rows->sortByDesc('avg')->values();
}
/**
* Daily created-vs-closed volume, capped at the most recent 60 days so a
* wide range (or "Cały okres") never renders an unreadably thin column

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Operator;
use App\Events\TicketQueueChanged;
use App\Models\Category;
use App\Models\Priority;
use App\Models\ReplyQuickAction;
@@ -17,6 +18,7 @@ use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -66,6 +68,16 @@ class TicketShow extends Component
public string $editTimerSeconds = '0';
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the ticket page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
public function mount(Ticket $ticket): void
{
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
@@ -168,6 +180,50 @@ class TicketShow extends Component
return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
}
/**
* Bridged from a TicketMessagePosted broadcast on this ticket's own
* channel (see resources/js/echo.js) a new reply/note from the other
* party appears without the viewer refreshing, the "live chat" effect.
* Ignores events for any other ticket id, since Livewire dispatches are
* page-wide and this component only cares about its own ticket.
*/
#[On('ticket-message-posted')]
public function onTicketMessagePosted(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
unset($this->publicMessages, $this->internalMessages);
}
/**
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
* and Queue::onQueueChanged()) lets a status/priority/team/assignee
* change made by another operator, or by an automation rule firing in
* the background, show up live on a ticket someone currently has open.
*/
#[On('queue-changed')]
public function onQueueChanged(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
$this->ticket->refresh();
}
/**
* Periodic fallback refresh (see the countdown badge in the blade view)
* broadcasting is best-effort, a dropped websocket connection
* shouldn't mean the thread/ticket silently stops updating.
*/
public function refreshTicketData(): void
{
unset($this->publicMessages, $this->internalMessages);
$this->ticket->refresh();
}
#[Computed]
public function statuses()
{
@@ -192,6 +248,10 @@ class TicketShow extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
@@ -500,7 +560,9 @@ class TicketShow extends Component
public function confirmDeleteTicket(): void
{
$ticketId = $this->ticket->id;
$this->ticket->delete();
TicketQueueChanged::dispatch($ticketId, 'deleted', Auth::id());
$this->redirect(route('operator.queue'), navigate: true);
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'label', 'enabled', 'condition_minutes',
'scope_priority_key', 'scope_subcategory_id', 'scope_team_id',
'action_type', 'action_value',
])]
class AutomationRule extends Model
{
public const ACTION_TYPES = ['change_priority', 'change_status', 'change_team', 'change_assignee'];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'condition_minutes' => 'integer',
];
}
public function logs(): HasMany
{
return $this->hasMany(AutomationRuleTicketLog::class);
}
public function hasFiredFor(Ticket $ticket): bool
{
return $this->logs()->where('ticket_id', $ticket->id)->exists();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['automation_rule_id', 'ticket_id', 'triggered_at'])]
class AutomationRuleTicketLog extends Model
{
public $timestamps = false;
protected function casts(): array
{
return ['triggered_at' => 'datetime'];
}
public function rule(): BelongsTo
{
return $this->belongsTo(AutomationRule::class, 'automation_rule_id');
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -13,8 +13,8 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
'csat_rating', 'csat_comment', 'csat_rated_at',
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
])]
class Ticket extends Model
{
@@ -23,6 +23,7 @@ class Ticket extends Model
return [
'custom_fields' => 'array',
'sla_notified_at' => 'datetime',
'last_customer_activity_at' => 'datetime',
'time_spent_seconds' => 'integer',
'timer_started_at' => 'datetime',
'csat_rating' => 'integer',
@@ -90,6 +91,11 @@ class Ticket extends Model
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
}
public function automationRuleLogs(): HasMany
{
return $this->hasMany(AutomationRuleTicketLog::class);
}
public static function nextNumber(): string
{
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();

View File

@@ -2,6 +2,8 @@
namespace App\Services;
use App\Events\TicketMessagePosted;
use App\Events\TicketQueueChanged;
use App\Models\ApiClient;
use App\Models\NotificationSetting;
use App\Models\Priority;
@@ -14,6 +16,7 @@ use App\Models\User;
use App\Notifications\TicketNotification;
use App\Support\Settings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
@@ -45,6 +48,7 @@ class TicketService
'team_id' => $this->autoAssignTeam($subcategory),
'assignee_id' => $data['assignee_id'] ?? null,
'custom_fields' => $data['custom_values'] ?? [],
'last_customer_activity_at' => now(),
]);
$message = $ticket->messages()->create([
@@ -54,6 +58,8 @@ class TicketService
$message->attachAuthor($customer?->id, 'client');
$this->notify($ticket, 'ticket_created');
$this->notifyOperatorsForNewTicket($ticket, $subcategory);
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
return $ticket;
}
@@ -68,6 +74,38 @@ class TicketService
->value('id');
}
/**
* Notifies every member of every team the new ticket's subcategory
* routes to independent of whether "auto_assign_by_category" actually
* assigned the ticket's team_id, since the point here is "a ticket
* matching your team's specialty came in", not the routing feature
* itself. Unlike notify(), this fans out to potentially many
* notifiables at once, so it can't reuse that single-recipient method.
*/
protected function notifyOperatorsForNewTicket(Ticket $ticket, ?Subcategory $subcategory): void
{
if (! $subcategory) {
return;
}
$setting = NotificationSetting::query()->where('trigger_key', 'ticket_created_team')->first();
if (! $setting || ! $setting->enabled || ! $setting->email_template_id) {
return;
}
$operators = Team::query()
->whereHas('subcategories', fn ($q) => $q->where('subcategories.id', $subcategory->id))
->with('members')
->get()
->flatMap(fn (Team $team) => $team->members)
->unique('id');
foreach ($operators as $operator) {
$operator->notify(new TicketNotification($ticket, $setting->email_template_id, 'operator'));
}
}
public function setStatus(Ticket $ticket, string $statusKey): void
{
// Any status change (closing, reopening, moving between open sub-statuses)
@@ -87,9 +125,15 @@ class TicketService
// the running segment (if any) the moment a ticket is closed,
// regardless of which flow triggered the status change.
$ticket->stopTimer();
// A closed ticket that reopens later should give every automation
// rule a clean slate rather than staying latched from before.
$ticket->automationRuleLogs()->delete();
} else {
$this->notify($ticket, 'status_changed');
}
TicketQueueChanged::dispatch($ticket->id, 'status_changed', Auth::id());
}
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
@@ -113,6 +157,7 @@ class TicketService
$ticket->update(['priority_key' => $priorityKey]);
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
$this->notify($ticket, 'priority_changed');
TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id());
}
public function setAssignee(Ticket $ticket, ?User $assignee): void
@@ -122,6 +167,7 @@ class TicketService
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
$this->notify($ticket, 'assignee_changed');
TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id());
}
public function setTeam(Ticket $ticket, ?Team $team): void
@@ -129,6 +175,7 @@ class TicketService
$ticket->update(['team_id' => $team?->id]);
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
$this->notify($ticket, 'team_changed');
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
}
public function setReporter(Ticket $ticket, User $customer): void
@@ -164,6 +211,8 @@ class TicketService
$ticket->touch();
$this->attachFiles($ticket, $message, $attachments);
$this->notify($ticket, 'operator_replied');
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id);
if ($statusAfter) {
$this->setStatus($ticket, $statusAfter);
@@ -179,6 +228,7 @@ class TicketService
]);
$message->attachAuthor($operator->id, 'operator');
$this->attachFiles($ticket, $message, $attachments);
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
}
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
@@ -190,6 +240,15 @@ class TicketService
$message->attachAuthor($client->id, 'client');
$ticket->touch();
$this->attachFiles($ticket, $message, $attachments);
// A fresh customer reply breaks whatever silence an automation rule
// fired on, so it should be able to fire again after a new period of
// silence rather than staying latched from before.
$ticket->update(['last_customer_activity_at' => now()]);
$ticket->automationRuleLogs()->delete();
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $client->id);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
}
/**
@@ -214,6 +273,9 @@ class TicketService
$this->notify($ticket, 'operator_replied');
}
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
return $message;
}
@@ -286,9 +348,11 @@ class TicketService
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
]);
$note->attachAuthor(null, 'operator');
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
}
$primary->touch();
TicketQueueChanged::dispatch($primary->id, 'message_posted', Auth::id());
}
/**

View File

@@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {

View File

@@ -13,6 +13,7 @@
"darkaonline/l5-swagger": "*",
"directorytree/ldaprecord-laravel": "*",
"laravel/framework": "^13.8",
"laravel/reverb": "*",
"laravel/sanctum": "*",
"laravel/tinker": "^3.0",
"livewire/livewire": "*"

907
src/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "73d594569fe3d69fd8d63789e36b323e",
"content-hash": "321add40614eb8751e0c8dbda55016eb",
"packages": [
{
"name": "brick/math",
@@ -134,6 +134,136 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "clue/redis-protocol",
"version": "v0.3.2",
"source": {
"type": "git",
"url": "https://github.com/clue/redis-protocol.git",
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c",
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"Clue\\Redis\\Protocol\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@lueck.tv"
}
],
"description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.",
"homepage": "https://github.com/clue/redis-protocol",
"keywords": [
"parser",
"protocol",
"redis",
"resp",
"serializer",
"streaming"
],
"support": {
"issues": "https://github.com/clue/redis-protocol/issues",
"source": "https://github.com/clue/redis-protocol/tree/v0.3.2"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2024-08-07T11:06:28+00:00"
},
{
"name": "clue/redis-react",
"version": "v2.8.0",
"source": {
"type": "git",
"url": "https://github.com/clue/reactphp-redis.git",
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca",
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca",
"shasum": ""
},
"require": {
"clue/redis-protocol": "^0.3.2",
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.0 || ^1.1",
"react/promise-timer": "^1.11",
"react/socket": "^1.16"
},
"require-dev": {
"clue/block-react": "^1.5",
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"Clue\\React\\Redis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering"
}
],
"description": "Async Redis client implementation, built on top of ReactPHP.",
"homepage": "https://github.com/clue/reactphp-redis",
"keywords": [
"async",
"client",
"database",
"reactphp",
"redis"
],
"support": {
"issues": "https://github.com/clue/reactphp-redis/issues",
"source": "https://github.com/clue/reactphp-redis/tree/v2.8.0"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2025-01-03T16:18:33+00:00"
},
{
"name": "darkaonline/l5-swagger",
"version": "11.1.0",
@@ -730,6 +860,53 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "evenement/evenement",
"version": "v3.0.2",
"source": {
"type": "git",
"url": "https://github.com/igorw/evenement.git",
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
"shasum": ""
},
"require": {
"php": ">=7.0"
},
"require-dev": {
"phpunit/phpunit": "^9 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"Evenement\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Igor Wiedler",
"email": "igor@wiedler.ch"
}
],
"description": "Événement is a very simple event dispatching library for PHP",
"keywords": [
"event-dispatcher",
"event-emitter"
],
"support": {
"issues": "https://github.com/igorw/evenement/issues",
"source": "https://github.com/igorw/evenement/tree/v3.0.2"
},
"time": "2023-08-08T05:53:35+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.4.0",
@@ -1566,6 +1743,85 @@
},
"time": "2026-06-26T00:11:25+00:00"
},
{
"name": "laravel/reverb",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/reverb.git",
"reference": "dca414f38e0f7acc237890ca18edfb5f3d535f86"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/reverb/zipball/dca414f38e0f7acc237890ca18edfb5f3d535f86",
"reference": "dca414f38e0f7acc237890ca18edfb5f3d535f86",
"shasum": ""
},
"require": {
"clue/redis-react": "^2.6",
"guzzlehttp/psr7": "^2.6",
"illuminate/console": "^10.47|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.47|^11.0|^12.0|^13.0",
"illuminate/http": "^10.47|^11.0|^12.0|^13.0",
"illuminate/support": "^10.47|^11.0|^12.0|^13.0",
"laravel/prompts": "^0.1.15|^0.2.0|^0.3.0",
"php": "^8.2",
"pusher/pusher-php-server": "^7.2",
"ratchet/rfc6455": "^0.4",
"react/promise-timer": "^1.10",
"react/socket": "^1.14",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-foundation": "^6.3|^7.0|^8.0"
},
"require-dev": {
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"pestphp/pest": "^2.0|^3.0|^4.0",
"phpstan/phpstan": "^1.10",
"ratchet/pawl": "^0.4.1",
"react/async": "^4.2",
"react/http": "^1.9"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Reverb\\ApplicationManagerServiceProvider",
"Laravel\\Reverb\\ReverbServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Reverb\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Joe Dixon",
"email": "joe@laravel.com"
}
],
"description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.",
"keywords": [
"WebSockets",
"laravel",
"real-time",
"websocket"
],
"support": {
"issues": "https://github.com/laravel/reverb/issues",
"source": "https://github.com/laravel/reverb/tree/v1.11.0"
},
"time": "2026-06-25T02:41:17+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.3.2",
@@ -3517,6 +3773,66 @@
},
"time": "2026-06-29T15:41:09+00:00"
},
{
"name": "pusher/pusher-php-server",
"version": "7.2.8",
"source": {
"type": "git",
"url": "https://github.com/pusher/pusher-http-php.git",
"reference": "4aa139ed2a2a805cd265449b691198beee1309d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2",
"reference": "4aa139ed2a2a805cd265449b691198beee1309d2",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.2",
"php": "^7.3|^8.0",
"psr/log": "^1.0|^2.0|^3.0"
},
"require-dev": {
"overtrue/phplint": "^2.3",
"phpunit/phpunit": "^9.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.0-dev"
}
},
"autoload": {
"psr-4": {
"Pusher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Library for interacting with the Pusher REST API",
"keywords": [
"events",
"messaging",
"php-pusher-server",
"publish",
"push",
"pusher",
"real time",
"real-time",
"realtime",
"rest",
"trigger"
],
"support": {
"issues": "https://github.com/pusher/pusher-http-php/issues",
"source": "https://github.com/pusher/pusher-http-php/tree/7.2.8"
},
"time": "2026-05-18T13:11:36+00:00"
},
{
"name": "radebatz/type-info-extras",
"version": "1.0.7",
@@ -3777,6 +4093,595 @@
},
"time": "2026-06-18T03:57:49+00:00"
},
{
"name": "ratchet/rfc6455",
"version": "v0.4.1",
"source": {
"type": "git",
"url": "https://github.com/ratchetphp/RFC6455.git",
"reference": "9b05f371219cbaf9748b505f139617dd0715592b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b",
"reference": "9b05f371219cbaf9748b505f139617dd0715592b",
"shasum": ""
},
"require": {
"php": ">=7.4",
"psr/http-factory-implementation": "^1.0",
"symfony/polyfill-php80": "^1.15"
},
"require-dev": {
"guzzlehttp/psr7": "^2.7",
"phpunit/phpunit": "^9.5",
"react/socket": "^1.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Ratchet\\RFC6455\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"role": "Developer"
},
{
"name": "Matt Bonneau",
"role": "Developer"
}
],
"description": "RFC6455 WebSocket protocol handler",
"homepage": "http://socketo.me",
"keywords": [
"WebSockets",
"rfc6455",
"websocket"
],
"support": {
"chat": "https://gitter.im/reactphp/reactphp",
"issues": "https://github.com/ratchetphp/RFC6455/issues",
"source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1"
},
"time": "2026-06-06T14:34:23+00:00"
},
{
"name": "react/cache",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/cache.git",
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
"shasum": ""
},
"require": {
"php": ">=5.3.0",
"react/promise": "^3.0 || ^2.0 || ^1.1"
},
"require-dev": {
"phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async, Promise-based cache interface for ReactPHP",
"keywords": [
"cache",
"caching",
"promise",
"reactphp"
],
"support": {
"issues": "https://github.com/reactphp/cache/issues",
"source": "https://github.com/reactphp/cache/tree/v1.2.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2022-11-30T15:59:55+00:00"
},
{
"name": "react/dns",
"version": "v1.14.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/dns.git",
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
"shasum": ""
},
"require": {
"php": ">=5.3.0",
"react/cache": "^1.0 || ^0.6 || ^0.5",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.7 || ^1.2.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
"react/async": "^4.3 || ^3 || ^2",
"react/promise-timer": "^1.11"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Dns\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async DNS resolver for ReactPHP",
"keywords": [
"async",
"dns",
"dns-resolver",
"reactphp"
],
"support": {
"issues": "https://github.com/reactphp/dns/issues",
"source": "https://github.com/reactphp/dns/tree/v1.14.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-18T19:34:28+00:00"
},
{
"name": "react/event-loop",
"version": "v1.6.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/event-loop.git",
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"suggest": {
"ext-pcntl": "For signal handling support when using the StreamSelectLoop"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\EventLoop\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
"keywords": [
"asynchronous",
"event-loop"
],
"support": {
"issues": "https://github.com/reactphp/event-loop/issues",
"source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-17T20:46:25+00:00"
},
{
"name": "react/promise",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/promise.git",
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
"shasum": ""
},
"require": {
"php": ">=7.1.0"
},
"require-dev": {
"phpstan/phpstan": "1.12.28 || 1.4.10",
"phpunit/phpunit": "^9.6 || ^7.5"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"React\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "A lightweight implementation of CommonJS Promises/A for PHP",
"keywords": [
"promise",
"promises"
],
"support": {
"issues": "https://github.com/reactphp/promise/issues",
"source": "https://github.com/reactphp/promise/tree/v3.3.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-08-19T18:57:03+00:00"
},
{
"name": "react/promise-timer",
"version": "v1.11.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/promise-timer.git",
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1",
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1",
"shasum": ""
},
"require": {
"php": ">=5.3",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.7.0 || ^1.2.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"React\\Promise\\Timer\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.",
"homepage": "https://github.com/reactphp/promise-timer",
"keywords": [
"async",
"event-loop",
"promise",
"reactphp",
"timeout",
"timer"
],
"support": {
"issues": "https://github.com/reactphp/promise-timer/issues",
"source": "https://github.com/reactphp/promise-timer/tree/v1.11.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2024-06-04T14:27:45+00:00"
},
{
"name": "react/socket",
"version": "v1.17.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/socket.git",
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
"shasum": ""
},
"require": {
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3.0",
"react/dns": "^1.13",
"react/event-loop": "^1.2",
"react/promise": "^3.2 || ^2.6 || ^1.2.1",
"react/stream": "^1.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
"react/async": "^4.3 || ^3.3 || ^2",
"react/promise-stream": "^1.4",
"react/promise-timer": "^1.11"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Socket\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
"keywords": [
"Connection",
"Socket",
"async",
"reactphp",
"stream"
],
"support": {
"issues": "https://github.com/reactphp/socket/issues",
"source": "https://github.com/reactphp/socket/tree/v1.17.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2025-11-19T20:47:34+00:00"
},
{
"name": "react/stream",
"version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/reactphp/stream.git",
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
"shasum": ""
},
"require": {
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
"php": ">=5.3.8",
"react/event-loop": "^1.2"
},
"require-dev": {
"clue/stream-filter": "~1.2",
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"psr-4": {
"React\\Stream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering",
"homepage": "https://clue.engineering/"
},
{
"name": "Cees-Jan Kiewiet",
"email": "reactphp@ceesjankiewiet.nl",
"homepage": "https://wyrihaximus.net/"
},
{
"name": "Jan Sorgalla",
"email": "jsorgalla@gmail.com",
"homepage": "https://sorgalla.com/"
},
{
"name": "Chris Boden",
"email": "cboden@gmail.com",
"homepage": "https://cboden.dev/"
}
],
"description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
"keywords": [
"event-driven",
"io",
"non-blocking",
"pipe",
"reactphp",
"readable",
"stream",
"writable"
],
"support": {
"issues": "https://github.com/reactphp/stream/issues",
"source": "https://github.com/reactphp/stream/tree/v1.4.0"
},
"funding": [
{
"url": "https://opencollective.com/reactphp",
"type": "open_collective"
}
],
"time": "2024-06-11T12:45:25+00:00"
},
{
"name": "swagger-api/swagger-ui",
"version": "v5.32.10",

View File

@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

102
src/config/reverb.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* scope_* columns are soft references (like tickets.status_key) no FK,
* so deleting a priority/subcategory/team in Admin never blocks or
* cascades into a rule; a null scope column means "any" for that filter.
* action_value likewise soft-holds whichever kind of key/id action_type
* needs (priority_key/status_key/team_id/user_id).
*/
public function up(): void
{
Schema::create('automation_rules', function (Blueprint $table) {
$table->id();
$table->string('label');
$table->boolean('enabled')->default(true);
$table->unsignedInteger('condition_minutes');
$table->string('scope_priority_key')->nullable();
$table->unsignedBigInteger('scope_subcategory_id')->nullable();
$table->unsignedBigInteger('scope_team_id')->nullable();
$table->string('action_type');
$table->string('action_value');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('automation_rules');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* One row per (rule, ticket) firing the idempotency latch that stops
* RunAutomationRules from re-applying the same rule to the same ticket
* every scheduler tick. Rows are deleted (not flagged) by TicketService
* whenever the underlying silence is broken, so the rule can fire again.
*/
public function up(): void
{
Schema::create('automation_rule_ticket_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('automation_rule_id')->constrained()->cascadeOnDelete();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->timestamp('triggered_at');
$table->unique(['automation_rule_id', 'ticket_id']);
});
}
public function down(): void
{
Schema::dropIfExists('automation_rule_ticket_logs');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Nullable, no backfill of existing rows RunAutomationRules falls back
* to created_at when this is null, mirroring how resolutionDeadline()
* treats a missing SlaRule as "no SLA" rather than backfilling one.
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->timestamp('last_customer_activity_at')->nullable()->after('sla_notified_at');
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn('last_customer_activity_at');
});
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Backfills the new 'ticket_created_team' trigger + its email template
* for an already-seeded database (mirrors DatabaseSeeder::
* seedEmailTemplatesAndNotifications(), which only runs on a fresh
* install) guarded so re-running, or a fresh seed that already has
* both rows, is a no-op.
*/
public function up(): void
{
$templateId = DB::table('email_templates')->where('key', 'tpl-team-new-ticket')->value('id');
if (! $templateId) {
$templateId = DB::table('email_templates')->insertGetId([
'key' => 'tpl-team-new-ticket',
'name' => 'Nowe zgłoszenie w zespole',
'trigger_label' => 'Nowe zgłoszenie w zespole — operator',
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p><p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p><p>Pozdrawiamy,<br>Zespół Wsparcia</p>',
'created_at' => now(),
'updated_at' => now(),
]);
}
if (DB::table('notification_settings')->where('trigger_key', 'ticket_created_team')->exists()) {
return;
}
DB::table('notification_settings')->insert([
'trigger_key' => 'ticket_created_team',
'trigger_label' => 'Nowe zgłoszenie w zespole (powiadom operatorów)',
'enabled' => true,
'recipient' => 'operator',
'email_template_id' => $templateId,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
DB::table('notification_settings')->where('trigger_key', 'ticket_created_team')->delete();
DB::table('email_templates')->where('key', 'tpl-team-new-ticket')->delete();
}
};

View File

@@ -55,7 +55,7 @@ class DatabaseSeeder extends Seeder
['key' => 'operator', 'label' => 'Operator'],
['key' => 'admin', 'label' => 'Administrator'],
] as $role) {
Role::query()->create($role);
Role::query()->firstOrCreate(['key' => $role['key']], $role);
}
}
@@ -259,6 +259,11 @@ class DatabaseSeeder extends Seeder
{
foreach ([
['label' => 'Wyślij i „Oczekuje na klienta”', 'status_key' => 'waiting_customer', 'sort_order' => 1],
// Used to point at the now-removed "resolved" status, folded into
// "closed" by the status restructure — same target as "Wyślij i
// zamknij" today, kept as a separate quick action for continuity
// with the old hardcoded menu (see ReplyQuickActionsTest).
['label' => 'Wyślij i oznacz jako rozwiązane', 'status_key' => 'closed', 'sort_order' => 2],
['label' => 'Wyślij i zamknij', 'status_key' => 'closed', 'sort_order' => 3],
] as $action) {
ReplyQuickAction::query()->create($action);
@@ -335,13 +340,17 @@ class DatabaseSeeder extends Seeder
'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
],
'tpl-team-new-ticket' => [
'name' => 'Nowe zgłoszenie w zespole', 'trigger_label' => 'Nowe zgłoszenie w zespole — operator',
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer,
],
];
$ids = [];
foreach ($templates as $key => $tpl) {
$ids[$key] = EmailTemplate::query()->create([
'key' => $key,
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
'name' => $tpl['name'],
'trigger_label' => $tpl['trigger_label'],
'subject' => $tpl['subject'],
@@ -359,9 +368,9 @@ class DatabaseSeeder extends Seeder
['trigger_key' => 'ticket_closed', 'trigger_label' => 'Zgłoszenie zamknięte', 'enabled' => true, 'recipient' => 'client', 'template' => 'tpl-closed'],
['trigger_key' => 'operator_replied', 'trigger_label' => 'Nowa odpowiedź operatora', 'enabled' => true, 'recipient' => 'client', 'template' => 'tpl-reply'],
['trigger_key' => 'sla_breached', 'trigger_label' => 'Przekroczono SLA (powiadom operatora)', 'enabled' => false, 'recipient' => 'operator', 'template' => 'tpl-sla-breach'],
['trigger_key' => 'ticket_created_team', 'trigger_label' => 'Nowe zgłoszenie w zespole (powiadom operatorów)', 'enabled' => true, 'recipient' => 'operator', 'template' => 'tpl-team-new-ticket'],
] as $setting) {
NotificationSetting::query()->create([
'trigger_key' => $setting['trigger_key'],
NotificationSetting::query()->firstOrCreate(['trigger_key' => $setting['trigger_key']], [
'trigger_label' => $setting['trigger_label'],
'enabled' => $setting['enabled'],
'recipient' => $setting['recipient'],

40
src/package-lock.json generated
View File

@@ -4,6 +4,10 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"laravel-echo": "^2.1.0",
"pusher-js": "^8.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",
@@ -909,6 +913,27 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/laravel-echo": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"pusher-js": "*",
"socket.io-client": "*"
},
"peerDependenciesMeta": {
"pusher-js": {
"optional": true
},
"socket.io-client": {
"optional": true
}
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
@@ -1275,6 +1300,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pusher-js": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1451,6 +1485,12 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"license": "Unlicense"
},
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",

View File

@@ -6,6 +6,10 @@
"build": "vite build",
"dev": "vite"
},
"dependencies": {
"laravel-echo": "^2.1.0",
"pusher-js": "^8.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.0.1",

View File

@@ -343,6 +343,22 @@ body {
.page-pad { padding: 16px !important; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
.nav-panel-label { display: none; }
/* Theme/notifications/profile dropdowns are anchored (position:absolute)
to their own small trigger button by default, which overflows off the
edge of narrow screens once their fixed width no longer fits between
the button and the viewport edge. Dropping the wrapper's own
positioning context makes .nav itself (already position:relative) the
containing block instead, so left/right:0 spans the whole navbar
width rather than the button's. */
.nav-dropdown-wrap { position: static !important; }
.nav-dropdown {
left: 0 !important;
right: 0 !important;
width: auto !important;
min-width: 0 !important;
margin-top: 8px !important;
}
.profile-menu-name { display: none; }
.main-col { min-width: 0; }
.aside-col { width: 100%; }

View File

@@ -1 +1,9 @@
//
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allow your team to quickly build robust real-time web applications.
*/
import './echo';

84
src/resources/js/echo.js Normal file
View File

@@ -0,0 +1,84 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
// Private channel subscriptions POST to /broadcasting/auth, which sits
// behind the app's normal CSRF middleware like any other POST route —
// without this header every private-channel auth request 419s silently
// (pusher-js swallows it as a subscription error), so nothing broadcast
// ever reaches the browser even though the socket connection itself works.
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
auth: {
headers: {
'X-CSRF-TOKEN': csrfToken,
},
},
});
/**
* Bridges Reverb broadcast events into plain Livewire events rather than
* using the `#[On('echo-private:...')]` attribute directly on components —
* this indirection is deliberately version-agnostic and easy to verify from
* the browser console regardless of Livewire's internals.
*
* This file is loaded via @vite as `type="module"`, which the HTML spec
* defers until after the document is parsed — meaning any plain
* (non-deferred) <script> earlier in the page, including Livewire's own
* bootstrap script from @livewireScripts, has ALREADY run by the time this
* executes. So `window.Livewire` is already available here; there's no
* reason to wait for the 'livewire:init' event. Waiting for it was actually
* a bug: Livewire dispatches that event synchronously as part of its own
* (earlier-running) script, so a listener registered this late permanently
* missed it — silently disabling this whole subscription, every time.
*/
if (window.currentUserId) {
window.Echo.private('operator.queue')
.listen('.TicketQueueChanged', (e) => {
if (e.actorId !== window.currentUserId) {
// Only ticketId is passed through — Livewire calls #[On] methods
// with the payload as named arguments, so keeping this to a
// single well-known key avoids every listener having to declare
// (and ignore) every field this event might ever carry.
Livewire.dispatch('queue-changed', { ticketId: e.ticketId });
}
})
.error((error) => console.error('operator.queue subscription error', error));
}
/**
* Subscribes to a single ticket's channel — called by the Blade view of
* whichever TicketShow component (operator or client) is currently mounted,
* since the channel name needs the ticket id that only the page knows.
*/
window.subscribeToTicketChannel = function (ticketId) {
window.Echo.private('ticket.' + ticketId)
.listen('.TicketMessagePosted', (e) => {
if (e.actorId !== window.currentUserId) {
Livewire.dispatch('ticket-message-posted', { ticketId: e.ticketId });
}
})
.listen('.TicketQueueChanged', (e) => {
if (e.actorId !== window.currentUserId) {
Livewire.dispatch('queue-changed', { ticketId: e.ticketId });
}
})
.error((error) => console.error('ticket.' + ticketId + ' subscription error', error));
};
// The @script block in ticket-show.blade.php calls subscribeToTicketChannel()
// as soon as Livewire processes that component — which can happen either
// before or after this deferred module has run, depending on exactly when
// Livewire gets to it. If it ran first, it queued the ticket id here instead
// of finding the function undefined; flush that queue now that we're ready.
(window.__pendingTicketChannelIds || []).forEach((id) => window.subscribeToTicketChannel(id));
window.__pendingTicketChannelIds = null;

View File

@@ -16,7 +16,7 @@
@endphp
@if ($user)
<div x-data="{ open: false }" @click.outside="open = false" style="position:relative;display:inline-block">
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block">
<button type="button" class="btn btn-secondary" @click="open = !open" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">account_circle</span>
<span class="profile-menu-name">{{ $user->name }}</span>
@@ -25,6 +25,7 @@
<div
x-show="open"
x-cloak
class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
>
@foreach ($areas as $area)

View File

@@ -12,6 +12,7 @@
}
}"
@click.outside="open = false"
class="nav-dropdown-wrap"
style="position:relative;display:inline-block"
>
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
@@ -20,6 +21,7 @@
<div
x-show="open"
x-cloak
class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"
>
<button type="button" class="theme-toggle-option" @click="apply('light')">

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? \App\Support\Settings::get('company_name') }}</title>
<link rel="icon" type="image/svg+xml" href="{{ \App\Support\Settings::faviconUrl() }}">
@@ -70,6 +71,12 @@
.ql-editor hr { border: none; border-top: 1px solid var(--color-divider); margin: 10px 0; }
</style>
<script>
// Lets the Echo listeners in resources/js/echo.js tell "my own action
// echoed back" apart from "someone else changed this" without a
// roundtrip — broadcast event payloads carry the same actorId shape.
window.currentUserId = @json(auth()->id());
</script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
@livewireStyles

View File

@@ -7,6 +7,7 @@ $tabGroups = [
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
],
'Zespół' => [
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
@@ -322,6 +323,39 @@ $tabGroups = [
@endif
@endif
{{-- ================= AUTOMATION RULES ================= --}}
@if ($tab === 'automation-rules')
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Automatyzacja SLA</h3>
<button class="btn btn-primary" wire:click="openAutomationRuleForm">+ Dodaj regułę</button>
</div>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Co 15 minut sprawdzane jest, czy zgłoszenie milczy (brak odpowiedzi klienta) dłużej niż próg reguły jeśli tak (i pasuje do opcjonalnego zawężenia), wykonywana jest wybrana akcja. Reguła nie powtarza się dla tego samego zgłoszenia, dopóki klient znów nie napisze albo zgłoszenie nie zostanie zamknięte i otwarte ponownie.</p>
@if ($this->automationRules->isNotEmpty())
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($this->automationRules as $rule)
<div class="card" style="padding:14px 16px;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div>
<span class="card-title">{{ $rule->label }}</span>
<span class="text-muted" style="font-size:12px;display:block">Brak odpowiedzi klienta &ge; {{ $rule->condition_minutes }} min. {{ match($rule->action_type) { 'change_priority' => 'zmień priorytet', 'change_status' => 'zmień status', 'change_team' => 'zmień zespół', 'change_assignee' => 'zmień przypisanie', default => $rule->action_type } }}</span>
</div>
<div style="display:flex;gap:6px;flex:none;align-items:center">
<label style="display:flex;align-items:center;gap:4px;font-size:12px">
<input type="checkbox" wire:click="toggleAutomationRuleEnabled({{ $rule->id }})" @checked($rule->enabled)>
Aktywna
</label>
<button class="btn btn-ghost" type="button" wire:click="editAutomationRule({{ $rule->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeAutomationRule({{ $rule->id }})">Usuń</button>
</div>
</div>
</div>
@endforeach
</div>
@else
<p class="text-muted" style="font-size:13px">Brak reguł automatyzacji. Dodaj pierwszą używając przycisku wyżej.</p>
@endif
@endif
{{-- ================= STATUSES ================= --}}
@if ($tab === 'statuses')
<h3 style="margin:0 0 6px">Statusy</h3>
@@ -803,6 +837,96 @@ $tabGroups = [
</div>
@endif
@if ($automationRuleFormOpen)
<div class="dialog-backdrop">
<form wire:submit="submitAutomationRule" class="dialog" style="max-width:520px">
<div class="dialog-title">{{ $automationRuleForm['id'] ? 'Edytuj regułę automatyzacji' : 'Nowa reguła automatyzacji' }}</div>
<div class="field"><label>Nazwa reguły</label><input class="input" wire:model="automationRuleForm.label" placeholder="np. Eskalacja przy braku odpowiedzi"></div>
@error('automationRuleForm.label') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field">
<label>Brak odpowiedzi klienta przez (minuty)</label>
<input class="input" type="number" min="1" wire:model="automationRuleForm.condition_minutes">
</div>
@error('automationRuleForm.condition_minutes') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="field">
<label>Zawężenie (opcjonalne puste = dowolne)</label>
<select class="input" wire:model="automationRuleForm.scope_priority_key" style="margin-bottom:6px">
<option value="">Dowolny priorytet</option>
@foreach ($this->priorities as $p)
<option value="{{ $p->key }}">{{ $p->label }}</option>
@endforeach
</select>
<select class="input" wire:model="automationRuleForm.scope_subcategory_id" style="margin-bottom:6px">
<option value="">Dowolna kategoria</option>
@foreach ($this->subcategoriesForTeamForm as $s)
<option value="{{ $s['id'] }}">{{ $s['label'] }}</option>
@endforeach
</select>
<select class="input" wire:model="automationRuleForm.scope_team_id">
<option value="">Dowolny zespół</option>
@foreach ($this->teams as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
</div>
<div class="field">
<label>Akcja</label>
<select class="input" wire:model.live="automationRuleForm.action_type">
<option value="change_priority">Zmień priorytet</option>
<option value="change_status">Zmień status</option>
<option value="change_team">Zmień zespół</option>
<option value="change_assignee">Zmień przypisanie</option>
</select>
</div>
<div class="field">
<label>Nowa wartość</label>
@switch ($automationRuleForm['action_type'])
@case ('change_priority')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->priorities as $p)
<option value="{{ $p->key }}">{{ $p->label }}</option>
@endforeach
</select>
@break
@case ('change_status')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->statuses as $s)
<option value="{{ $s->key }}">{{ $s->label }}</option>
@endforeach
</select>
@break
@case ('change_team')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->teams as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
@break
@case ('change_assignee')
<select class="input" wire:model="automationRuleForm.action_value">
<option value=""> wybierz </option>
@foreach ($this->operatorsForTeamForm as $o)
<option value="{{ $o['id'] }}">{{ $o['label'] }}</option>
@endforeach
</select>
@break
@endswitch
</div>
@error('automationRuleForm.action_value') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div class="dialog-actions">
<button class="btn btn-secondary" type="button" wire:click="closeAutomationRuleForm">Anuluj</button>
<button class="btn btn-primary" type="submit">{{ $automationRuleForm['id'] ? 'Zapisz' : 'Dodaj regułę' }}</button>
</div>
</form>
</div>
@endif
@if ($this->editingTemplate)
<div class="dialog-backdrop">
<div class="dialog" style="max-width:560px">

View File

@@ -60,7 +60,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -1,8 +1,24 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">&larr; Wróć do listy</a>
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
@@ -31,7 +47,7 @@
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($threadMessages as $m)
@php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@@ -94,12 +110,18 @@
<div style="font-size:14px;font-weight:500">{{ auth()->user()->name }}</div>
<div class="text-muted" style="font-size:13px">{{ auth()->user()->email }}</div>
</div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
</div>
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Status i priorytet</div>
<div style="display:flex;gap:6px">
<div style="display:flex;gap:6px;flex-wrap:wrap">
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</div>
<div style="font-size:13px" class="text-muted">Przypisany operator: <span style="color:var(--color-text)">{{ $ticket->assignee?->name ?? 'Nieprzypisane' }}</span></div>
<div style="font-size:13px" class="text-muted">Zespół: <span style="color:var(--color-text)">{{ $ticket->team?->name ?? 'Brak' }}</span></div>
@if (! $ticket->isClosed())
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
@elseif ($ticket->isClosed())
@@ -137,7 +159,7 @@
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Historia zmian</div>
@forelse ($ticket->histories as $h)
<div style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
<div wire:key="history-{{ $h->id }}" style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
@empty
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
@endforelse
@@ -169,4 +191,19 @@
</div>
</div>
@endif
@script
<script>
// Re-runs on every mount of this component, including after a
// wire:navigate to a different ticket, so the socket subscription
// always matches whichever ticket is currently on screen.
if (window.subscribeToTicketChannel) {
window.subscribeToTicketChannel({{ $ticket->id }});
} else {
// echo.js (a deferred module) hasn't run yet — queue the id so it
// subscribes as soon as it does, instead of silently doing nothing.
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
}
</script>
@endscript
</div>

View File

@@ -90,7 +90,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -1,4 +1,4 @@
<div x-data="{ open: false }" @click.outside="open = false" style="position:relative;display:inline-block" wire:poll.30s="$refresh">
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" 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)
@@ -9,6 +9,7 @@
<div
x-show="open"
x-cloak
class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);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)">
@@ -20,11 +21,12 @@
@forelse ($this->notifications as $notification)
<a
wire:key="notification-{{ $notification->id }}"
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)' }}"
style="display:block;padding:10px 14px;text-decoration:none;color:var(--color-text);border-bottom:1px solid var(--color-divider);font-size:12.5px;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>

View File

@@ -66,7 +66,9 @@
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field">
<label>Temat</label>

View File

@@ -116,6 +116,21 @@
@endforeach
</div>
</div>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently (backgrounded tab, network blip) this is a periodic
fallback refresh, with a visible countdown so it's clear the queue
is still refreshing itself rather than just stuck. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 60, total: 60 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
title="Kolejka odświeża się automatycznie co minutę"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div class="table-wrap">
@@ -143,7 +158,7 @@
<tbody>
@foreach ($this->filteredTickets as $t)
@php $sla = $t->slaInfo(); @endphp
<tr>
<tr wire:key="ticket-{{ $t->id }}">
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
@if (in_array('number', $visibleColumns))
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>

View File

@@ -61,6 +61,8 @@
{{-- KPI tiles --}}
@php($kpis = $this->kpis)
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Podsumowanie</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:12px">
<div class="stat-tile">
<div class="stat-tile-label">Łącznie zgłoszeń</div>
@@ -96,84 +98,200 @@
<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">
{{-- By status --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
@php($max = max($this->byStatus->max('count'), 1))
@forelse ($this->byStatus as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
{{-- Rozkład zgłoszeń --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Rozkład zgłoszeń</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
@php($max = max($this->byStatus->max('count'), 1))
@forelse ($this->byStatus as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
@php($max = max($this->byPriority->max('count'), 1))
@forelse ($this->byPriority as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
@php($cats = $this->byCategory)
@php($max = max($cats->max('count') ?? 0, 1))
@forelse ($cats as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg podkategorii</div>
@php($subs = $this->bySubcategory)
@php($max = max($subs->max('count') ?? 0, 1))
@forelse ($subs as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
{{-- Obciążenie --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Obciążenie</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
@php($teamRows = $this->byTeam)
@php($max = max($teamRows->max('count') ?? 0, 1))
@forelse ($teamRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
@php($opRows = $this->byAssignee)
@php($max = max($opRows->max('count') ?? 0, 1))
@forelse ($opRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
{{-- Klienci --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Klienci</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Najaktywniejsi klienci (Top 10)</div>
@php($customerRows = $this->byCustomer)
@php($max = max($customerRows->max('count') ?? 0, 1))
@forelse ($customerRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
{{-- By priority --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
@php($max = max($this->byPriority->max('count'), 1))
@forelse ($this->byPriority as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
<div class="card-title" style="margin-bottom:4px">Klienci wg podkategorii</div>
<div class="card-meta" style="margin-bottom:12px">Top 10 klientów × top 5 podkategorii wg wolumenu w wybranym okresie; reszta zbiorczo w kolumnie „Inne”.</div>
@php($matrix = $this->customerSubcategoryMatrix)
@if (count($matrix['rows']))
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Klient</th>
@foreach ($matrix['columns'] as $col)
<th>{{ $col }}</th>
@endforeach
@if ($matrix['hasOther'])
<th>Inne</th>
@endif
<th>Razem</th>
</tr>
</thead>
<tbody>
@foreach ($matrix['rows'] as $row)
<tr>
<td style="white-space:nowrap">{{ $row['label'] }}</td>
@foreach ($row['cells'] as $cell)
<td>{{ $cell ?: '—' }}</td>
@endforeach
@if ($matrix['hasOther'])
<td>{{ $row['other'] ?: '—' }}</td>
@endif
<td style="font-weight:600">{{ $row['total'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@empty
@else
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
@endif
</div>
</div>
{{-- By category --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
@php($cats = $this->byCategory)
@php($max = max($cats->max('count') ?? 0, 1))
@forelse ($cats as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
{{-- Ocena obsługi (CSAT) --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Ocena obsługi (CSAT)</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">CSAT wg zespołu</div>
@php($csatTeamRows = $this->csatByTeam)
@forelse ($csatTeamRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
</div>
@empty
<div class="card-meta">Brak ocen w wybranym okresie.</div>
@endforelse
</div>
{{-- By team --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
@php($teamRows = $this->byTeam)
@php($max = max($teamRows->max('count') ?? 0, 1))
@forelse ($teamRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
{{-- By assignee --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
@php($opRows = $this->byAssignee)
@php($max = max($opRows->max('count') ?? 0, 1))
@forelse ($opRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">CSAT wg operatora</div>
@php($csatOpRows = $this->csatByAssignee)
@forelse ($csatOpRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
</div>
@empty
<div class="card-meta">Brak ocen w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
@@ -181,6 +299,8 @@
@php($trend = $this->trend)
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1))
@php($labelStep = max((int) ceil(count($trend) / 12), 1))
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Trend w czasie</h3>
<div class="card" style="padding:16px">
<div class="card-title">Trend zgłoszeń</div>
<div class="card-meta" style="margin-bottom:12px">Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).</div>
@@ -223,5 +343,6 @@
</div>
@endif
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,23 @@
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">&larr; Wróć do listy</a>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
@@ -67,7 +83,7 @@
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Notatki wewnętrzne</div>
@forelse ($this->internalMessages as $m)
<div style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
<div wire:key="note-{{ $m->id }}" style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}</div>
@if ($m->role === 'operator')
@@ -130,7 +146,7 @@
<div style="display:flex;flex-direction:column;gap:10px">
@foreach ($threadMessages as $m)
@php $mine = $m->role === 'operator'; @endphp
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@@ -286,7 +302,9 @@
</div>
</div>
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</div>
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">SLA</div>
@@ -435,4 +453,19 @@
</div>
</div>
@endif
@script
<script>
// Re-runs on every mount of this component, including after a
// wire:navigate to a different ticket, so the socket subscription
// always matches whichever ticket is currently on screen.
if (window.subscribeToTicketChannel) {
window.subscribeToTicketChannel({{ $ticket->id }});
} else {
// echo.js (a deferred module) hasn't run yet — queue the id so it
// subscribes as soon as it does, instead of silently doing nothing.
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
}
</script>
@endscript
</div>

36
src/routes/channels.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
use App\Models\Ticket;
use Illuminate\Support\Facades\Broadcast;
/**
* One shared channel for anything queue-relevant (new/changed/closed
* tickets) rather than per-team channels payloads carry only minimal
* metadata, so the receiving Livewire component just re-queries through its
* own already-correct Ticket::scopeVisibleToOperator() instead of this
* callback needing to duplicate that ACL logic.
*/
Broadcast::channel('operator.queue', function ($user) {
return in_array('operator', $user->roles ?? []) || $user->isAdmin();
});
/**
* Per-ticket channel for message/detail changes (drives both the live
* message thread and live ticket-header updates). OR, not else-if the
* owner's account holds both client and operator roles at once, so both
* branches must be checked rather than picking one based on role alone.
* Payloads here also stay minimal (never the message body itself), so an
* internal note can safely broadcast on the same channel a client is
* subscribed to the client's own computed properties never touch
* internal messages regardless of which event arrived.
*/
Broadcast::channel('ticket.{ticketId}', function ($user, int $ticketId) {
$ticket = Ticket::query()->find($ticketId);
if (! $ticket) {
return false;
}
return (in_array('operator', $user->roles ?? []) && $ticket->isVisibleToOperator($user))
|| $ticket->customer_id === $user->id;
});

View File

@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
})->purpose('Display an inspiring quote');
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
Schedule::command('automation:run-rules')->everyFifteenMinutes();

View File

@@ -0,0 +1,172 @@
<?php
use App\Models\AutomationRule;
use App\Models\Priority;
use App\Models\Team;
use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Artisan;
test('a rule fires once a ticket has been silent past its threshold and applies its action', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($rule->fresh()->hasFiredFor($ticket->fresh()))->toBeTrue();
// TicketService::setPriority() writes its own "Priorytet zmieniony na: ..."
// history line; RunAutomationRules adds a second, clearly-attributed one.
expect($ticket->fresh()->histories()->count())->toBe(2);
});
test('a rule does not fire again on the same ticket once already latched', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
$ticket->refresh()->update(['priority_key' => 'high']); // simulate an operator manually reverting it
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a fresh client reply resets the latch so the rule can fire again after new silence', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$client = User::query()->create(['name' => 'Klient', 'email' => 'client-auto@example.com', 'roles' => ['client']]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
app(TicketService::class)->clientReply($ticket->fresh(), $client, 'Nadal potrzebuję pomocy');
$ticket->refresh()->update(['last_customer_activity_at' => now()->subMinutes(90), 'priority_key' => 'high']);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
});
test('a rule scoped to a priority ignores tickets with a different priority', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['priority_key' => 'low', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Tylko dla wysokiego priorytetu',
'enabled' => true,
'condition_minutes' => 60,
'scope_priority_key' => 'high',
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('low');
expect($ticket->fresh()->histories()->count())->toBe(0);
});
test('a disabled rule never fires', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Wyłączona reguła',
'enabled' => false,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('a closed ticket is never matched even if it has been silent long enough', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet po 60 min',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($ticket->fresh()->priority_key)->toBe('high');
});
test('two independent rules can both fire on the same ticket in one run', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$team = Team::query()->create(['name' => 'Wsparcie L2']);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
AutomationRule::query()->create([
'label' => 'Przekaż do L2',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_team',
'action_value' => (string) $team->id,
]);
Artisan::call('automation:run-rules');
$ticket->refresh();
expect($ticket->priority_key)->toBe('low');
expect($ticket->team_id)->toBe($team->id);
expect($ticket->histories()->count())->toBe(4);
});
test('reopening a closed ticket clears its automation logs so rules can fire again', function () {
seedStatusesAndPriorities();
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
$rule = AutomationRule::query()->create([
'label' => 'Podnieś priorytet',
'enabled' => true,
'condition_minutes' => 60,
'action_type' => 'change_priority',
'action_value' => 'low',
]);
Artisan::call('automation:run-rules');
expect($rule->logs()->count())->toBe(1);
app(TicketService::class)->setStatus($ticket->fresh(), 'closed');
expect($rule->logs()->count())->toBe(0);
});

View File

@@ -3,13 +3,15 @@
use App\Livewire\Admin\Panel;
use App\Models\EmailTemplate;
use App\Models\NotificationSetting;
use App\Models\User;
use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
$admin = adminUser();
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
Livewire::actingAs($admin)->test(Panel::class)
@@ -26,10 +28,11 @@ test('admin can toggle a notification on/off but cannot add, delete, or reassign
});
test('admin can edit the subject and body of a trigger\'s fixed template', function () {
$admin = adminUser();
$this->seed();
$admin = User::query()->withRole('admin')->firstOrFail();
// A bare migrated (unseeded) database has no email_templates rows yet, so
// ticket_created's binding is still null at this point — give it one.
// Repoint ticket_created's NotificationSetting at a template we control,
// instead of the real seeded one, so this test's assertions are its own.
$template = EmailTemplate::query()->create(['key' => 'tpl-created-test', 'name' => 'Nowe', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B']);
$ticketCreated = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail();
$ticketCreated->update(['email_template_id' => $template->id]);
@@ -50,7 +53,7 @@ test('admin can edit the subject and body of a trigger\'s fixed template', funct
test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$template = EmailTemplate::query()->create([
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',

View File

@@ -9,19 +9,21 @@ use App\Notifications\TicketNotification;
use App\Services\TicketService;
use Illuminate\Support\Facades\Notification;
test('the 6 extended triggers exist and are disabled by default, alongside the 2 enabled originals', function () {
test('the 10 notification triggers exist, with the customer-facing lifecycle ones (and the new-ticket-for-team one) enabled by default and the rest opt-in', function () {
$this->seed();
$settings = NotificationSetting::query()->get()->keyBy('trigger_key');
expect($settings->keys()->sort()->values()->all())->toBe([
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created',
'ticket_created_team',
]);
foreach (['ticket_created', 'status_changed'] as $key) {
foreach (['ticket_created', 'status_changed', 'ticket_closed', 'operator_replied', 'ticket_created_team'] as $key) {
expect($settings[$key]->enabled)->toBeTrue();
}
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'ticket_closed', 'operator_replied', 'sla_breached'] as $key) {
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'sla_breached'] as $key) {
expect($settings[$key]->enabled)->toBeFalse()
->and($settings[$key]->email_template_id)->not->toBeNull();
}
@@ -29,7 +31,7 @@ test('the 6 extended triggers exist and are disabled by default, alongside the 2
test('a disabled-by-default trigger sends nothing until an admin turns it on', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$ticket = makeTicket();
app(TicketService::class)->setPriority($ticket, 'high');
@@ -42,7 +44,7 @@ test('a disabled-by-default trigger sends nothing until an admin turns it on', f
test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -57,7 +59,7 @@ test('changing the assignee fires assignee_changed with the {operator} placehold
test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
$ticket = makeTicket();
@@ -72,7 +74,7 @@ test('changing the team fires team_changed with the {zespol} placeholder once en
test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
$category = Category::query()->create(['name' => 'IT-Pomoc']);
@@ -92,12 +94,12 @@ test('changing the subcategory fires category_changed, but re-saving details wit
test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
// status_changed ships enabled by default, but in a bare migrated (unseeded)
// database it has no template assigned yet — give it one so it would have
// something to send if it (wrongly) fired, isolating this test from seeding order.
// status_changed ships enabled by default — repoint it at a template we
// control so this test's "did it wrongly fire" assertion isn't tied to
// whatever content the real seeded template happens to have.
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
@@ -112,7 +114,7 @@ test('closing a ticket fires only ticket_closed, not status_changed, so it does
test('a non-closing status change still fires status_changed as usual', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
@@ -128,7 +130,7 @@ test('a non-closing status change still fires status_changed as usual', function
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
$ticket = makeTicket();
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]);
@@ -137,3 +139,48 @@ test('an operator reply fires operator_replied once enabled, independent of any
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('every member of a team whose subcategory matches a new ticket gets notified once, no duplicates', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$sub = $category->subcategories()->create(['name' => 'VPN']);
$team = Team::query()->create(['name' => 'Zespół VPN']);
$team->subcategories()->attach($sub->id);
$memberA = User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']]);
$memberB = User::query()->create(['name' => 'Jan', 'email' => 'team-notif-b@example.com', 'roles' => ['operator']]);
$team->members()->attach([$memberA->id, $memberB->id]);
app(TicketService::class)->create([
'email' => 'client-team-notif@example.com',
'subject' => 'Problem z VPN',
'body' => 'Nie mogę się połączyć.',
'subcategory_id' => $sub->id,
], null);
Notification::assertSentTo($memberA, TicketNotification::class);
Notification::assertSentTo($memberB, TicketNotification::class);
// Plus one more: TicketService::create() also fires the pre-existing
// 'ticket_created' trigger, routed anonymously to the guest's e-mail
// since this ticket has no real customer account.
Notification::assertSentTimes(TicketNotification::class, 3);
});
test('a new ticket with no matching team notifies no operator', function () {
Notification::fake();
$this->seed();
$category = Category::query()->create(['name' => 'Bez zespołu']);
$sub = $category->subcategories()->create(['name' => 'Inne']);
$operator = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
app(TicketService::class)->create([
'email' => 'client-no-team@example.com',
'subject' => 'Coś innego',
'body' => 'Treść.',
'subcategory_id' => $sub->id,
], null);
Notification::assertNotSentTo($operator, TicketNotification::class);
});

View File

@@ -6,6 +6,8 @@ use App\Models\ReplyQuickAction;
use Livewire\Livewire;
test('the 3 default reply quick actions exist out of the box, matching the old hardcoded menu', function () {
$this->seed();
// "Wyślij i oznacz jako rozwiązane" used to point at the now-removed
// "resolved" status (folded into "closed" — see the status restructure
// migration), so it points at "closed" today, same as "Wyślij i zamknij".
@@ -72,7 +74,7 @@ test('admin can edit and delete a reply quick action', function () {
});
test('the operator ticket view lists the configured reply quick actions in the send menu', function () {
seedStatusesAndPriorities();
$this->seed();
$operator = operatorUser('quickaction-view@example.com');
$ticket = makeTicket(['number' => '1001']);
@@ -83,7 +85,7 @@ test('the operator ticket view lists the configured reply quick actions in the s
});
test('sending via a status-changing quick action updates the ticket status', function () {
seedStatusesAndPriorities();
$this->seed();
$operator = operatorUser('quickaction-send@example.com');
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();

View File

@@ -21,7 +21,7 @@ test('the SLA-breach check does nothing while its trigger is disabled (the defau
test('an overdue ticket with an assigned operator gets notified once the trigger is enabled, and only once', function () {
Notification::fake();
seedStatusesAndPriorities();
$this->seed();
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
$operator = User::query()->create(['name' => 'Ola Operator', 'email' => 'sla-op-2@example.com', 'roles' => ['operator']]);
@@ -29,15 +29,15 @@ test('an overdue ticket with an assigned operator gets notified once the trigger
Artisan::call('tickets:check-sla-breaches');
Notification::assertSentOnDemand(
TicketNotification::class,
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'sla-op-2@example.com'
);
// $operator is a real persisted User (not a guest), so TicketService::notify()
// notifies it directly rather than routing anonymously — assertSentTo, not
// assertSentOnDemand (which only matches AnonymousNotifiable routing).
Notification::assertSentTo($operator, TicketNotification::class);
expect($ticket->fresh()->sla_notified_at)->not->toBeNull();
Artisan::call('tickets:check-sla-breaches');
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
Notification::assertSentTimes(TicketNotification::class, 1);
});
test('an overdue but unassigned ticket is never notified (nobody to send it to)', function () {

View File

@@ -8,11 +8,25 @@ use Livewire\Livewire;
test('every page renders without error against fully seeded data', function () {
$this->seed();
$client = User::query()->withRole('client')->firstOrFail();
$operator = User::query()->withRole('operator')->firstOrFail();
// DatabaseSeeder deliberately only seeds one admin fallback account and
// no ticket data (see README) — this test needs a client and an operator
// plus a ticket to exercise every page, so it creates its own on top of
// the seeded reference data (categories/teams/statuses/priorities/etc).
$client = User::query()->create(['name' => 'Smoke Client', 'email' => 'smoke-client@example.com', 'roles' => ['client']]);
$operator = User::query()->create(['name' => 'Smoke Operator', 'email' => 'smoke-operator@example.com', 'roles' => ['operator']]);
$admin = User::query()->withRole('admin')->firstOrFail();
$clientTicket = Ticket::query()->where('customer_id', $client->id)->firstOrFail();
$anyTicket = Ticket::query()->firstOrFail();
$clientTicket = Ticket::query()->create([
'number' => '9001',
'customer_id' => $client->id,
'email' => $client->email,
'name' => $client->name,
'subject' => 'Smoke test subject',
'body' => 'Smoke test body',
'status_key' => 'new',
'priority_key' => 'high',
'custom_fields' => [],
]);
$anyTicket = $clientTicket;
$this->get('/')->assertOk()->assertSee('Jak możemy pomóc');
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');

View File

@@ -0,0 +1,48 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Team;
use App\Models\User;
use Livewire\Livewire;
test('csatByTeam averages ratings per team and drops teams with no ratings', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin@example.com', 'roles' => ['admin']]);
$teamA = Team::query()->create(['name' => 'Zespół A']);
$teamB = Team::query()->create(['name' => 'Zespół B']);
$teamC = Team::query()->create(['name' => 'Zespół C (bez ocen)']);
makeTicket(['number' => '2001', 'status_key' => 'closed', 'team_id' => $teamA->id, 'csat_rating' => 5]);
makeTicket(['number' => '2002', 'status_key' => 'closed', 'team_id' => $teamA->id, 'csat_rating' => 3]);
makeTicket(['number' => '2003', 'status_key' => 'closed', 'team_id' => $teamB->id, 'csat_rating' => 4]);
makeTicket(['number' => '2004', 'status_key' => 'closed', 'team_id' => $teamC->id, 'csat_rating' => null]);
makeTicket(['number' => '2005', 'status_key' => 'closed', 'team_id' => null, 'csat_rating' => 2]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->csatByTeam;
expect($rows->firstWhere('label', 'Zespół A'))->toBe(['label' => 'Zespół A', 'avg' => 4.0, 'count' => 2])
->and($rows->firstWhere('label', 'Zespół B'))->toBe(['label' => 'Zespół B', 'avg' => 4.0, 'count' => 1])
->and($rows->firstWhere('label', 'Bez zespołu'))->toBe(['label' => 'Bez zespołu', 'avg' => 2.0, 'count' => 1])
->and($rows->firstWhere('label', 'Zespół C (bez ocen)'))->toBeNull();
});
test('csatByAssignee averages ratings per operator and drops operators with no ratings', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-2@example.com', 'roles' => ['admin']]);
$opA = User::query()->create(['name' => 'Ola', 'email' => 'stats-op-a@example.com', 'roles' => ['operator']]);
$opB = User::query()->create(['name' => 'Jan', 'email' => 'stats-op-b@example.com', 'roles' => ['operator']]);
$opUnrated = User::query()->create(['name' => 'Bez ocen', 'email' => 'stats-op-c@example.com', 'roles' => ['operator']]);
makeTicket(['number' => '3001', 'status_key' => 'closed', 'assignee_id' => $opA->id, 'csat_rating' => 5]);
makeTicket(['number' => '3002', 'status_key' => 'closed', 'assignee_id' => $opA->id, 'csat_rating' => 1]);
makeTicket(['number' => '3003', 'status_key' => 'closed', 'assignee_id' => $opB->id, 'csat_rating' => 3]);
makeTicket(['number' => '3004', 'status_key' => 'closed', 'assignee_id' => null, 'csat_rating' => 4]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->csatByAssignee;
expect($rows->firstWhere('label', 'Ola'))->toBe(['label' => 'Ola', 'avg' => 3.0, 'count' => 2])
->and($rows->firstWhere('label', 'Jan'))->toBe(['label' => 'Jan', 'avg' => 3.0, 'count' => 1])
->and($rows->firstWhere('label', 'Nieprzypisane'))->toBe(['label' => 'Nieprzypisane', 'avg' => 4.0, 'count' => 1])
->and($rows->firstWhere('label', 'Bez ocen'))->toBeNull();
});

View File

@@ -0,0 +1,44 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\User;
use Livewire\Livewire;
test('byCustomer ranks registered clients by ticket volume and sums guest tickets into one bucket', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust@example.com', 'roles' => ['admin']]);
$clientA = User::query()->create(['name' => 'Klient A', 'email' => 'stats-client-a@example.com', 'roles' => ['client']]);
$clientB = User::query()->create(['name' => 'Klient B', 'email' => 'stats-client-b@example.com', 'roles' => ['client']]);
makeTicket(['number' => '5001', 'customer_id' => $clientA->id]);
makeTicket(['number' => '5002', 'customer_id' => $clientA->id]);
makeTicket(['number' => '5003', 'customer_id' => $clientB->id]);
makeTicket(['number' => '5004', 'customer_id' => null]);
makeTicket(['number' => '5005', 'customer_id' => null]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCustomer;
expect($rows->firstWhere('label', 'Klient A'))->toBe(['label' => 'Klient A', 'count' => 2])
->and($rows->firstWhere('label', 'Klient B'))->toBe(['label' => 'Klient B', 'count' => 1])
->and($rows->firstWhere('label', 'Goście (bez konta)'))->toBe(['label' => 'Goście (bez konta)', 'count' => 2]);
});
test('byCustomer caps the ranking at the top 10 clients by volume', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-2@example.com', 'roles' => ['admin']]);
$ticketNumber = 6000;
foreach (range(1, 12) as $i) {
$client = User::query()->create(['name' => "Klient {$i}", 'email' => "stats-client-{$i}@example.com", 'roles' => ['client']]);
// Give each client a distinct ticket count (12 down to 1) so ranking is deterministic.
foreach (range(1, 13 - $i) as $n) {
makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id]);
}
}
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->byCustomer;
expect($rows)->toHaveCount(10)
->and($rows->first())->toBe(['label' => 'Klient 1', 'count' => 12]);
});

View File

@@ -0,0 +1,81 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Category;
use App\Models\User;
use Livewire\Livewire;
test('customerSubcategoryMatrix cross-tabs clients against their top subcategories', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix@example.com', 'roles' => ['admin']]);
$clientA = User::query()->create(['name' => 'Klient A', 'email' => 'stats-matrix-client-a@example.com', 'roles' => ['client']]);
$clientB = User::query()->create(['name' => 'Klient B', 'email' => 'stats-matrix-client-b@example.com', 'roles' => ['client']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$printers = $it->subcategories()->create(['name' => 'Drukarki']);
makeTicket(['number' => '9001', 'customer_id' => $clientA->id, 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '9002', 'customer_id' => $clientA->id, 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '9003', 'customer_id' => $clientA->id, 'subcategory_id' => $printers->id]);
makeTicket(['number' => '9004', 'customer_id' => $clientB->id, 'subcategory_id' => $printers->id]);
makeTicket(['number' => '9005', 'customer_id' => null, 'subcategory_id' => $vpn->id]);
$matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
expect($matrix['columns'])->toBe(['IT-Pomoc / VPN', 'IT-Pomoc / Drukarki'])
->and($matrix['hasOther'])->toBeFalse();
$rowsByLabel = collect($matrix['rows'])->keyBy('label');
expect($rowsByLabel['Klient A'])->toBe(['label' => 'Klient A', 'cells' => [2, 1], 'other' => null, 'total' => 3])
->and($rowsByLabel['Klient B'])->toBe(['label' => 'Klient B', 'cells' => [0, 1], 'other' => null, 'total' => 1]);
});
test('customerSubcategoryMatrix caps rows at top 10 clients and columns at top 5 subcategories, folding the rest into "Inne"', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix-2@example.com', 'roles' => ['admin']]);
$client = User::query()->create(['name' => 'Klient', 'email' => 'stats-matrix-client-c@example.com', 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Kategoria']);
$ticketNumber = 9100;
foreach (range(1, 7) as $i) {
$sub = $category->subcategories()->create(['name' => "Sub {$i}"]);
// Distinct volumes (7 down to 1) so which 5 make the cut is deterministic.
foreach (range(1, 8 - $i) as $n) {
makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id, 'subcategory_id' => $sub->id]);
}
}
$matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
expect($matrix['columns'])->toBe(['Kategoria / Sub 1', 'Kategoria / Sub 2', 'Kategoria / Sub 3', 'Kategoria / Sub 4', 'Kategoria / Sub 5'])
->and($matrix['hasOther'])->toBeTrue();
$row = $matrix['rows'][0];
expect($row['cells'])->toBe([7, 6, 5, 4, 3])
->and($row['other'])->toBe(2 + 1)
->and($row['total'])->toBe(7 + 6 + 5 + 4 + 3 + 2 + 1);
});
test('customerSubcategoryMatrix caps rows at the top 10 clients by volume', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-cust-matrix-3@example.com', 'roles' => ['admin']]);
$category = Category::query()->create(['name' => 'Kategoria']);
$sub = $category->subcategories()->create(['name' => 'Sub']);
$ticketNumber = 9200;
foreach (range(1, 12) as $i) {
$client = User::query()->create(['name' => "Klient {$i}", 'email' => "stats-matrix-client-{$i}@example.com", 'roles' => ['client']]);
foreach (range(1, 13 - $i) as $n) {
makeTicket(['number' => (string) $ticketNumber++, 'customer_id' => $client->id, 'subcategory_id' => $sub->id]);
}
}
$matrix = Livewire::actingAs($admin)->test(Stats::class)->instance()->customerSubcategoryMatrix;
expect($matrix['rows'])->toHaveCount(10)
->and($matrix['rows'][0]['label'])->toBe('Klient 1')
->and($matrix['rows'][0]['total'])->toBe(12);
});

View File

@@ -0,0 +1,30 @@
<?php
use App\Livewire\Operator\Stats;
use App\Models\Category;
use App\Models\User;
use Livewire\Livewire;
test('bySubcategory groups tickets per subcategory, labeled "Category / Subcategory"', function () {
seedStatusesAndPriorities();
$admin = User::query()->create(['name' => 'Admin', 'email' => 'stats-admin-sub@example.com', 'roles' => ['admin']]);
$it = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $it->subcategories()->create(['name' => 'VPN']);
$printers = $it->subcategories()->create(['name' => 'Drukarki']);
$orders = Category::query()->create(['name' => 'Zamówienia']);
$hardware = $orders->subcategories()->create(['name' => 'Sprzęt']);
makeTicket(['number' => '4001', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '4002', 'subcategory_id' => $vpn->id]);
makeTicket(['number' => '4003', 'subcategory_id' => $printers->id]);
makeTicket(['number' => '4004', 'subcategory_id' => $hardware->id]);
makeTicket(['number' => '4005', 'subcategory_id' => null]);
$rows = Livewire::actingAs($admin)->test(Stats::class)->instance()->bySubcategory;
expect($rows->firstWhere('label', 'IT-Pomoc / VPN'))->toBe(['label' => 'IT-Pomoc / VPN', 'count' => 2])
->and($rows->firstWhere('label', 'IT-Pomoc / Drukarki'))->toBe(['label' => 'IT-Pomoc / Drukarki', 'count' => 1])
->and($rows->firstWhere('label', 'Zamówienia / Sprzęt'))->toBe(['label' => 'Zamówienia / Sprzęt', 'count' => 1])
->and($rows->count())->toBe(3);
});

View File

@@ -2,6 +2,7 @@
namespace Tests;
use App\Models\Role;
use App\Support\Settings;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@@ -14,5 +15,15 @@ abstract class TestCase extends BaseTestCase
// Settings caches statically for the lifetime of a (production) request;
// reset it between tests since Pest reuses one process for the whole run.
Settings::flush();
// User::roles is a virtual attribute backed by the roles/role_user
// pivot (see User::setAttribute()) — assigning a role by key only
// takes effect if a matching Role row already exists, so every test
// that creates a role-bearing user needs these seeded first. Mirrors
// DatabaseSeeder::seedRoles(); each test's transaction rolls this
// back, so it's re-seeded fresh before every test rather than once.
foreach (['client' => 'Klient', 'operator' => 'Operator', 'admin' => 'Administrator'] as $key => $label) {
Role::query()->firstOrCreate(['key' => $key], ['label' => $label]);
}
}
}

View File

@@ -55,6 +55,28 @@ zespołu i przypisanymi mu bezpośrednio).
„Brak”). Naruszenia sprawdza cykliczne zadanie co 15 minut
(`tickets:check-sla-breaches`) i może powiadomić operatora.
## Automatyzacja SLA
Reguły, które same zmieniają zgłoszenie po określonym czasie **ciszy ze strony
klienta** (liczonym od ostatniej odpowiedzi klienta, a jeśli jeszcze nie
odpowiedział — od utworzenia zgłoszenia). Każda reguła ma:
- **Nazwę** i przełącznik **aktywna/nieaktywna**.
- **Próg** w minutach.
- Opcjonalne **zawężenie** — priorytet / kategoria (podkategoria) / zespół;
puste pole = dowolny. Wszystkie warunki muszą być spełnione naraz.
- **Akcję** — zmień priorytet / status / zespół / przypisanego operatora, oraz
wartość docelową.
Reguły sprawdza cykliczne zadanie co 15 minut (`automation:run-rules`, razem z
`tickets:check-sla-breaches`). Akcja korzysta z tych samych mechanizmów co
ręczna zmiana przez operatora — dostaje wpis w historii zgłoszenia (z dopiskiem
„Automatyzacja: nazwa reguły”), wysyła standardowe powiadomienie dla tej zmiany
i pojawia się na żywo w kolejce/widoku zgłoszenia. Reguła nie powtarza się dla
tego samego zgłoszenia, dopóki klient znów nie napisze albo zgłoszenie nie
zostanie zamknięte i otwarte ponownie — więc bezpiecznie zostawić kilka
aktywnych reguł naraz, bez ryzyka zapętlenia się co 15 minut.
## Szybkie akcje odpowiedzi
Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu
@@ -80,14 +102,18 @@ więcej informacji”, „Restart usuwa problem”.
„Resetuj” do wartości domyślnej); sam layout nie jest edytowalny z poziomu UI.
- **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/
kategorii/priorytetu/zespołu/przypisania, zgłoszenie zamknięte, operator
odpowiedział, SLA przekroczone) — każde ma przełącznik włącz/wyłącz, odbiorcę
(klient / operator) i przypisany szablon. Usunięcie przypisanego szablonu po
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.
odpowiedział, SLA przekroczone, **nowe zgłoszenie w zespole**) — każde ma
przełącznik włącz/wyłącz, odbiorcę (klient / operator) i przypisany szablon.
Usunięcie przypisanego szablonu po 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.
„Nowe zgłoszenie w zespole” (domyślnie włączone) trafia do **każdego**
operatora w zespole, którego podkategorie pasują do nowego zgłoszenia, nie
tylko do jednej przypisanej osoby. **Ten sam przełącznik kontroluje zarówno
e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — nie ma osobnego
ustawienia dla powiadomień w apce, a dzwoneczek pokazuje tylko nieprzeczytane
(znikają po kliknięciu/oznaczeniu).
## Wygląd / Branding

View File

@@ -3,7 +3,8 @@
Panel klienta (`/client`) służy do zgłaszania problemów/próśb i śledzenia ich
rozwiązania. Po zalogowaniu każde konto domyślnie ląduje właśnie tutaj — nawet jeśli
posiada też uprawnienia operatora lub administratora (przełączysz się przez menu
profilu w prawym górnym rogu).
profilu w prawym górnym rogu). Dzwoneczek powiadomień w górnym pasku pokazuje
tylko **nieprzeczytane** powiadomienia — kliknięcie usuwa je z listy.
## Zgłaszanie nowej sprawy
@@ -41,10 +42,20 @@ odpowiedzi w wątku).
Otwórz dowolne zgłoszenie, by zobaczyć:
- aktualny **status** i **priorytet**,
- aktualny **status** i **priorytet**, oraz **przypisanego operatora** i
**zespół**, który obsługuje sprawę,
- pełną **historię wiadomości** (Twoje i operatora — notatki wewnętrzne operatora
nie są widoczne dla klienta); załączone obrazy pokazują się jako miniatury,
- **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy.
- **historię zmian** — log statusu/priorytetu/zespołu/przypisania z datą,
- **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy,
- jeśli administrator włączył integrację z bazą wiedzy — panel z artykułami
dopasowanymi do kategorii/podkategorii sprawy (te same podpowiedzi, co przy
tworzeniu zgłoszenia).
Wszystko na tej stronie aktualizuje się **na żywo** — jeśli operator odpowie
albo zmieni status/przypisanie, zobaczysz to bez odświeżania strony. Mały
licznik przy przycisku „Wróć do listy” to niezależny, okresowy fallback (co
ok. 30 s), na wypadek gdyby połączenie w tle się zerwało.
## Odpowiadanie

View File

@@ -6,9 +6,10 @@ odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu.
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
zdarzenia na Twoich zgłoszeniach na bieżąco, bez odświeżania strony.
Twoje **nieprzeczytane** powiadomienia — kliknięcie (albo „Oznacz wszystkie jako
przeczytane”) usuwa je z listy.
## Kolejka zgłoszeń
## Kolejka zgłoszeń — aktualizacje na żywo
Panel główny (`/operator`) pokazuje listę zgłoszeń z zakładkami po lewej stronie:
@@ -37,8 +38,20 @@ swoje.
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
zamknięta) albo **usunąć**.
Kolejka aktualizuje się **na żywo** — nowe zgłoszenie, zmiana statusu/priorytetu/
przypisania czy nowa odpowiedź pojawiają się bez odświeżania strony. Obok
przycisku „Kolumny” widać mały licznik odliczający do zera — to niezależny od
połączenia na żywo, okresowy fallback (co ok. 60 s), na wypadek gdyby
połączenie sieciowe w tle się zerwało.
## Praca ze zgłoszeniem
Widok zgłoszenia też aktualizuje się na żywo — nowa wiadomość klienta pojawia
się od razu (bez odświeżania), podobnie jak zmiana statusu/priorytetu/zespołu
zrobiona przez innego operatora albo przez regułę automatyzacji SLA. Licznik
przy przycisku „Wróć do listy” to taki sam fallbackowy zegar jak w kolejce
(co ok. 30 s).
W widoku pojedynczego zgłoszenia:
- **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
@@ -65,7 +78,8 @@ W widoku pojedynczego zgłoszenia:
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
zmiana kategorii może wysłać powiadomienie do klienta.
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
datą.
datą; wpis zaczynający się od „Automatyzacja: …” oznacza, że zmianę wykonała
reguła automatyzacji SLA (Admin > Automatyzacja SLA), nie operator ręcznie.
## Statystyki (`/operator/stats`)
@@ -94,15 +108,33 @@ zmianie filtra, bez przeładowania strony.
Przycisk **„Eksportuj CSV"** pobiera listę zgłoszeń (jeden wiersz na zgłoszenie) z
uwzględnieniem aktualnie wybranego zakresu dat i filtrów.
**Wykresy** (paski poziome, kolor = ta sama identyfikacja co w kolejce dla statusu/
priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
Reszta strony jest podzielona na sekcje:
**Rozkład zgłoszeń** (paski poziome, kolor = ta sama identyfikacja co w kolejce
dla statusu/priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
- Zgłoszenia wg statusu
- Zgłoszenia wg priorytetu
- Zgłoszenia wg kategorii
- Zgłoszenia wg podkategorii
**Obciążenie**:
- Obciążenie zespołów
- Obciążenie operatorów (ranking wg liczby przypisanych zgłoszeń)
**Klienci**:
- Najaktywniejsi klienci (Top 10 wg liczby zgłoszeń w wybranym okresie; osobny
wiersz „Goście (bez konta)” sumuje zgłoszenia bez zalogowanego klienta)
- **Klienci wg podkategorii** — tabela krzyżowa: top 10 klientów × top 5
najczęstszych podkategorii w wybranym okresie, reszta podkategorii zbiorczo w
kolumnie „Inne”.
**Ocena obsługi (CSAT)** — średnia ocena (X.XX / 5) wg zespołu i wg operatora;
zespół/operator bez żadnej oceny w wybranym okresie po prostu nie pojawia się
na liście.
**Trend** — dzienny wykres słupkowy „Nowe zgłoszenia” i „Zamknięte zgłoszenia”
obok siebie (maks. ostatnie 60 dni wybranego zakresu, żeby słupki pozostały
czytelne przy długich okresach).