Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b06687ea1 | |||
| def7c70887 |
106
ARCHITECTURE.md
106
ARCHITECTURE.md
@@ -65,11 +65,19 @@ rather than spreading it across Livewire components.
|
|||||||
|
|
||||||
## Roles & permissions
|
## Roles & permissions
|
||||||
|
|
||||||
Roles are a plain array on the user (`$user->roles`), not a separate pivot-backed
|
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
||||||
package — checked via `EnsureRole` at the route level. Every account gets
|
it's a **virtual attribute** (`User::getAttribute()`/`setAttribute()`
|
||||||
`client` by default (`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned
|
overrides) backed by a real `roles` lookup table + `role_user` pivot, not an
|
||||||
accounts); staff switch areas via the header role switcher, but always land on
|
actual column — assigning `'roles' => [...]` on create/update stashes the keys
|
||||||
`/client` first after login.
|
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
|
## 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/...`
|
the e-mail body and the in-app notification's `url`) points into `/client/...`
|
||||||
or `/operator/...`.
|
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 30–60 seconds via a small Alpine countdown calling
|
||||||
|
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
|
||||||
|
best-effort, not the only way these views ever update.
|
||||||
|
|
||||||
## SLA
|
## SLA
|
||||||
|
|
||||||
`SlaRule` holds per-priority response/resolution targets in minutes. The
|
`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
|
why this requires an external cron entry (the Docker image ships no
|
||||||
cron/supervisor of its own).
|
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
|
## API
|
||||||
|
|
||||||
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
||||||
|
|||||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -3,6 +3,91 @@
|
|||||||
All notable changes to this project are documented in this file. Format loosely
|
All notable changes to this project are documented in this file. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [1.1.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 30–60 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
|
## [1.1.0] - 2026-07-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
unzip \
|
unzip \
|
||||||
git \
|
git \
|
||||||
libldap2-dev \
|
libldap2-dev \
|
||||||
&& docker-php-ext-install curl mysqli pdo pdo_mysql ldap zip
|
&& docker-php-ext-install curl mysqli pdo pdo_mysql ldap zip pcntl posix
|
||||||
|
|
||||||
# Kopiowanie Composera z oficjalnego obrazu
|
# Kopiowanie Composera z oficjalnego obrazu
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -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
|
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
||||||
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
||||||
the assigned operator.
|
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
|
- **Categories & custom fields** — admin-defined categories/subcategories, each with
|
||||||
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
|
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
|
||||||
optional default priority.
|
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
|
team changed, closed, operator replied, SLA breached), each independently
|
||||||
enable/disable-able.
|
enable/disable-able.
|
||||||
- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range,
|
- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range,
|
||||||
team, priority, category, assignee) with KPI tiles (volume, SLA breach rate,
|
team, priority, category, assignee) organized into sections: KPI tiles (volume,
|
||||||
average first-response/resolution time) and breakdowns by status, priority,
|
SLA breach rate, average first-response/resolution time, CSAT); breakdowns by
|
||||||
category, team and operator workload, plus a daily created-vs-closed trend.
|
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,
|
- **Branding & config** — company name/logo/favicon/accent color, login notice,
|
||||||
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
|
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
|
||||||
limits, session lifetime, timezone — all editable from Admin > Konfiguracja.
|
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.
|
- **PWA** — installable manifest + icons for the client-facing area.
|
||||||
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
|
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
|
||||||
backed by Laravel's database notification channel, alongside the existing
|
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
|
- **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.
|
inline image thumbnails in the message thread instead of a plain download link.
|
||||||
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
|
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 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)* —
|
- **BookStack knowledge-base integration** *(optional, off by default)* —
|
||||||
suggests relevant BookStack articles by category/subcategory while a ticket
|
suggests relevant BookStack articles by category/subcategory while a ticket
|
||||||
is being created, and in a separate sidebar panel on an existing ticket for
|
is being created, and in a separate sidebar panel on an existing ticket for
|
||||||
operators (with a copy-link button). Configured entirely from Admin >
|
both operators and clients (with a copy-link button for operators). Loads in
|
||||||
Konfiguracja: connection + API token, optional SSL-verification bypass for
|
after the page's first paint rather than blocking it. Configured entirely
|
||||||
self-signed instances, page/book search-type filter, and two independent
|
from Admin > Konfiguracja: connection + API token, optional SSL-verification
|
||||||
per-shelf allow-lists (nothing is searched until an admin opts specific
|
bypass for self-signed instances, page/book search-type filter, and two
|
||||||
shelves in, separately for ticket-creation suggestions vs. the operator
|
independent per-shelf allow-lists (nothing is searched until an admin opts
|
||||||
sidebar).
|
specific shelves in, separately for ticket-creation suggestions vs. the
|
||||||
|
operator/client ticket-view sidebar).
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
|
- **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
|
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
|
- **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
|
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
|
||||||
build step beyond the CSS bundle.
|
build step beyond the CSS bundle.
|
||||||
- **Database**: MariaDB.
|
- **Database**: MariaDB.
|
||||||
- **Deployment**: `compose.yaml` — `servicedesk` (source bind-mounted from `./src`,
|
- **Deployment**: `compose.yaml` — `servicedesk` (source bind-mounted from `./src`,
|
||||||
no image rebuild needed for PHP/Blade/route changes) + `mariadb`, fronted by
|
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
|
||||||
Traefik with a private-CA TLS cert. The `servicedesk` image itself is built and
|
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA
|
||||||
pushed by Gitea Actions (`.gitea/workflows/build.yml`) to the Gitea container
|
TLS cert (the websocket path is routed to `reverb` by a higher-priority
|
||||||
registry whenever `Dockerfile` changes — `compose.yaml` just pulls a tag, it
|
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image
|
||||||
never builds locally.
|
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 —
|
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,
|
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
|
src/ Laravel application
|
||||||
app/Livewire/ Client/Operator/Admin Livewire components
|
app/Livewire/ Client/Operator/Admin Livewire components
|
||||||
app/Models/ Eloquent models
|
app/Models/ Eloquent models
|
||||||
|
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
|
||||||
|
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
|
||||||
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
|
||||||
app/Ldap/ LDAP user model + sync handlers
|
app/Ldap/ LDAP user model + sync handlers
|
||||||
database/migrations/ Schema (one file per table group, final shape)
|
database/migrations/ Schema (one file per table group, final shape)
|
||||||
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||||
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
|
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
|
resources/views/ Blade templates
|
||||||
routes/web.php Client/Operator/Admin routes (role-gated)
|
routes/web.php Client/Operator/Admin routes (role-gated)
|
||||||
routes/api.php REST API (Sanctum, ability-gated)
|
routes/api.php REST API (Sanctum, ability-gated)
|
||||||
|
routes/channels.php Broadcasting channel authorization (operator.queue, ticket.{id})
|
||||||
wiki/
|
wiki/
|
||||||
client/ How-to guide for the Client role
|
client/ How-to guide for the Client role
|
||||||
operator/ How-to guide for the Operator role
|
operator/ How-to guide for the Operator role
|
||||||
|
|||||||
98
install.md
98
install.md
@@ -74,7 +74,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
||||||
VERSION=1.1.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane
|
VERSION=1.1.2 # widoczne w Admin > O aplikacji
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
|
||||||
@@ -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
|
push/PR zmieniający `Dockerfile`, albo ręczne odpalenie z zakładki Actions w
|
||||||
Gitea), zanim spróbujesz `docker compose pull` na serwerze.
|
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
|
### 1.4. Instalacja aplikacji wewnątrz kontenera
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -285,7 +349,7 @@ APP_LOCALE=pl
|
|||||||
APP_FALLBACK_LOCALE=pl
|
APP_FALLBACK_LOCALE=pl
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
||||||
VERSION=1.1.0
|
VERSION=1.1.2
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
|
||||||
@@ -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`
|
skoro powiadomienia wysyłają się synchronicznie; zostaw `QUEUE_CONNECTION=database`
|
||||||
jako bezpieczny domyślny driver na przyszłość.
|
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
|
### 2.7. Pierwsze logowanie i dalsza konfiguracja
|
||||||
|
|
||||||
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
|
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ APP_DEBUG=true
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||||
VERSION=1.1.0
|
VERSION=1.1.2
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -40,6 +40,24 @@ BROADCAST_CONNECTION=log
|
|||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
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_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
|||||||
80
src/app/Console/Commands/RunAutomationRules.php
Normal file
80
src/app/Console/Commands/RunAutomationRules.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/app/Events/TicketMessagePosted.php
Normal file
52
src/app/Events/TicketMessagePosted.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/Events/TicketQueueChanged.php
Normal file
56
src/app/Events/TicketQueueChanged.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Admin;
|
namespace App\Livewire\Admin;
|
||||||
|
|
||||||
|
use App\Models\AutomationRule;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\CustomField;
|
use App\Models\CustomField;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
@@ -100,6 +101,15 @@ class Panel extends Component
|
|||||||
|
|
||||||
public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => ''];
|
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 ----
|
// ---- templates ----
|
||||||
public ?int $editingTemplateId = null;
|
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.');
|
$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 =====================
|
// ===================== STATUSES / PRIORITIES =====================
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
@@ -1491,10 +1591,11 @@ class Panel extends Component
|
|||||||
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
|
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
|
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
|
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
|
'automation-rule' => AutomationRule::query()->find($this->pendingDeleteId)?->delete(),
|
||||||
default => null,
|
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();
|
$this->cancelPendingDelete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ class NewTicket extends Component
|
|||||||
|
|
||||||
public array $attachments = [];
|
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]
|
#[Computed]
|
||||||
public function categories()
|
public function categories()
|
||||||
{
|
{
|
||||||
@@ -67,6 +77,10 @@ class NewTicket extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
|
if (! $this->suggestedArticlesLoaded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
return app(BookStackClient::class)->search($query);
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ namespace App\Livewire\Client;
|
|||||||
|
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketMessage;
|
use App\Models\TicketMessage;
|
||||||
|
use App\Services\BookStackClient;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
@@ -31,6 +33,16 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public string $csatComment = '';
|
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
|
public function mount(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
abort_unless($ticket->customer_id === Auth::id(), 403);
|
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();
|
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]
|
#[Computed]
|
||||||
public function otherTickets()
|
public function otherTickets()
|
||||||
{
|
{
|
||||||
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
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
|
public function updatedAttachments(): void
|
||||||
{
|
{
|
||||||
if (! $this->attachments) {
|
if (! $this->attachments) {
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ class Landing extends Component
|
|||||||
|
|
||||||
public ?int $submittedTicketId = null;
|
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]
|
#[Computed]
|
||||||
public function categories()
|
public function categories()
|
||||||
{
|
{
|
||||||
@@ -85,7 +95,7 @@ class Landing extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
if (! Settings::bool('bookstack_show_to_guests')) {
|
if (! $this->suggestedArticlesLoaded || ! Settings::bool('bookstack_show_to_guests')) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ use Livewire\Component;
|
|||||||
|
|
||||||
class NotificationBell extends 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]
|
#[Computed]
|
||||||
public function notifications()
|
public function notifications()
|
||||||
{
|
{
|
||||||
return Auth::user()->notifications()->latest()->limit(20)->get();
|
return Auth::user()->unreadNotifications()->latest()->limit(20)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ class NewTicket extends Component
|
|||||||
|
|
||||||
public array $attachments = [];
|
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]
|
#[Computed]
|
||||||
public function clients()
|
public function clients()
|
||||||
{
|
{
|
||||||
@@ -76,6 +86,10 @@ class NewTicket extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
|
if (! $this->suggestedArticlesLoaded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||||
|
|
||||||
return app(BookStackClient::class)->search($query);
|
return app(BookStackClient::class)->search($query);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Operator;
|
namespace App\Livewire\Operator;
|
||||||
|
|
||||||
|
use App\Events\TicketQueueChanged;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Priority;
|
use App\Models\Priority;
|
||||||
use App\Models\Status;
|
use App\Models\Status;
|
||||||
@@ -11,6 +12,7 @@ use App\Models\User;
|
|||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
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]
|
#[Computed]
|
||||||
public function savedViews()
|
public function savedViews()
|
||||||
{
|
{
|
||||||
@@ -417,7 +446,13 @@ class Queue extends Component
|
|||||||
|
|
||||||
public function confirmDeleteSelected(): void
|
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->selectedIds = [];
|
||||||
$this->pendingDeleteSelected = false;
|
$this->pendingDeleteSelected = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,25 @@ class Stats extends Component
|
|||||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
->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]
|
#[Computed]
|
||||||
public function byTeam()
|
public function byTeam()
|
||||||
{
|
{
|
||||||
@@ -351,6 +370,170 @@ class Stats extends Component
|
|||||||
return $rows;
|
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
|
* 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
|
* wide range (or "Cały okres") never renders an unreadably thin column
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Operator;
|
namespace App\Livewire\Operator;
|
||||||
|
|
||||||
|
use App\Events\TicketQueueChanged;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Priority;
|
use App\Models\Priority;
|
||||||
use App\Models\ReplyQuickAction;
|
use App\Models\ReplyQuickAction;
|
||||||
@@ -17,6 +18,7 @@ use App\Services\TicketService;
|
|||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
@@ -66,6 +68,16 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public string $editTimerSeconds = '0';
|
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
|
public function mount(Ticket $ticket): void
|
||||||
{
|
{
|
||||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
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();
|
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]
|
#[Computed]
|
||||||
public function statuses()
|
public function statuses()
|
||||||
{
|
{
|
||||||
@@ -192,6 +248,10 @@ class TicketShow extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function suggestedArticles(): array
|
public function suggestedArticles(): array
|
||||||
{
|
{
|
||||||
|
if (! $this->suggestedArticlesLoaded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$subcategory = $this->ticket->subcategory;
|
$subcategory = $this->ticket->subcategory;
|
||||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||||
|
|
||||||
@@ -500,7 +560,9 @@ class TicketShow extends Component
|
|||||||
|
|
||||||
public function confirmDeleteTicket(): void
|
public function confirmDeleteTicket(): void
|
||||||
{
|
{
|
||||||
|
$ticketId = $this->ticket->id;
|
||||||
$this->ticket->delete();
|
$this->ticket->delete();
|
||||||
|
TicketQueueChanged::dispatch($ticketId, 'deleted', Auth::id());
|
||||||
$this->redirect(route('operator.queue'), navigate: true);
|
$this->redirect(route('operator.queue'), navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
src/app/Models/AutomationRule.php
Normal file
35
src/app/Models/AutomationRule.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/Models/AutomationRuleTicketLog.php
Normal file
28
src/app/Models/AutomationRuleTicketLog.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ use Illuminate\Support\Facades\DB;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
||||||
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
|
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
|
||||||
'csat_rating', 'csat_comment', 'csat_rated_at',
|
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
|
||||||
])]
|
])]
|
||||||
class Ticket extends Model
|
class Ticket extends Model
|
||||||
{
|
{
|
||||||
@@ -23,6 +23,7 @@ class Ticket extends Model
|
|||||||
return [
|
return [
|
||||||
'custom_fields' => 'array',
|
'custom_fields' => 'array',
|
||||||
'sla_notified_at' => 'datetime',
|
'sla_notified_at' => 'datetime',
|
||||||
|
'last_customer_activity_at' => 'datetime',
|
||||||
'time_spent_seconds' => 'integer',
|
'time_spent_seconds' => 'integer',
|
||||||
'timer_started_at' => 'datetime',
|
'timer_started_at' => 'datetime',
|
||||||
'csat_rating' => 'integer',
|
'csat_rating' => 'integer',
|
||||||
@@ -90,6 +91,11 @@ class Ticket extends Model
|
|||||||
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
|
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function automationRuleLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(AutomationRuleTicketLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
public static function nextNumber(): string
|
public static function nextNumber(): string
|
||||||
{
|
{
|
||||||
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Events\TicketMessagePosted;
|
||||||
|
use App\Events\TicketQueueChanged;
|
||||||
use App\Models\ApiClient;
|
use App\Models\ApiClient;
|
||||||
use App\Models\NotificationSetting;
|
use App\Models\NotificationSetting;
|
||||||
use App\Models\Priority;
|
use App\Models\Priority;
|
||||||
@@ -14,6 +16,7 @@ use App\Models\User;
|
|||||||
use App\Notifications\TicketNotification;
|
use App\Notifications\TicketNotification;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ class TicketService
|
|||||||
'team_id' => $this->autoAssignTeam($subcategory),
|
'team_id' => $this->autoAssignTeam($subcategory),
|
||||||
'assignee_id' => $data['assignee_id'] ?? null,
|
'assignee_id' => $data['assignee_id'] ?? null,
|
||||||
'custom_fields' => $data['custom_values'] ?? [],
|
'custom_fields' => $data['custom_values'] ?? [],
|
||||||
|
'last_customer_activity_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$message = $ticket->messages()->create([
|
$message = $ticket->messages()->create([
|
||||||
@@ -54,6 +58,8 @@ class TicketService
|
|||||||
$message->attachAuthor($customer?->id, 'client');
|
$message->attachAuthor($customer?->id, 'client');
|
||||||
|
|
||||||
$this->notify($ticket, 'ticket_created');
|
$this->notify($ticket, 'ticket_created');
|
||||||
|
$this->notifyOperatorsForNewTicket($ticket, $subcategory);
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
|
||||||
|
|
||||||
return $ticket;
|
return $ticket;
|
||||||
}
|
}
|
||||||
@@ -68,6 +74,38 @@ class TicketService
|
|||||||
->value('id');
|
->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
|
public function setStatus(Ticket $ticket, string $statusKey): void
|
||||||
{
|
{
|
||||||
// Any status change (closing, reopening, moving between open sub-statuses)
|
// 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,
|
// the running segment (if any) the moment a ticket is closed,
|
||||||
// regardless of which flow triggered the status change.
|
// regardless of which flow triggered the status change.
|
||||||
$ticket->stopTimer();
|
$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 {
|
} else {
|
||||||
$this->notify($ticket, 'status_changed');
|
$this->notify($ticket, 'status_changed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'status_changed', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
|
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
|
||||||
@@ -113,6 +157,7 @@ class TicketService
|
|||||||
$ticket->update(['priority_key' => $priorityKey]);
|
$ticket->update(['priority_key' => $priorityKey]);
|
||||||
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
|
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
|
||||||
$this->notify($ticket, 'priority_changed');
|
$this->notify($ticket, 'priority_changed');
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setAssignee(Ticket $ticket, ?User $assignee): void
|
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->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
|
||||||
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
|
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
|
||||||
$this->notify($ticket, 'assignee_changed');
|
$this->notify($ticket, 'assignee_changed');
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTeam(Ticket $ticket, ?Team $team): void
|
public function setTeam(Ticket $ticket, ?Team $team): void
|
||||||
@@ -129,6 +175,7 @@ class TicketService
|
|||||||
$ticket->update(['team_id' => $team?->id]);
|
$ticket->update(['team_id' => $team?->id]);
|
||||||
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
|
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
|
||||||
$this->notify($ticket, 'team_changed');
|
$this->notify($ticket, 'team_changed');
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setReporter(Ticket $ticket, User $customer): void
|
public function setReporter(Ticket $ticket, User $customer): void
|
||||||
@@ -164,6 +211,8 @@ class TicketService
|
|||||||
$ticket->touch();
|
$ticket->touch();
|
||||||
$this->attachFiles($ticket, $message, $attachments);
|
$this->attachFiles($ticket, $message, $attachments);
|
||||||
$this->notify($ticket, 'operator_replied');
|
$this->notify($ticket, 'operator_replied');
|
||||||
|
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id);
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id);
|
||||||
|
|
||||||
if ($statusAfter) {
|
if ($statusAfter) {
|
||||||
$this->setStatus($ticket, $statusAfter);
|
$this->setStatus($ticket, $statusAfter);
|
||||||
@@ -179,6 +228,7 @@ class TicketService
|
|||||||
]);
|
]);
|
||||||
$message->attachAuthor($operator->id, 'operator');
|
$message->attachAuthor($operator->id, 'operator');
|
||||||
$this->attachFiles($ticket, $message, $attachments);
|
$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
|
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
|
||||||
@@ -190,6 +240,15 @@ class TicketService
|
|||||||
$message->attachAuthor($client->id, 'client');
|
$message->attachAuthor($client->id, 'client');
|
||||||
$ticket->touch();
|
$ticket->touch();
|
||||||
$this->attachFiles($ticket, $message, $attachments);
|
$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');
|
$this->notify($ticket, 'operator_replied');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
|
||||||
|
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
||||||
|
|
||||||
return $message;
|
return $message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,9 +348,11 @@ class TicketService
|
|||||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
||||||
]);
|
]);
|
||||||
$note->attachAuthor(null, 'operator');
|
$note->attachAuthor(null, 'operator');
|
||||||
|
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
$primary->touch();
|
$primary->touch();
|
||||||
|
TicketQueueChanged::dispatch($primary->id, 'message_posted', Auth::id());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
api: __DIR__.'/../routes/api.php',
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"darkaonline/l5-swagger": "*",
|
"darkaonline/l5-swagger": "*",
|
||||||
"directorytree/ldaprecord-laravel": "*",
|
"directorytree/ldaprecord-laravel": "*",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
|
"laravel/reverb": "*",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "*"
|
"livewire/livewire": "*"
|
||||||
|
|||||||
907
src/composer.lock
generated
907
src/composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "73d594569fe3d69fd8d63789e36b323e",
|
"content-hash": "321add40614eb8751e0c8dbda55016eb",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -134,6 +134,136 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"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",
|
"name": "darkaonline/l5-swagger",
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
@@ -730,6 +860,53 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-06T22:45:56+00:00"
|
"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",
|
"name": "fruitcake/php-cors",
|
||||||
"version": "v1.4.0",
|
"version": "v1.4.0",
|
||||||
@@ -1566,6 +1743,85 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-26T00:11:25+00:00"
|
"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",
|
"name": "laravel/sanctum",
|
||||||
"version": "v4.3.2",
|
"version": "v4.3.2",
|
||||||
@@ -3517,6 +3773,66 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-29T15:41:09+00:00"
|
"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",
|
"name": "radebatz/type-info-extras",
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
@@ -3777,6 +4093,595 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-06-18T03:57:49+00:00"
|
"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",
|
"name": "swagger-api/swagger-ui",
|
||||||
"version": "v5.32.10",
|
"version": "v5.32.10",
|
||||||
|
|||||||
82
src/config/broadcasting.php
Normal file
82
src/config/broadcasting.php
Normal 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
102
src/config/reverb.php
Normal 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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -55,7 +55,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
['key' => 'operator', 'label' => 'Operator'],
|
['key' => 'operator', 'label' => 'Operator'],
|
||||||
['key' => 'admin', 'label' => 'Administrator'],
|
['key' => 'admin', 'label' => 'Administrator'],
|
||||||
] as $role) {
|
] as $role) {
|
||||||
Role::query()->create($role);
|
Role::query()->firstOrCreate(['key' => $role['key']], $role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +259,11 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
foreach ([
|
foreach ([
|
||||||
['label' => 'Wyślij i „Oczekuje na klienta”', 'status_key' => 'waiting_customer', 'sort_order' => 1],
|
['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],
|
['label' => 'Wyślij i zamknij', 'status_key' => 'closed', 'sort_order' => 3],
|
||||||
] as $action) {
|
] as $action) {
|
||||||
ReplyQuickAction::query()->create($action);
|
ReplyQuickAction::query()->create($action);
|
||||||
@@ -335,13 +340,17 @@ class DatabaseSeeder extends Seeder
|
|||||||
'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
|
'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,
|
'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 = [];
|
$ids = [];
|
||||||
|
|
||||||
foreach ($templates as $key => $tpl) {
|
foreach ($templates as $key => $tpl) {
|
||||||
$ids[$key] = EmailTemplate::query()->create([
|
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
|
||||||
'key' => $key,
|
|
||||||
'name' => $tpl['name'],
|
'name' => $tpl['name'],
|
||||||
'trigger_label' => $tpl['trigger_label'],
|
'trigger_label' => $tpl['trigger_label'],
|
||||||
'subject' => $tpl['subject'],
|
'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' => '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' => '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' => '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) {
|
] as $setting) {
|
||||||
NotificationSetting::query()->create([
|
NotificationSetting::query()->firstOrCreate(['trigger_key' => $setting['trigger_key']], [
|
||||||
'trigger_key' => $setting['trigger_key'],
|
|
||||||
'trigger_label' => $setting['trigger_label'],
|
'trigger_label' => $setting['trigger_label'],
|
||||||
'enabled' => $setting['enabled'],
|
'enabled' => $setting['enabled'],
|
||||||
'recipient' => $setting['recipient'],
|
'recipient' => $setting['recipient'],
|
||||||
|
|||||||
40
src/package-lock.json
generated
40
src/package-lock.json
generated
@@ -4,6 +4,10 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"laravel-echo": "^2.1.0",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
@@ -909,6 +913,27 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
|
||||||
@@ -1275,6 +1300,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -1451,6 +1485,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.5",
|
"version": "8.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite"
|
"dev": "vite"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"laravel-echo": "^2.1.0",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
|||||||
@@ -343,6 +343,22 @@ body {
|
|||||||
.page-pad { padding: 16px !important; }
|
.page-pad { padding: 16px !important; }
|
||||||
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
|
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
|
||||||
.nav-panel-label { display: none; }
|
.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; }
|
.profile-menu-name { display: none; }
|
||||||
.main-col { min-width: 0; }
|
.main-col { min-width: 0; }
|
||||||
.aside-col { width: 100%; }
|
.aside-col { width: 100%; }
|
||||||
|
|||||||
@@ -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
84
src/resources/js/echo.js
Normal 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;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if ($user)
|
@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">
|
<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="material-symbols-outlined" style="font-size:18px">account_circle</span>
|
||||||
<span class="profile-menu-name">{{ $user->name }}</span>
|
<span class="profile-menu-name">{{ $user->name }}</span>
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
class="nav-dropdown"
|
||||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
|
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
|
||||||
>
|
>
|
||||||
@foreach ($areas as $area)
|
@foreach ($areas as $area)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@click.outside="open = false"
|
@click.outside="open = false"
|
||||||
|
class="nav-dropdown-wrap"
|
||||||
style="position:relative;display:inline-block"
|
style="position:relative;display:inline-block"
|
||||||
>
|
>
|
||||||
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
|
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
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"
|
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')">
|
<button type="button" class="theme-toggle-option" @click="apply('light')">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
<title>{{ $title ?? \App\Support\Settings::get('company_name') }}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ \App\Support\Settings::faviconUrl() }}">
|
<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; }
|
.ql-editor hr { border: none; border-top: 1px solid var(--color-divider); margin: 10px 0; }
|
||||||
</style>
|
</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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
|
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ $tabGroups = [
|
|||||||
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
|
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
|
||||||
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
|
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
|
||||||
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
|
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
|
||||||
|
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
|
||||||
],
|
],
|
||||||
'Zespół' => [
|
'Zespół' => [
|
||||||
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
|
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
|
||||||
@@ -322,6 +323,39 @@ $tabGroups = [
|
|||||||
@endif
|
@endif
|
||||||
@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 ≥ {{ $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 ================= --}}
|
{{-- ================= STATUSES ================= --}}
|
||||||
@if ($tab === 'statuses')
|
@if ($tab === 'statuses')
|
||||||
<h3 style="margin:0 0 6px">Statusy</h3>
|
<h3 style="margin:0 0 6px">Statusy</h3>
|
||||||
@@ -803,6 +837,96 @@ $tabGroups = [
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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)
|
@if ($this->editingTemplate)
|
||||||
<div class="dialog-backdrop">
|
<div class="dialog-backdrop">
|
||||||
<div class="dialog" style="max-width:560px">
|
<div class="dialog" style="max-width:560px">
|
||||||
|
|||||||
@@ -60,7 +60,9 @@
|
|||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
<div style="flex:1;display:flex;flex-direction:column">
|
<div style="flex:1;display:flex;flex-direction:column">
|
||||||
<x-topbar />
|
<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">
|
<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">
|
||||||
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
<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">← 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 style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
<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">
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
@foreach ($threadMessages as $m)
|
@foreach ($threadMessages as $m)
|
||||||
@php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp
|
@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="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="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 }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \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 style="font-size:14px;font-weight:500">{{ auth()->user()->name }}</div>
|
||||||
<div class="text-muted" style="font-size:13px">{{ auth()->user()->email }}</div>
|
<div class="text-muted" style="font-size:13px">{{ auth()->user()->email }}</div>
|
||||||
</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" style="padding:16px;gap:10px">
|
||||||
<div class="card-kicker">Status i priorytet</div>
|
<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->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
|
||||||
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
||||||
</div>
|
</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())
|
@if (! $ticket->isClosed())
|
||||||
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
|
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
|
||||||
@elseif ($ticket->isClosed())
|
@elseif ($ticket->isClosed())
|
||||||
@@ -137,7 +159,7 @@
|
|||||||
<div class="card" style="padding:16px;gap:8px">
|
<div class="card" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">Historia zmian</div>
|
<div class="card-kicker">Historia zmian</div>
|
||||||
@forelse ($ticket->histories as $h)
|
@forelse ($ticket->histories as $h)
|
||||||
<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
|
@empty
|
||||||
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
|
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
|
||||||
@endforelse
|
@endforelse
|
||||||
@@ -169,4 +191,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
|
|||||||
@@ -90,7 +90,9 @@
|
|||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
|
|||||||
@@ -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">
|
<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>
|
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
|
||||||
@if ($this->unreadCount)
|
@if ($this->unreadCount)
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<div
|
<div
|
||||||
x-show="open"
|
x-show="open"
|
||||||
x-cloak
|
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"
|
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)">
|
<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)
|
@forelse ($this->notifications as $notification)
|
||||||
<a
|
<a
|
||||||
|
wire:key="notification-{{ $notification->id }}"
|
||||||
href="{{ $notification->data['url'] ?? '#' }}"
|
href="{{ $notification->data['url'] ?? '#' }}"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
wire:click="markAsRead('{{ $notification->id }}')"
|
wire:click="markAsRead('{{ $notification->id }}')"
|
||||||
@click="open = false"
|
@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>{{ $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>
|
<div style="font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:2px">{{ $notification->created_at->diffForHumans() }}</div>
|
||||||
|
|||||||
@@ -66,7 +66,9 @@
|
|||||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Temat</label>
|
<label>Temat</label>
|
||||||
|
|||||||
@@ -116,6 +116,21 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -143,7 +158,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@foreach ($this->filteredTickets as $t)
|
@foreach ($this->filteredTickets as $t)
|
||||||
@php $sla = $t->slaInfo(); @endphp
|
@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>
|
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||||
@if (in_array('number', $visibleColumns))
|
@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>
|
<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>
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
|
|
||||||
{{-- KPI tiles --}}
|
{{-- KPI tiles --}}
|
||||||
@php($kpis = $this->kpis)
|
@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 style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:12px">
|
||||||
<div class="stat-tile">
|
<div class="stat-tile">
|
||||||
<div class="stat-tile-label">Łącznie zgłoszeń</div>
|
<div class="stat-tile-label">Łącznie zgłoszeń</div>
|
||||||
@@ -96,9 +98,12 @@
|
|||||||
<div class="stat-tile-meta">{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}</div>
|
<div class="stat-tile-meta">{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 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 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" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
|
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
|
||||||
@php($max = max($this->byStatus->max('count'), 1))
|
@php($max = max($this->byStatus->max('count'), 1))
|
||||||
@@ -113,7 +118,6 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By priority --}}
|
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
|
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
|
||||||
@php($max = max($this->byPriority->max('count'), 1))
|
@php($max = max($this->byPriority->max('count'), 1))
|
||||||
@@ -128,7 +132,6 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By category --}}
|
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
|
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
|
||||||
@php($cats = $this->byCategory)
|
@php($cats = $this->byCategory)
|
||||||
@@ -144,7 +147,27 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By team --}}
|
<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" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
|
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
|
||||||
@php($teamRows = $this->byTeam)
|
@php($teamRows = $this->byTeam)
|
||||||
@@ -160,7 +183,6 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- By assignee --}}
|
|
||||||
<div class="card" style="padding:16px">
|
<div class="card" style="padding:16px">
|
||||||
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
|
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
|
||||||
@php($opRows = $this->byAssignee)
|
@php($opRows = $this->byAssignee)
|
||||||
@@ -176,11 +198,109 @@
|
|||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
<div class="card" style="padding:16px">
|
||||||
|
<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>
|
||||||
|
@else
|
||||||
|
<div class="card-meta">Brak danych w wybranym okresie.</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{{-- Trend --}}
|
{{-- Trend --}}
|
||||||
@php($trend = $this->trend)
|
@php($trend = $this->trend)
|
||||||
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1))
|
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1))
|
||||||
@php($labelStep = max((int) ceil(count($trend) / 12), 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" style="padding:16px">
|
||||||
<div class="card-title">Trend zgłoszeń</div>
|
<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>
|
<div class="card-meta" style="margin-bottom:12px">Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).</div>
|
||||||
@@ -225,3 +345,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,23 @@
|
|||||||
|
|
||||||
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
|
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
|
||||||
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
||||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
<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">← 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 style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
<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" style="padding:16px;gap:10px">
|
||||||
<div class="card-kicker">Notatki wewnętrzne</div>
|
<div class="card-kicker">Notatki wewnętrzne</div>
|
||||||
@forelse ($this->internalMessages as $m)
|
@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="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 }} · {{ \App\Support\Rel::format($m->created_at) }}</div>
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}</div>
|
||||||
@if ($m->role === 'operator')
|
@if ($m->role === 'operator')
|
||||||
@@ -130,7 +146,7 @@
|
|||||||
<div style="display:flex;flex-direction:column;gap:10px">
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
@foreach ($threadMessages as $m)
|
@foreach ($threadMessages as $m)
|
||||||
@php $mine = $m->role === 'operator'; @endphp
|
@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="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="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 }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
||||||
@@ -286,7 +302,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div wire:init="loadSuggestedArticles">
|
||||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
|
<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" style="padding:16px;gap:8px">
|
||||||
<div class="card-kicker">SLA</div>
|
<div class="card-kicker">SLA</div>
|
||||||
@@ -435,4 +453,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
|
|||||||
36
src/routes/channels.php
Normal file
36
src/routes/channels.php
Normal 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;
|
||||||
|
});
|
||||||
@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
|
|||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
|
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
|
||||||
|
Schedule::command('automation:run-rules')->everyFifteenMinutes();
|
||||||
|
|||||||
172
src/tests/Feature/AutomationRulesTest.php
Normal file
172
src/tests/Feature/AutomationRulesTest.php
Normal 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);
|
||||||
|
});
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
use App\Livewire\Admin\Panel;
|
use App\Livewire\Admin\Panel;
|
||||||
use App\Models\EmailTemplate;
|
use App\Models\EmailTemplate;
|
||||||
use App\Models\NotificationSetting;
|
use App\Models\NotificationSetting;
|
||||||
|
use App\Models\User;
|
||||||
use App\Notifications\TicketNotification;
|
use App\Notifications\TicketNotification;
|
||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
|
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();
|
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
|
||||||
|
|
||||||
Livewire::actingAs($admin)->test(Panel::class)
|
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 () {
|
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
|
// Repoint ticket_created's NotificationSetting at a template we control,
|
||||||
// ticket_created's binding is still null at this point — give it one.
|
// 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']);
|
$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 = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail();
|
||||||
$ticketCreated->update(['email_template_id' => $template->id]);
|
$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 () {
|
test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
|
|
||||||
$template = EmailTemplate::query()->create([
|
$template = EmailTemplate::query()->create([
|
||||||
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',
|
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',
|
||||||
|
|||||||
@@ -9,19 +9,21 @@ use App\Notifications\TicketNotification;
|
|||||||
use App\Services\TicketService;
|
use App\Services\TicketService;
|
||||||
use Illuminate\Support\Facades\Notification;
|
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');
|
$settings = NotificationSetting::query()->get()->keyBy('trigger_key');
|
||||||
|
|
||||||
expect($settings->keys()->sort()->values()->all())->toBe([
|
expect($settings->keys()->sort()->values()->all())->toBe([
|
||||||
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
|
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
|
||||||
'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created',
|
'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();
|
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()
|
expect($settings[$key]->enabled)->toBeFalse()
|
||||||
->and($settings[$key]->email_template_id)->not->toBeNull();
|
->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 () {
|
test('a disabled-by-default trigger sends nothing until an admin turns it on', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
$ticket = makeTicket();
|
$ticket = makeTicket();
|
||||||
|
|
||||||
app(TicketService::class)->setPriority($ticket, 'high');
|
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 () {
|
test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
|
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
|
||||||
|
|
||||||
$ticket = makeTicket();
|
$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 () {
|
test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
|
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
|
||||||
|
|
||||||
$ticket = makeTicket();
|
$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 () {
|
test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
|
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
|
||||||
|
|
||||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
$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 () {
|
test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
|
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
|
||||||
|
|
||||||
// status_changed ships enabled by default, but in a bare migrated (unseeded)
|
// status_changed ships enabled by default — repoint it at a template we
|
||||||
// database it has no template assigned yet — give it one so it would have
|
// control so this test's "did it wrongly fire" assertion isn't tied to
|
||||||
// something to send if it (wrongly) fired, isolating this test from seeding order.
|
// whatever content the real seeded template happens to have.
|
||||||
$statusTemplate = EmailTemplate::query()->create([
|
$statusTemplate = EmailTemplate::query()->create([
|
||||||
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||||
]);
|
]);
|
||||||
@@ -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 () {
|
test('a non-closing status change still fires status_changed as usual', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
|
|
||||||
$statusTemplate = EmailTemplate::query()->create([
|
$statusTemplate = EmailTemplate::query()->create([
|
||||||
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
'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 () {
|
test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
|
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
|
||||||
$ticket = makeTicket();
|
$ticket = makeTicket();
|
||||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]);
|
$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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use App\Models\ReplyQuickAction;
|
|||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
test('the 3 default reply quick actions exist out of the box, matching the old hardcoded menu', function () {
|
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
|
// "Wyślij i oznacz jako rozwiązane" used to point at the now-removed
|
||||||
// "resolved" status (folded into "closed" — see the status restructure
|
// "resolved" status (folded into "closed" — see the status restructure
|
||||||
// migration), so it points at "closed" today, same as "Wyślij i zamknij".
|
// 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 () {
|
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');
|
$operator = operatorUser('quickaction-view@example.com');
|
||||||
$ticket = makeTicket(['number' => '1001']);
|
$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 () {
|
test('sending via a status-changing quick action updates the ticket status', function () {
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
$operator = operatorUser('quickaction-send@example.com');
|
$operator = operatorUser('quickaction-send@example.com');
|
||||||
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
|
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
|
||||||
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
|
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
|
||||||
|
|||||||
@@ -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 () {
|
test('an overdue ticket with an assigned operator gets notified once the trigger is enabled, and only once', function () {
|
||||||
Notification::fake();
|
Notification::fake();
|
||||||
seedStatusesAndPriorities();
|
$this->seed();
|
||||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
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']]);
|
$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');
|
Artisan::call('tickets:check-sla-breaches');
|
||||||
|
|
||||||
Notification::assertSentOnDemand(
|
// $operator is a real persisted User (not a guest), so TicketService::notify()
|
||||||
TicketNotification::class,
|
// notifies it directly rather than routing anonymously — assertSentTo, not
|
||||||
fn ($notification, $channels, $notifiable) => $notifiable->routes['mail'] === 'sla-op-2@example.com'
|
// assertSentOnDemand (which only matches AnonymousNotifiable routing).
|
||||||
);
|
Notification::assertSentTo($operator, TicketNotification::class);
|
||||||
expect($ticket->fresh()->sla_notified_at)->not->toBeNull();
|
expect($ticket->fresh()->sla_notified_at)->not->toBeNull();
|
||||||
|
|
||||||
Artisan::call('tickets:check-sla-breaches');
|
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 () {
|
test('an overdue but unassigned ticket is never notified (nobody to send it to)', function () {
|
||||||
|
|||||||
@@ -8,11 +8,25 @@ use Livewire\Livewire;
|
|||||||
test('every page renders without error against fully seeded data', function () {
|
test('every page renders without error against fully seeded data', function () {
|
||||||
$this->seed();
|
$this->seed();
|
||||||
|
|
||||||
$client = User::query()->withRole('client')->firstOrFail();
|
// DatabaseSeeder deliberately only seeds one admin fallback account and
|
||||||
$operator = User::query()->withRole('operator')->firstOrFail();
|
// 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();
|
$admin = User::query()->withRole('admin')->firstOrFail();
|
||||||
$clientTicket = Ticket::query()->where('customer_id', $client->id)->firstOrFail();
|
$clientTicket = Ticket::query()->create([
|
||||||
$anyTicket = Ticket::query()->firstOrFail();
|
'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('/')->assertOk()->assertSee('Jak możemy pomóc');
|
||||||
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');
|
$this->get('/login')->assertOk()->assertSee('Nowe zgłoszenie bez logowania');
|
||||||
|
|||||||
48
src/tests/Feature/StatsCsatBreakdownTest.php
Normal file
48
src/tests/Feature/StatsCsatBreakdownTest.php
Normal 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();
|
||||||
|
});
|
||||||
44
src/tests/Feature/StatsCustomerBreakdownTest.php
Normal file
44
src/tests/Feature/StatsCustomerBreakdownTest.php
Normal 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]);
|
||||||
|
});
|
||||||
81
src/tests/Feature/StatsCustomerSubcategoryMatrixTest.php
Normal file
81
src/tests/Feature/StatsCustomerSubcategoryMatrixTest.php
Normal 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);
|
||||||
|
});
|
||||||
30
src/tests/Feature/StatsSubcategoryBreakdownTest.php
Normal file
30
src/tests/Feature/StatsSubcategoryBreakdownTest.php
Normal 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);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests;
|
namespace Tests;
|
||||||
|
|
||||||
|
use App\Models\Role;
|
||||||
use App\Support\Settings;
|
use App\Support\Settings;
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
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;
|
// Settings caches statically for the lifetime of a (production) request;
|
||||||
// reset it between tests since Pest reuses one process for the whole run.
|
// reset it between tests since Pest reuses one process for the whole run.
|
||||||
Settings::flush();
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,28 @@ zespołu i przypisanymi mu bezpośrednio).
|
|||||||
„Brak”). Naruszenia sprawdza cykliczne zadanie co 15 minut
|
„Brak”). Naruszenia sprawdza cykliczne zadanie co 15 minut
|
||||||
(`tickets:check-sla-breaches`) i może powiadomić operatora.
|
(`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
|
## Szybkie akcje odpowiedzi
|
||||||
|
|
||||||
Przyciski w widoku zgłoszenia operatora, które **wysyłają odpowiedź i od razu
|
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.
|
„Resetuj” do wartości domyślnej); sam layout nie jest edytowalny z poziomu UI.
|
||||||
- **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/
|
- **Powiadomienia** — lista zdarzeń (zgłoszenie utworzone, zmiana statusu/
|
||||||
kategorii/priorytetu/zespołu/przypisania, zgłoszenie zamknięte, operator
|
kategorii/priorytetu/zespołu/przypisania, zgłoszenie zamknięte, operator
|
||||||
odpowiedział, SLA przekroczone) — każde ma przełącznik włącz/wyłącz, odbiorcę
|
odpowiedział, SLA przekroczone, **nowe zgłoszenie w zespole**) — każde ma
|
||||||
(klient / operator) i przypisany szablon. Usunięcie przypisanego szablonu po
|
przełącznik włącz/wyłącz, odbiorcę (klient / operator) i przypisany szablon.
|
||||||
prostu wyłącza wysyłkę tego powiadomienia, dopóki ktoś nie wybierze nowego.
|
Usunięcie przypisanego szablonu po prostu wyłącza wysyłkę tego powiadomienia,
|
||||||
„Zmiana statusu” i „zgłoszenie zamknięte” się wzajemnie wykluczają dla tej
|
dopóki ktoś nie wybierze nowego. „Zmiana statusu” i „zgłoszenie zamknięte” się
|
||||||
samej zmiany — zamknięcie zgłoszenia wysyła wyłącznie powiadomienie
|
wzajemnie wykluczają dla tej samej zmiany — zamknięcie zgłoszenia wysyła
|
||||||
„zgłoszenie zamknięte”, żeby nie dublować maila. **Ten sam przełącznik
|
wyłącznie powiadomienie „zgłoszenie zamknięte”, żeby nie dublować maila.
|
||||||
kontroluje zarówno e-mail, jak i powiadomienie w dzwoneczku w aplikacji** —
|
„Nowe zgłoszenie w zespole” (domyślnie włączone) trafia do **każdego**
|
||||||
nie ma osobnego ustawienia dla powiadomień w apce.
|
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
|
## Wygląd / Branding
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
Panel klienta (`/client`) służy do zgłaszania problemów/próśb i śledzenia ich
|
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
|
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
|
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
|
## Zgłaszanie nowej sprawy
|
||||||
|
|
||||||
@@ -41,10 +42,20 @@ odpowiedzi w wątku).
|
|||||||
|
|
||||||
Otwórz dowolne zgłoszenie, by zobaczyć:
|
Otwórz dowolne zgłoszenie, by zobaczyć:
|
||||||
|
|
||||||
- aktualny **status** i **priorytet**,
|
- aktualny **status** i **priorytet**, oraz **przypisanego operatora** i
|
||||||
|
**zespół**, który obsługuje sprawę,
|
||||||
- pełną **historię wiadomości** (Twoje i operatora — notatki wewnętrzne operatora
|
- pełną **historię wiadomości** (Twoje i operatora — notatki wewnętrzne operatora
|
||||||
nie są widoczne dla klienta); załączone obrazy pokazują się jako miniatury,
|
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
|
## Odpowiadanie
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
|
||||||
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
|
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
|
||||||
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
|
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:
|
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
|
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
|
||||||
zamknięta) albo **usunąć**.
|
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
|
## 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:
|
W widoku pojedynczego zgłoszenia:
|
||||||
|
|
||||||
- **Zmiana statusu / priorytetu / zespołu / przypisanego operatora** — z listy
|
- **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;
|
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
|
||||||
zmiana kategorii może wysłać powiadomienie do klienta.
|
zmiana kategorii może wysłać powiadomienie do klienta.
|
||||||
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
|
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
|
||||||
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`)
|
## 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
|
Przycisk **„Eksportuj CSV"** pobiera listę zgłoszeń (jeden wiersz na zgłoszenie) z
|
||||||
uwzględnieniem aktualnie wybranego zakresu dat i filtrów.
|
uwzględnieniem aktualnie wybranego zakresu dat i filtrów.
|
||||||
|
|
||||||
**Wykresy** (paski poziome, kolor = ta sama identyfikacja co w kolejce dla statusu/
|
Reszta strony jest podzielona na sekcje:
|
||||||
priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
|
|
||||||
|
**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 statusu
|
||||||
- Zgłoszenia wg priorytetu
|
- Zgłoszenia wg priorytetu
|
||||||
- Zgłoszenia wg kategorii
|
- Zgłoszenia wg kategorii
|
||||||
|
- Zgłoszenia wg podkategorii
|
||||||
|
|
||||||
|
**Obciążenie**:
|
||||||
|
|
||||||
- Obciążenie zespołów
|
- Obciążenie zespołów
|
||||||
- Obciążenie operatorów (ranking wg liczby przypisanych zgłoszeń)
|
- 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”
|
**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
|
obok siebie (maks. ostatnie 60 dni wybranego zakresu, żeby słupki pozostały
|
||||||
czytelne przy długich okresach).
|
czytelne przy długich okresach).
|
||||||
|
|||||||
Reference in New Issue
Block a user