4 Commits

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 20:43:05 +02:00
def7c70887 Add pcntl/posix PHP extensions for Reverb signal handling
All checks were successful
Build and push image / build (push) Successful in 1m25s
The Reverb websocket server (artisan reverb:start) needs pcntl for
SIGINT/SIGTERM/SIGTSTP signal handling on shutdown; the base php:apache
image doesn't enable it by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 16:33:08 +02:00
90fae0a4de v1.1.0
- In-app notifications: a bell in the top bar backed by Laravel's database
  notification channel, alongside existing e-mail notifications (same
  per-trigger toggle drives both; ticket links now correctly point into the
  recipient's own area instead of always linking to the client view).
- Drag-and-drop attachments on every upload form, plus inline image
  thumbnails in the message thread instead of a plain download link.
- Customer satisfaction (CSAT) rating: clients rate a closed ticket 1-5 stars
  with an optional comment; shown read-only to operators, surfaced as a KPI
  on the stats dashboard, and linked from the "ticket closed" e-mail.
- Saved queue views: operators can save/apply/delete named filter+sort+
  column presets in the ticket queue and mark one as their default.
- Full-text search (MySQL FULLTEXT, portable LIKE fallback) across ticket
  subject/body and reply message bodies, now also on the client's own ticket
  list.
- Stats CSV export for the currently filtered ticket set.
- Optional BookStack knowledge-base integration (off by default): suggests
  articles by category/subcategory while creating a ticket and in a separate
  sidebar for operators on an existing ticket (with a copy-link button).
  Configurable connection/SSL bypass/search-type filter, plus two
  independent per-shelf allow-lists so nothing is ever searched until an
  admin opts specific shelves in.
- Closed tickets no longer show in "Moje zgłoszenia"/"Nieprzypisane"/team
  queue tabs, only under "Zamknięte" (matching how "Otwarte" already worked).
- Wired up the Admin > About "Wersja" field to config('app.version')/VERSION
  in .env instead of a stale hardcoded string.
- Fixed: TicketService::setStatus() now checks a status's stage rather than
  the literal key 'closed' to decide whether to fire the "ticket closed"
  notification/stop the timer.
- Updated README/ARCHITECTURE/CHANGELOG/install/SECURITY docs and all three
  wiki/ role guides for the above; documented a root-vs-www-data file
  ownership gotcha in CLAUDE.md (running artisan commands via a plain
  `docker exec` can leave root-owned Blade cache files that later break
  recompilation for the www-data Apache process).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:18:09 +02:00
4e8f17189a v1.0.2
All checks were successful
Build and push image / build (push) Successful in 1m24s
- Fix CI registry login (unauthorized): use dedicated REGISTRY_TOKEN secret
  instead of GITHUB_TOKEN, fail fast with a clear error if it's unset.
- Pause ticket work-timer while a ticket is closed (won't auto-start on open
  or manual resume; stops on close via status change, quick action, API, or
  merge).
- Reject empty/blank login submissions client- and server-side instead of
  passing them straight to the auth provider.
- Closing a ticket now sends only the "ticket closed" notification instead
  of also sending a duplicate "status changed" one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:50:03 +02:00
89 changed files with 5403 additions and 228 deletions

View File

@@ -24,12 +24,19 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Verify registry credentials are configured
run: |
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "::error::Secret REGISTRY_TOKEN is not set, so login to gitea.kzbikowski.pl would fail. Create a Gitea access token with 'write:package' (and 'read:package') scope — user Settings > Applications > Generate New Token — then add it as an Actions secret named REGISTRY_TOKEN under this repo's Settings > Actions > Secrets. Aborting before attempting login." >&2
exit 1
fi
- name: Log in to Gitea container registry - name: Log in to Gitea container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: gitea.kzbikowski.pl registry: gitea.kzbikowski.pl
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

View File

@@ -38,14 +38,23 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
├──< TicketHistory ├──< TicketHistory
├── customer/assignee → User ├── customer/assignee → User
├── status → Status (fixed stages: new/open/closed) ├── status → Status (fixed stages: new/open/closed)
── priority → Priority → SlaRule (response/resolution minutes) ── priority → Priority → SlaRule (response/resolution minutes)
└── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)
User ─< UserFieldValue >─ UserField User ─< UserFieldValue >─ UserField
User ─< SavedQueueView (operator's own saved queue filter/sort/column presets)
User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
ApiClient (Sanctum token owner, ability-scoped) ApiClient (Sanctum token owner, ability-scoped)
Setting (single-row-per-key config store, see below) Setting (single-row-per-key config store, see below)
ReplyQuickAction, ResponseTemplate, EmailTemplate, NotificationSetting ReplyQuickAction, ResponseTemplate, EmailTemplate, NotificationSetting
``` ```
`tickets.subject`/`tickets.body` and `ticket_messages.body` carry a MySQL/MariaDB
`FULLTEXT` index (added in a later migration, MySQL-only — absent on the sqlite
connection the test suite runs on) — `Ticket::scopeSearch()` uses
`whereFullText()` when the active connection is `mysql` and falls back to a
portable `LIKE` otherwise, so the same call site works in both places.
`Ticket` (`app/Models/Ticket.php`) is the largest model — it owns SLA math `Ticket` (`app/Models/Ticket.php`) is the largest model — it owns SLA math
(`slaInfo()`, `isOverdue()`, `resolutionDeadline()`), status/priority display (`slaInfo()`, `isOverdue()`, `resolutionDeadline()`), status/priority display
helpers (`statusLabel()`, `tagStyleFromColor()`), operator-visibility scoping helpers (`statusLabel()`, `tagStyleFromColor()`), operator-visibility scoping
@@ -56,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
@@ -91,6 +108,90 @@ source of the "seeded placeholder overrides real `.env` values" gotcha
documented in [install.md](install.md) — anything touching LDAP/mail/session/ documented in [install.md](install.md) — anything touching LDAP/mail/session/
timezone config should go through `Settings`, not raw `config()`/`.env` reads. timezone config should go through `Settings`, not raw `config()`/`.env` reads.
## Notifications
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
fan-out point for every ticket lifecycle event (see the `NotificationSetting`
rows seeded per trigger key) — it resolves the configured recipient
(`$ticket->assignee` or `$ticket->customer`) to a real `User` when one exists
and calls `$user->notify(new TicketNotification(...))`, which fires **both**
the `mail` and `database` channels (`App\Notifications\TicketNotification`) —
there's no separate on/off switch for in-app vs. e-mail, the same
`NotificationSetting.enabled` flag gates both. A guest customer with no
account still gets routed anonymously (`Notification::route('mail', $email)`,
mail-only — the database channel needs a real notifiable to attach the row
to). `TicketNotification` is constructed with an explicit `$recipientRole`
('client'|'operator') rather than inferring it from the notifiable's roles,
since one account can hold both — this decides whether the ticket link (both
the e-mail body and the in-app notification's `url`) points into `/client/...`
or `/operator/...`.
## Real-time broadcasting (Reverb)
Two private channels, authorized in `routes/channels.php`:
- **`operator.queue`** — one shared channel for every operator/admin (not
scoped per team/ticket), so the receiving `Operator\Queue` component just
re-queries through its own already-correct `Ticket::scopeVisibleToOperator()`
on any event instead of the channel-auth callback needing to duplicate that
ACL logic. Payloads stay minimal (ticket id + reason + actor id) for the
same reason.
- **`ticket.{id}`** — per-ticket channel for the message thread and detail
changes, authorized for an operator with `isVisibleToOperator()` **or** the
ticket's own customer (OR, not else-if — the one real account in this app
holds both roles at once). An internal note broadcasts on the same channel
a client can subscribe to, but the payload never carries the message body —
each side's Livewire component only ever re-queries whatever its own
already-authorized computed property returns, so there's nothing to leak.
Two events, both `App\Events\TicketQueueChanged` (broadcasts on **both**
channels above — a status/priority/team/assignee change needs to reach a
client watching their own ticket too) and `App\Events\TicketMessagePosted`
(broadcasts on `ticket.{id}` only). Both implement `ShouldBroadcastNow`, not
`ShouldBroadcast` — this app runs with no queue worker by design (see
`TicketNotification`), so broadcasting happens synchronously within the
request like everything else here. `TicketService` dispatches both from every
ticket-mutating method (create/setStatus/setPriority/setAssignee/setTeam/
operatorReply/operatorNote/clientReply/apiMessage/merge); the two ad hoc
delete call sites (`Operator\Queue::confirmDeleteSelected()`,
`Operator\TicketShow::confirmDeleteTicket()`) dispatch `TicketQueueChanged`
directly since there's no `TicketService::delete()` to hook into.
Browser side, `resources/js/echo.js` bridges Reverb events into plain
Livewire events (`Livewire.dispatch('queue-changed', ...)` /
`'ticket-message-posted'`) rather than using the `#[On('echo-private:...')]`
attribute directly — version-agnostic, and each Livewire component just
declares a plain `#[On(...)]` listener that no-ops if the payload's `actorId`
matches the viewer's own id (self-echo suppression) or the ticket id doesn't
match the component's own ticket. Two easy-to-reintroduce bugs to know about
if "nothing updates live" ever comes back:
1. **CSRF on `/broadcasting/auth`.** Echo's private-channel subscription
POSTs there under the app's normal CSRF middleware; the `Echo` constructor
must pass `auth.headers['X-CSRF-TOKEN']` (read from the `<meta
name="csrf-token">` tag in `layouts/app.blade.php`) or every subscription
attempt is silently rejected.
2. **Script load order.** `resources/js/app.js` (which imports `echo.js`)
loads via `@vite` as `type="module"`, which the HTML spec defers until
after the document is parsed — meaning Livewire's own bootstrap script
(`@livewireScripts`, a plain synchronous `<script>` near the end of
`<body>`) has already run by the time `echo.js` executes. Don't gate
anything in `echo.js` behind `document.addEventListener('livewire:init',
...)` — that event fires as part of Livewire's own (earlier) script, so a
listener registered this late permanently misses it. The one place this
still matters is the per-ticket subscription triggered from a Livewire
`@script` block in the ticket-show views, which can run before or after
`echo.js` depending on exactly when Livewire processes it — it queues the
ticket id onto `window.__pendingTicketChannelIds` if `echo.js` hasn't
defined `window.subscribeToTicketChannel` yet, and `echo.js` flushes that
queue once it has.
As a defense against a dropped websocket connection (backgrounded tab,
network blip), the operator queue and both ticket-detail views also poll
themselves every 3060 seconds via a small Alpine countdown calling
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
best-effort, not the only way these views ever update.
## SLA ## SLA
`SlaRule` holds per-priority response/resolution targets in minutes. The `SlaRule` holds per-priority response/resolution targets in minutes. The
@@ -100,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
@@ -110,3 +233,27 @@ tighter per-IP limit for unauthenticated requests
(`AppServiceProvider::configureApiRateLimiting()`). Interactive docs are (`AppServiceProvider::configureApiRateLimiting()`). Interactive docs are
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
reference in-repo. reference in-repo.
## BookStack integration
`App\Services\BookStackClient` is the only outbound HTTP client in the
codebase (Laravel's `Http` facade) — everything else here only ever receives
requests. It's entirely `Settings`-driven, no `.env`/`config()` involved:
`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/
`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords),
`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and
**two independent** allow-lists of BookStack shelf IDs —
`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and
`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an
existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` /
`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list
means "search nothing"**, not "search everything" — nothing is ever
suggested until an admin explicitly opts shelves in, independently per
context. BookStack has no "which shelf is this book on" field in its own
search response, so `BookStackClient` fetches `/api/shelves` +
`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both
to resolve the allow-list to book IDs and to build the "Shelf > Book"
breadcrumb shown next to each suggestion. Per-query search results are cached
10 minutes, keyed on the query text **and** the active allow-list, so toggling
which shelves are allowed is reflected immediately instead of serving a
pre-change result for up to 10 minutes.

View File

@@ -3,6 +3,164 @@
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 3060 seconds regardless
of broadcasting, with a small visible countdown badge so it's clear the
page is still refreshing on its own.
- **SLA automation rules** (Admin > Automatyzacja SLA) — configurable rules
that act on a ticket after N minutes of customer silence (optionally
scoped to a priority/category/team): change priority, status, team, or
assignee. Reuses the same `TicketService` setters a manual operator
action would, so automated changes get the same history entry,
notification, and (now) live broadcast as a human doing it. A new
scheduled command (`automation:run-rules`, every 15 minutes) evaluates
all enabled rules; each rule only fires once per ticket until a fresh
reply or a close/reopen resets it.
- **New notification: ticket landed in your team** — every operator on a
team whose subcategories match a newly created ticket now gets notified
(enabled by default; toggle like any other trigger in Admin > Szablony
e-mail).
- **BookStack knowledge-base sidebar on the client's own ticket view** —
previously only shown to operators; clients now see the same
category/subcategory-matched suggestions on their ticket page that they
saw while creating it.
- **Operator ticket view / client ticket view now show the assigned
operator and team** in the "Status i priorytet" card (client side).
- **Stats dashboard**: new breakdowns — tickets by subcategory, CSAT
average by team and by operator, top 10 clients by ticket volume (+ one
"Goście" bucket for guest submissions), and a client × subcategory
cross-tab (top 10 clients × top 5 subcategories, rest folded into
"Inne"). The whole dashboard is now organized into labeled sections
(Podsumowanie / Rozkład zgłoszeń / Obciążenie / Klienci / Ocena obsługi /
Trend) instead of one flat wall of cards.
### Changed
- BookStack suggestions (ticket-creation wizards, guest landing page,
operator/client ticket views) now load in a beat after the page's first
paint (`wire:init`) instead of blocking the initial render on BookStack's
API response.
- The notification bell shows **unread notifications only** — reading one
(by clicking it or "mark all as read") now removes it from the list
instead of just dimming it.
- The client ticket-view page is now the same width as the operator's
(1180px, up from 920px) — the main message-thread column is unaffected.
- Mobile: the theme/notifications/profile-menu dropdowns in the top nav
now expand to the full screen width below 640px instead of a fixed
narrow width that could overflow off-screen.
### Fixed
- Two bugs that silently disabled all real-time updates from the moment
Reverb was first wired in: (1) the Echo client never sent a CSRF token
when authorizing private channels, so every subscription attempt was
rejected by the app's own CSRF middleware; (2) the Echo setup script
(loaded as a deferred ES module) raced against Livewire's own
synchronously-loaded bootstrap script and could miss the `livewire:init`
event entirely, silently skipping the operator-queue subscription.
- The `mariadb` container's healthcheck was failing (`Access denied ...
using password: NO`) because its auto-generated credentials file had
world-writable permissions (an artifact of this stack's NFS-backed bind
mount) and MariaDB's client refuses to read credentials from a file
anyone can modify — the healthcheck now passes.
- Several pre-existing test-suite gaps found while working in this area:
`User::roles` (a virtual attribute backed by the `roles`/`role_user`
pivot, not a plain column) needs the `roles` table seeded before
assigning a role by key — now seeded automatically for every test in
`Tests\TestCase`. A handful of other tests were missing
`NotificationSetting`/`ReplyQuickAction` seed data, asserting a stale set
of default-enabled notification triggers, or using the wrong Notification
Fake assertion for a real (non-guest) recipient.
## [1.1.0] - 2026-07-22
### Added
- **In-app notifications** — a bell in the top bar backed by Laravel's
database notification channel, alongside existing e-mail notifications
(same per-trigger toggle drives both; ticket links now correctly point into
the recipient's own area instead of always linking to the client view).
- **Drag-and-drop attachments** on every upload form (ticket creation, replies,
internal notes), plus inline image thumbnails in the message thread instead
of a plain download link.
- **Customer satisfaction (CSAT)** rating — clients rate a closed ticket 15
stars with an optional comment; shown read-only to operators, surfaced as a
KPI on the stats dashboard, and linked from the "ticket closed" e-mail.
- **Saved queue views** — operators can save/apply/delete named filter+sort+
column presets in the ticket queue and mark one as their default.
- **Full-text search** — MySQL/MariaDB `FULLTEXT` search (portable `LIKE`
fallback on sqlite) across ticket subject/body and reply message bodies, now
also available on the client's own ticket list (previously operator-only,
and previously subject/number/name/email only).
- **Stats CSV export** — exports the currently filtered ticket set from the
operator stats dashboard.
- **BookStack knowledge-base integration**, optional and off by default —
suggests relevant articles by category/subcategory while creating a ticket,
and in a separate sidebar on an existing ticket for operators (with a
copy-link button). Configurable from Admin > Konfiguracja: connection + API
token (encrypted), optional SSL-verification bypass for self-signed
instances, page/book search-type filter, and two independent per-shelf
allow-lists (nothing is ever searched until specific shelves are opted in,
separately for ticket-creation suggestions vs. the operator sidebar) with a
manual refresh button for the shelf list.
### Changed
- Closed tickets are no longer shown in the "Moje zgłoszenia" / "Nieprzypisane"
/ per-team queue tabs — they now only ever appear under "Zamknięte", matching
how the "Otwarte" tab already worked.
- The operator ticket-view sidebar is ~50% wider (to fit the BookStack
suggestions panel); the page itself grew to match, so the ticket
content/thread column keeps its previous width.
- The "Resetuj" work-timer button sits below the "Zgłoszenie zamknięte —
zliczanie wstrzymane" notice instead of beside it.
### Fixed
- `TicketService::setStatus()` now checks a status's `stage` (via
`Status::stageFor()`) rather than the literal key `'closed'` to decide
whether to fire the "ticket closed" notification/stop the timer — correct
even if an admin renames or replaces which key maps to the closed stage.
## [1.0.2] - 2026-07-22
- Fixed `.gitea/workflows/build.yml`: registry login was failing with
`unauthorized` because Gitea's auto-injected `secrets.GITHUB_TOKEN` isn't
granted push access to its own container registry on this instance. Now
uses a dedicated `REGISTRY_TOKEN` secret (a Gitea access token with
`write:package`/`read:package` scope), and the workflow fails fast with a
clear `::error::` message before attempting login if that secret isn't
configured, instead of surfacing Docker's opaque `unauthorized` error.
- Fixed: opening or manually resuming a **closed** ticket no longer starts its
work timer, and closing a ticket (via the status dropdown, a reply "quick
action" transition, the REST API, or merge) now checkpoints and stops any
running timer. Time tracking only ever accrues while a ticket is open.
- Fixed: the login form accepted an empty username/password, submitting them
straight to the auth provider. The form fields are now `required` (blocks
submission client-side) and `Login::submit()` also rejects blank/
whitespace-only credentials server-side before attempting authentication,
showing "Podaj nazwę użytkownika i hasło." instead.
- Fixed: closing a ticket sent two separate notification e-mails
(`status_changed` and `ticket_closed`) for the same event. Closing now only
fires `ticket_closed`; every other status transition still fires
`status_changed` as before.
## [1.0.1] - 2026-07-22 ## [1.0.1] - 2026-07-22
Documentation and deployment/CI overhaul — no application behavior changes. Documentation and deployment/CI overhaul — no application behavior changes.

View File

@@ -47,6 +47,18 @@ with no rebuild or restart:
`view:cache`), clear them again afterward (`config:clear`/`view:clear`) — this `view:cache`), clear them again afterward (`config:clear`/`view:clear`) — this
app normally runs uncached so edits apply live; leaving a cache on silently app normally runs uncached so edits apply live; leaving a cache on silently
breaks that workflow. breaks that workflow.
- **`sudo docker exec` runs as `root`, not `www-data`.** Apache's worker
processes run as `www-data`; anything you run via a plain `docker exec`
(`php artisan test`, `tinker`, `view:cache`, etc.) runs as `root`. On this
NFS-backed mount, a file Blade compiles/caches while running as `root`
(`storage/framework/views/*.php`) can't later be overwritten by `www-data`
when a real request needs to recompile it (the source changed) — this
surfaces in production as a 500 with `touch(): Utime failed: Operation not
permitted`. If you ran `php artisan test`/`tinker`/any artisan command via
`docker exec` in a session where you also edited Blade files afterward,
finish with `sudo docker exec servicedesk-servicedesk-1 php artisan
view:clear` to flush any root-owned compiled views before ending the
session — don't wait for a report of a broken page to catch it.
## Apache `/icons/` alias trap ## Apache `/icons/` alias trap

View File

@@ -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

View File

@@ -29,6 +29,18 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **SLA** — per-priority response/resolution time targets; a scheduled command - **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.
@@ -55,23 +70,55 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
categories/statuses/priorities/teams — issued via admin-managed API clients. categories/statuses/priorities/teams — issued via admin-managed API clients.
Interactive docs (L5-Swagger) at `/admin/api-docs`. Interactive docs (L5-Swagger) at `/admin/api-docs`.
- **PWA** — installable manifest + icons for the client-facing area. - **PWA** — installable manifest + icons for the client-facing area.
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
backed by Laravel's database notification channel, alongside the existing
e-mail notifications (same per-trigger enable toggle drives both); shows
unread notifications only — reading one removes it from the list. Includes a
dedicated trigger notifying every operator on a team whose subcategories
match a newly created ticket.
- **Attachments** — drag-and-drop upload (in addition to the file picker) and
inline image thumbnails in the message thread instead of a plain download link.
- **Customer satisfaction (CSAT)** — clients rate a ticket 15 stars (+ optional
comment) once it's closed; average/response-rate surfaced as a KPI on the
operator stats dashboard, with a link in the "ticket closed" e-mail.
- **Saved queue views** — operators can save their current filter/sort/column
combination in the ticket queue, mark one as default, and switch between them.
- **Full-text search** — MySQL/MariaDB `FULLTEXT` search (with a portable `LIKE`
fallback) across ticket subject/body and reply message bodies, available in
both the operator queue and the client's own ticket list.
- **Stats export** — the operator stats dashboard can export the currently
filtered ticket set as CSV.
- **BookStack knowledge-base integration** *(optional, off by default)*
suggests relevant BookStack articles by category/subcategory while a ticket
is being created, and in a separate sidebar panel on an existing ticket for
both operators and clients (with a copy-link button for operators). Loads in
after the page's first paint rather than blocking it. Configured entirely
from Admin > Konfiguracja: connection + API token, optional SSL-verification
bypass for self-signed instances, page/book search-type filter, and two
independent per-shelf allow-lists (nothing is searched until an admin opts
specific shelves in, separately for ticket-creation suggestions vs. the
operator/client ticket-view sidebar).
## Tech stack ## 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,
@@ -104,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/Services/ TicketService (ticket lifecycle + notifications) app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules)
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient
app/Ldap/ LDAP user model + sync handlers 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

View File

@@ -45,6 +45,16 @@ credential-equivalent.
- **TLS**: production traffic terminates at Traefik with a private CA - **TLS**: production traffic terminates at Traefik with a private CA
certificate (not publicly trusted) — this is expected for this deployment, not certificate (not publicly trusted) — this is expected for this deployment, not
a misconfiguration. a misconfiguration.
- **BookStack integration** (`App\Services\BookStackClient`, optional, off by
default): the only outbound HTTP client in the codebase. The target
`bookstack_base_url` and the SSL-verification bypass (`bookstack_verify_ssl`)
are both admin-configurable — restrict Admin-role accounts accordingly, same
reasoning as the LDAP/SMTP settings override above (a compromised admin
account could point it at an arbitrary host, or disable TLS verification
against one). The API token secret is stored encrypted (same as the LDAP
bind/SMTP passwords). Nothing is ever searched/suggested until an admin
explicitly allow-lists specific BookStack shelves — the default (no shelves
allowed) returns no results without making any outbound request.
## Dependencies ## Dependencies

View File

@@ -74,7 +74,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
VERSION=1.0.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane VERSION=1.1.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
@@ -136,6 +136,21 @@ kontenerów Gitea (`gitea.kzbikowski.pl/kzbkowski/servicedesk`) po każdym pushu
przebudowa dla samego kodu byłaby marnowaniem czasu CI). Wypycha dwa tagi: przebudowa dla samego kodu byłaby marnowaniem czasu CI). Wypycha dwa tagi:
`latest` i `<sha commita>`. `latest` i `<sha commita>`.
Zanim to zadziała, workflow potrzebuje sekretu `REGISTRY_TOKEN` — Gitei **nie**
ufaj domyślnemu `secrets.GITHUB_TOKEN` do logowania w jej własnym rejestrze
kontenerów, w praktyce kończy się to błędem `unauthorized` przy
`docker login`. Zamiast tego:
1. Wygeneruj token: **Ustawienia użytkownika > Aplikacje > Generate New Token**,
z uprawnieniami co najmniej `write:package` i `read:package`.
2. Dodaj go jako sekret repo: **Ustawienia repo > Actions > Secrets**
nazwa `REGISTRY_TOKEN`, wartość = wygenerowany token.
Jeśli ten sekret nie jest ustawiony, workflow celowo przerywa się **przed**
próbą logowania z czytelnym komunikatem błędu (`::error::`), zamiast wysyłać
puste/nieautoryzowane dane do rejestru i kończyć na niejasnym `unauthorized` z
demona Dockera.
To **tylko build + push** — świadomie bez auto-deployu na produkcję. Po tym jak To **tylko build + push** — świadomie bez auto-deployu na produkcję. Po tym jak
CI skończy, wdrożenie nowego obrazu na serwerze wciąż jest ręcznym krokiem: CI skończy, wdrożenie nowego obrazu na serwerze wciąż jest ręcznym krokiem:
@@ -158,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
@@ -219,6 +298,16 @@ użytku:
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz 3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
polegać na logowaniu przez katalog. polegać na logowaniu przez katalog.
### Integracje opcjonalne (BookStack)
Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w
panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w
`.env` — całość konfiguruje się w **Admin > Konfiguracja**: adres instancji,
Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku
uprawnienie „Access System API”), oraz osobne listy dozwolonych półek dla
podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna
półka nie jest zaznaczona, wyszukiwanie nic nie zwraca.
--- ---
## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera) ## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera)
@@ -260,7 +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.0.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
@@ -380,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ń

View File

@@ -5,7 +5,7 @@ APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.0.1 VERSION=1.1.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=

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Admin; 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;
@@ -15,8 +16,10 @@ use App\Models\Subcategory;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use App\Models\UserField; use App\Models\UserField;
use App\Services\BookStackClient;
use App\Services\LdapUserProvisioner; use App\Services\LdapUserProvisioner;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
@@ -98,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;
@@ -137,6 +149,12 @@ class Panel extends Component
public ?string $mailTestResult = null; public ?string $mailTestResult = null;
public array $bookstackConfig = [];
public ?string $bookstackTestResult = null;
public ?string $bookstackTestMessage = null;
// ---- generic pending-delete confirm ---- // ---- generic pending-delete confirm ----
public ?string $pendingDeleteType = null; public ?string $pendingDeleteType = null;
@@ -187,6 +205,18 @@ class Panel extends Component
'fromAddress' => Settings::get('mail_from_address'), 'fromAddress' => Settings::get('mail_from_address'),
'fromName' => Settings::get('mail_from_name'), 'fromName' => Settings::get('mail_from_name'),
]; ];
$this->bookstackConfig = [
'enabled' => Settings::bool('bookstack_enabled'),
'baseUrl' => Settings::get('bookstack_base_url'),
'tokenId' => Settings::get('bookstack_token_id'),
'tokenSecret' => Settings::get('bookstack_token_secret'),
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
'searchTypes' => Settings::get('bookstack_search_types', 'both'),
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
];
} }
public function setTab(string $tab): void public function setTab(string $tab): void
@@ -834,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]
@@ -965,7 +1085,7 @@ class Panel extends Component
* key-primary-keyed, sort_order-ordered lists edited the same way: swap * key-primary-keyed, sort_order-ordered lists edited the same way: swap
* this row's sort_order with its immediate neighbor in the given direction. * this row's sort_order with its immediate neighbor in the given direction.
* *
* @param \Illuminate\Support\Collection<int, Status|Priority> $ordered * @param Collection<int, Status|Priority> $ordered
*/ */
protected function swapAdjacentSortOrder($ordered, string $key, int $direction): void protected function swapAdjacentSortOrder($ordered, string $key, int $direction): void
{ {
@@ -1288,6 +1408,90 @@ class Panel extends Component
} }
} }
// ===================== BOOKSTACK CONFIG =====================
public function saveBookstackConfig(): void
{
Settings::set('bookstack_enabled', $this->bookstackConfig['enabled'] ? '1' : '0');
Settings::set('bookstack_base_url', $this->bookstackConfig['baseUrl']);
Settings::set('bookstack_token_id', $this->bookstackConfig['tokenId']);
if ($this->bookstackConfig['tokenSecret']) {
Settings::set('bookstack_token_secret', $this->bookstackConfig['tokenSecret']);
}
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0');
if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) {
Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']);
}
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
Settings::set('bookstack_allowed_shelf_ids_ticket_view', implode(',', $this->bookstackConfig['allowedShelfIdsTicketView']));
$this->bookstackTestResult = null;
$this->bookstackTestMessage = null;
}
#[Computed]
public function bookstackShelves(): array
{
return app(BookStackClient::class)->shelves();
}
/**
* Drops the cached shelf list (and its dependent shelf>book map) so a
* shelf added/renamed/removed in BookStack shows up in both checklists
* right away, then re-fetches shared by both "Dozwolone półki"
* checklists since they list the exact same shelves.
*/
public function refreshBookstackShelves(): void
{
app(BookStackClient::class)->clearShelfCache();
unset($this->bookstackShelves);
}
/**
* @return int[]
*/
protected function parseShelfIds(string $raw): array
{
return collect(explode(',', $raw))->map(fn ($v) => (int) trim($v))->filter()->values()->all();
}
/**
* $field is 'allowedShelfIdsCreation' or 'allowedShelfIdsTicketView'
* the two independent allow-lists (ticket-creation suggestions vs. the
* operator ticket-view sidebar) toggled by their own checklist.
*/
public function toggleBookstackAllowedShelf(string $field, int $id): void
{
$ids = $this->bookstackConfig[$field];
$this->bookstackConfig[$field] = in_array($id, $ids, true)
? array_values(array_diff($ids, [$id]))
: [...$ids, $id];
}
public function testBookstackConnection(): void
{
$cfg = $this->bookstackConfig;
if (empty($cfg['baseUrl']) || empty($cfg['tokenId'])) {
$this->bookstackTestResult = 'error';
$this->bookstackTestMessage = 'Uzupełnij adres instancji i Token ID.';
return;
}
$tokenSecret = $cfg['tokenSecret'] ?: Settings::get('bookstack_token_secret');
$result = app(BookStackClient::class)->testConnection($cfg['baseUrl'], $cfg['tokenId'], $tokenSecret ?? '', (bool) $cfg['verifySsl']);
$this->bookstackTestResult = $result['ok'] ? 'ok' : 'error';
$this->bookstackTestMessage = $result['message'];
}
// ===================== MAIL / SMTP CONFIG ===================== // ===================== MAIL / SMTP CONFIG =====================
public function saveMailConfig(): void public function saveMailConfig(): void
@@ -1387,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();
} }

View File

@@ -27,6 +27,16 @@ class Login extends Component
{ {
$this->error = null; $this->error = null;
// The form also has HTML `required` attributes so the browser blocks
// an empty submit before it ever reaches here, but that's only a UX
// nicety — nothing stops a request hitting this method directly, so
// it needs to fail closed on its own too.
if (trim($this->username) === '' || $this->password === '') {
$this->error = 'Podaj nazwę użytkownika i hasło.';
return;
}
$attribute = Settings::ldapUsernameAttribute(); $attribute = Settings::ldapUsernameAttribute();
// Local accounts (created with a password from the admin panel) don't // Local accounts (created with a password from the admin panel) don't

View File

@@ -11,10 +11,13 @@ class Dashboard extends Component
{ {
public string $tab = 'current'; public string $tab = 'current';
public string $search = '';
#[Computed] #[Computed]
public function tickets() public function tickets()
{ {
return Auth::user()->ticketsAsCustomer() return Auth::user()->ticketsAsCustomer()
->search($this->search)
->with('subcategory.category') ->with('subcategory.category')
->orderByDesc('updated_at') ->orderByDesc('updated_at')
->get(); ->get();

View File

@@ -4,6 +4,7 @@ namespace App\Livewire\Client;
use App\Models\Category; use App\Models\Category;
use App\Models\Subcategory; use App\Models\Subcategory;
use App\Services\BookStackClient;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -29,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()
{ {
@@ -60,6 +71,21 @@ class NewTicket extends Component
$this->step = 3; $this->step = 3;
} }
/**
* @return array<int, array{name: string, url: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function backToCategory(): void public function backToCategory(): void
{ {
$this->step = 1; $this->step = 1;

View File

@@ -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;
@@ -27,6 +29,20 @@ class TicketShow extends Component
public ?int $pendingDeleteMessageId = null; public ?int $pendingDeleteMessageId = null;
public ?int $csatRating = null;
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);
@@ -40,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) {
@@ -86,6 +166,14 @@ class TicketShow extends Component
$this->ticket->refresh(); $this->ticket->refresh();
} }
public function submitCsat(): void
{
$this->validate(['csatRating' => 'required|integer|between:1,5']);
app(TicketService::class)->submitCsat($this->ticket, $this->csatRating, $this->csatComment ?: null);
$this->ticket->refresh();
}
public function startEdit(int $messageId, string $body): void public function startEdit(int $messageId, string $body): void
{ {
$message = TicketMessage::query()->findOrFail($messageId); $message = TicketMessage::query()->findOrFail($messageId);

View File

@@ -6,6 +6,7 @@ use App\Models\Category;
use App\Models\Subcategory; use App\Models\Subcategory;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Services\BookStackClient;
use App\Services\LdapUserProvisioner; use App\Services\LdapUserProvisioner;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
@@ -35,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()
{ {
@@ -72,6 +83,27 @@ class Landing extends Component
$this->step = 3; $this->step = 3;
} }
/**
* Unlike the logged-in Client/Operator wizards, this guest-facing form
* only shows suggestions when the admin has explicitly opted into
* exposing them to anonymous visitors (bookstack_show_to_guests)
* suggested KB article titles/links could otherwise leak internal
* content to the public.
*
* @return array<int, array{name: string, url: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded || ! Settings::bool('bookstack_show_to_guests')) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function backToCategory(): void public function backToCategory(): void
{ {
$this->step = 1; $this->step = 1;

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
class NotificationBell extends Component
{
/**
* Only unread once a notification is read (clicked through, or via
* "mark all as read"), it disappears from the bell rather than staying
* listed dimmed. The full history still lives in the notifications
* table for anyone querying it directly, just not surfaced here.
*/
#[Computed]
public function notifications()
{
return Auth::user()->unreadNotifications()->latest()->limit(20)->get();
}
#[Computed]
public function unreadCount(): int
{
return Auth::user()->unreadNotifications()->count();
}
public function markAsRead(string $id): void
{
Auth::user()->notifications()->where('id', $id)->first()?->markAsRead();
unset($this->notifications, $this->unreadCount);
}
public function markAllAsRead(): void
{
Auth::user()->unreadNotifications->each->markAsRead();
unset($this->notifications, $this->unreadCount);
}
public function render()
{
return view('livewire.notification-bell');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Livewire\Operator;
use App\Models\Category; use App\Models\Category;
use App\Models\Subcategory; use App\Models\Subcategory;
use App\Models\User; use App\Models\User;
use App\Services\BookStackClient;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -32,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()
{ {
@@ -69,6 +80,21 @@ class NewTicket extends Component
$this->step = 3; $this->step = 3;
} }
/**
* @return array<int, array{name: string, url: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function backToCategory(): void public function backToCategory(): void
{ {
$this->step = 1; $this->step = 1;

View File

@@ -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;
@@ -41,6 +43,152 @@ class Queue extends Component
public bool $pendingDeleteSelected = false; public bool $pendingDeleteSelected = false;
#[Url]
public ?int $savedViewId = null;
public string $newViewName = '';
/**
* A bare visit (no explicit ?savedViewId=... in the URL, i.e. Livewire
* never bound one) auto-applies the operator's default saved view, if
* they have one an explicit savedViewId in the URL always wins.
*/
public function mount(): void
{
if ($this->savedViewId !== null) {
return;
}
$default = Auth::user()->savedQueueViews()->where('is_default', true)->first();
if ($default) {
$this->applyViewFilters($default->filters);
$this->savedViewId = $default->id;
}
}
/**
* Bridged from a TicketQueueChanged broadcast via resources/js/echo.js
* see that file for why this is a plain Livewire event rather than an
* `#[On('echo-private:...')]` attribute. Re-queries through the same
* visibleToOperator()-scoped computed property a manual refresh would
* use, so a ticket that just became invisible to this operator (closed,
* reassigned away, moved to another team) simply won't come back, and
* its id is pruned from the current selection so a stale checkbox
* doesn't linger for a row that's no longer on screen.
*/
#[On('queue-changed')]
public function onQueueChanged(int $ticketId = 0): void
{
$this->refreshQueue();
}
/**
* Periodic fallback refresh (see the countdown badge next to "Kolumny"
* in the blade view) broadcasting is best-effort, a dropped websocket
* connection shouldn't mean the queue silently stops updating.
*/
public function refreshQueue(): void
{
unset($this->filteredTickets);
$this->selectedIds = array_values(array_intersect($this->selectedIds, $this->filteredTickets->pluck('id')->all()));
}
#[Computed]
public function savedViews()
{
return Auth::user()->savedQueueViews()->orderBy('name')->get();
}
/**
* @return array<string, mixed>
*/
protected function snapshotFilters(): array
{
return [
'queue' => $this->queue,
'filterStatus' => $this->filterStatus,
'filterPriority' => $this->filterPriority,
'filterCategory' => $this->filterCategory,
'search' => $this->search,
'sortBy' => $this->sortBy,
'sortDir' => $this->sortDir,
'visibleColumns' => $this->visibleColumns,
];
}
protected function applyViewFilters(array $filters): void
{
$this->queue = $filters['queue'] ?? $this->queue;
$this->filterStatus = $filters['filterStatus'] ?? $this->filterStatus;
$this->filterPriority = $filters['filterPriority'] ?? $this->filterPriority;
$this->filterCategory = $filters['filterCategory'] ?? $this->filterCategory;
$this->search = $filters['search'] ?? $this->search;
$this->sortBy = $filters['sortBy'] ?? $this->sortBy;
$this->sortDir = $filters['sortDir'] ?? $this->sortDir;
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
$this->selectedIds = [];
}
public function saveCurrentView(): void
{
$name = trim($this->newViewName);
if ($name === '') {
return;
}
$view = Auth::user()->savedQueueViews()->create([
'name' => $name,
'filters' => $this->snapshotFilters(),
]);
$this->savedViewId = $view->id;
$this->newViewName = '';
unset($this->savedViews);
}
/**
* Always scoped to the current user (never a bare SavedQueueView::find())
* savedViewId/id args here are client-controllable, same defensive
* pattern as selectedIdsInScope() for bulk ticket actions.
*/
public function applySavedView(int $id): void
{
$view = Auth::user()->savedQueueViews()->find($id);
if (! $view) {
return;
}
$this->applyViewFilters($view->filters);
$this->savedViewId = $view->id;
}
public function deleteSavedView(int $id): void
{
Auth::user()->savedQueueViews()->where('id', $id)->delete();
if ($this->savedViewId === $id) {
$this->savedViewId = null;
}
unset($this->savedViews);
}
public function setDefaultView(int $id): void
{
$view = Auth::user()->savedQueueViews()->find($id);
if (! $view) {
return;
}
Auth::user()->savedQueueViews()->where('id', '!=', $id)->update(['is_default' => false]);
$view->update(['is_default' => true]);
unset($this->savedViews);
}
#[Computed] #[Computed]
public function statuses() public function statuses()
{ {
@@ -55,9 +203,9 @@ class Queue extends Component
#[Computed] #[Computed]
public function filterableStatuses() public function filterableStatuses()
{ {
return $this->queue === 'all' return $this->queue === 'closed'
? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed') ? $this->statuses
: $this->statuses; : $this->statuses->reject(fn (Status $s) => $s->stage === 'closed');
} }
#[Computed] #[Computed]
@@ -101,15 +249,15 @@ class Queue extends Component
$defs = [ $defs = [
'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)], 'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)],
'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())], 'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())->whereNotIn('status_key', $closedKeys)],
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')], 'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')->whereNotIn('status_key', $closedKeys)],
'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)], 'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)],
]; ];
foreach ($this->teams as $team) { foreach ($this->teams as $team) {
$defs['team:'.$team->id] = [ $defs['team:'.$team->id] = [
'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły', 'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły',
'filter' => fn ($q) => $q->where('team_id', $team->id), 'filter' => fn ($q) => $q->where('team_id', $team->id)->whereNotIn('status_key', $closedKeys),
]; ];
} }
@@ -155,11 +303,7 @@ class Queue extends Component
$query->where('customer_id', $this->filterCustomerId); $query->where('customer_id', $this->filterCustomerId);
} }
if (trim($this->search) !== '') { if (trim($this->search) !== '') {
$term = '%'.trim($this->search).'%'; $query->search($this->search);
$query->where(fn ($q) => $q->where('number', 'like', $term)
->orWhere('subject', 'like', $term)
->orWhere('name', 'like', $term)
->orWhere('email', 'like', $term));
} }
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get(); $tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
@@ -251,7 +395,11 @@ class Queue extends Component
$this->queue = $key; $this->queue = $key;
$this->selectedIds = []; $this->selectedIds = [];
if ($key === 'all' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') { // Every tab except "closed" now excludes closed-stage tickets (see
// queueDefs()), so a stale closed-stage status filter would silently
// zero out the list on any other tab — clear it on every tab switch
// away from "closed", not just when landing on "all".
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
$this->filterStatus = 'all'; $this->filterStatus = 'all';
} }
} }
@@ -298,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;
} }

View File

@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
class Stats extends Component class Stats extends Component
{ {
@@ -152,6 +153,23 @@ class Stats extends Component
'avgFirstResponseHours' => $this->avgFirstResponseHours(), 'avgFirstResponseHours' => $this->avgFirstResponseHours(),
'avgResolutionHours' => $this->avgResolutionHours($closedKeys), 'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
'sla' => $this->slaBreachStats($closedKeys), 'sla' => $this->slaBreachStats($closedKeys),
'csat' => $this->csatStats($closedKeys),
];
}
/**
* Response rate is against closed tickets (the only ones that can ever
* be rated see Ticket::csatSubmittable()), not the whole filtered set.
*/
protected function csatStats(array $closedKeys): array
{
$closedTotal = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count();
$rated = (clone $this->baseQuery)->whereNotNull('csat_rating')->get(['csat_rating']);
return [
'avg' => $rated->isEmpty() ? null : round($rated->avg('csat_rating'), 2),
'count' => $rated->count(),
'responseRate' => $closedTotal > 0 ? round($rated->count() / $closedTotal * 100, 1) : null,
]; ];
} }
@@ -294,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()
{ {
@@ -333,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
@@ -387,6 +588,40 @@ class Stats extends Component
$this->range = $range; $this->range = $range;
} }
/**
* Row-per-ticket CSV of everything the active filters/date range
* currently show streamed directly, no temp file, no new dependency.
*/
public function export(): StreamedResponse
{
$tickets = (clone $this->baseQuery)
->with(['subcategory.category', 'assignee', 'team', 'status', 'priority'])
->orderBy('tickets.created_at')
->get();
return response()->streamDownload(function () use ($tickets) {
$out = fopen('php://output', 'w');
fputcsv($out, ['Numer', 'Temat', 'Status', 'Priorytet', 'Kategoria', 'Zespół', 'Operator', 'Utworzono', 'Zaktualizowano', 'Ocena CSAT'], escape: '\\');
foreach ($tickets as $ticket) {
fputcsv($out, [
$ticket->number,
$ticket->subject,
$ticket->statusLabel(),
$ticket->priorityLabel(),
$ticket->categoryLabel(),
$ticket->team?->name,
$ticket->assignee?->name,
$ticket->created_at,
$ticket->updated_at,
$ticket->csat_rating,
], escape: '\\');
}
fclose($out);
}, 'statystyki-'.now()->format('Y-m-d').'.csv');
}
public function render() public function render()
{ {
return view('livewire.operator.stats'); return view('livewire.operator.stats');

View File

@@ -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;
@@ -12,10 +13,12 @@ use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\TicketMessage; use App\Models\TicketMessage;
use App\Models\User; use App\Models\User;
use App\Services\BookStackClient;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
@@ -65,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);
@@ -167,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()
{ {
@@ -185,6 +242,22 @@ class TicketShow extends Component
return Category::query()->with('subcategories')->get(); return Category::query()->with('subcategories')->get();
} }
/**
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW);
}
/** /**
* A non-admin operator can only reassign a ticket to one of their own * A non-admin operator can only reassign a ticket to one of their own
* teams (mirrors the visibility scoping in Operator\Queue). * teams (mirrors the visibility scoping in Operator\Queue).
@@ -487,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);
} }

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'name', 'filters', 'is_default'])]
class SavedQueueView extends Model
{
protected function casts(): array
{
return [
'filters' => 'array',
'is_default' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -8,11 +8,13 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
#[Fillable([ #[Fillable([
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body', 'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at', 'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_at',
'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
])] ])]
class Ticket extends Model class Ticket extends Model
{ {
@@ -21,8 +23,11 @@ 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_rated_at' => 'datetime',
]; ];
} }
@@ -86,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();
@@ -129,6 +139,46 @@ class Ticket extends Model
|| $this->assignee_id === $user->id; || $this->assignee_id === $user->id;
} }
/**
* Matches ticket number/subject/name/email plus subject/body and reply
* body text. Uses MySQL FULLTEXT (natural-language mode) on MySQL/MariaDB
* matching the indexes added in the 2026_07_22_000141 migration and
* falls back to plain LIKE on sqlite (used by the test suite), which has
* no FULLTEXT equivalent.
*/
public function scopeSearch(Builder $query, string $term): Builder
{
$term = trim($term);
if ($term === '') {
return $query;
}
$mysql = DB::connection()->getDriverName() === 'mysql';
$like = '%'.$term.'%';
$messageTicketIds = DB::table('ticket_messages')
->when(
$mysql,
fn ($q) => $q->whereFullText('body', $term),
fn ($q) => $q->where('body', 'like', $like),
)
->pluck('ticket_id');
return $query->where(function (Builder $q) use ($term, $like, $mysql, $messageTicketIds) {
if ($mysql) {
$q->whereFullText(['subject', 'body'], $term);
} else {
$q->where('subject', 'like', $like)->orWhere('body', 'like', $like);
}
$q->orWhere('number', 'like', $like)
->orWhere('name', 'like', $like)
->orWhere('email', 'like', $like)
->orWhereIn('id', $messageTicketIds);
});
}
public function addHistory(string $text): TicketHistory public function addHistory(string $text): TicketHistory
{ {
return $this->histories()->create(['text' => $text, 'created_at' => now()]); return $this->histories()->create(['text' => $text, 'created_at' => now()]);
@@ -166,6 +216,21 @@ class Ticket extends Model
return Status::stageFor($this->status_key) === 'closed'; return Status::stageFor($this->status_key) === 'closed';
} }
public function hasCsatRating(): bool
{
return $this->csat_rating !== null;
}
/**
* A client can rate a ticket once it's closed, and only until they do
* there's no "change your rating" flow, mirroring how e.g. edit-message
* doesn't apply once the underlying thing is done.
*/
public function csatSubmittable(): bool
{
return $this->isClosed() && ! $this->hasCsatRating();
}
/** /**
* A resolution time of 0 minutes means "no SLA" for that priority, not * A resolution time of 0 minutes means "no SLA" for that priority, not
* "due instantly" such tickets never count down and never breach. * "due instantly" such tickets never count down and never breach.
@@ -283,8 +348,17 @@ class Ticket extends Model
} }
} }
/**
* No-ops on a closed ticket time tracking only applies to open work,
* so a closed ticket's timer should never start (whether via auto-resume
* on open or the manual "Wznów" button).
*/
public function resumeTimer(): void public function resumeTimer(): void
{ {
if ($this->isClosed()) {
return;
}
if (! $this->timer_started_at) { if (! $this->timer_started_at) {
$this->update(['timer_started_at' => now()]); $this->update(['timer_started_at' => now()]);
} }

View File

@@ -189,6 +189,11 @@ class User extends Authenticatable implements LdapAuthenticatable
return $this->hasMany(Ticket::class, 'assignee_id'); return $this->hasMany(Ticket::class, 'assignee_id');
} }
public function savedQueueViews(): HasMany
{
return $this->hasMany(SavedQueueView::class);
}
/** /**
* The admin-defined "user field" values, stored one row per field in * The admin-defined "user field" values, stored one row per field in
* user_field_values see the custom_field_values virtual attribute * user_field_values see the custom_field_values virtual attribute

View File

@@ -6,6 +6,7 @@ use App\Models\EmailTemplate;
use App\Models\Ticket; use App\Models\Ticket;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
@@ -13,11 +14,39 @@ class TicketNotification extends Notification
{ {
use Queueable; use Queueable;
public function __construct(protected Ticket $ticket, protected int $emailTemplateId) {} /**
* $recipientRole is which area the notified person is being addressed in
* ('client' or 'operator', mirrors NotificationSetting::$recipient) a
* user can hold both roles at once, so this can't be inferred from the
* notifiable itself; it decides which ticket URL (client vs operator
* area) both the e-mail link and the in-app notification point to.
*/
public function __construct(protected Ticket $ticket, protected int $emailTemplateId, protected string $recipientRole = 'client') {}
/**
* A guest customer with no account is routed anonymously (see
* TicketService::notify()) and can only ever receive mail the
* "database" channel needs a real notifiable model to attach the row to.
*/
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
return ['mail']; return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['mail', 'database'];
}
protected function ticketUrl(): string
{
return route($this->recipientRole === 'operator' ? 'operator.ticket' : 'client.ticket', $this->ticket);
}
public function toDatabase(object $notifiable): array
{
return [
'ticket_id' => $this->ticket->id,
'number' => $this->ticket->number,
'subject' => $this->ticket->subject,
'message' => 'Zgłoszenie #'.$this->ticket->number.' — '.$this->ticket->subject,
'url' => $this->ticketUrl(),
];
} }
public function toMail(object $notifiable): MailMessage public function toMail(object $notifiable): MailMessage
@@ -35,7 +64,8 @@ class TicketNotification extends Notification
'priorytet' => $this->ticket->priorityLabel(), 'priorytet' => $this->ticket->priorityLabel(),
'zespol' => $this->ticket->team?->name ?? 'Brak', 'zespol' => $this->ticket->team?->name ?? 'Brak',
'operator' => $this->ticket->assignee?->name ?? 'Nieprzypisane', 'operator' => $this->ticket->assignee?->name ?? 'Nieprzypisane',
'link' => route('client.ticket', $this->ticket), 'link' => $this->ticketUrl(),
'ocena' => route('client.ticket', $this->ticket).'#csat',
]) ?? [ ]) ?? [
'subject' => 'Zgłoszenie #'.$this->ticket->number, 'subject' => 'Zgłoszenie #'.$this->ticket->number,
'body' => $this->ticket->subject, 'body' => $this->ticket->subject,

View File

@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Models\ApiClient; use App\Models\ApiClient;
use App\Models\User;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
@@ -35,7 +36,9 @@ class AppServiceProvider extends ServiceProvider
$this->applyTimezoneSettingsOverride(); $this->applyTimezoneSettingsOverride();
$this->configureApiRateLimiting(); $this->configureApiRateLimiting();
Relation::enforceMorphMap(['api_client' => ApiClient::class]); // 'user' backs the polymorphic notifiable_type column on the
// database-notifications table (in-app notification bell).
Relation::enforceMorphMap(['api_client' => ApiClient::class, 'user' => User::class]);
} }
/** /**

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Services;
use App\Support\Settings;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class BookStackClient
{
/**
* Two independent allow-lists, since which shelves make sense to
* surface differs by where suggestions show up: 'creation' gates the
* ticket-wizard suggestions (client/operator/guest), 'ticket_view'
* gates the separate sidebar shown to an operator on an existing ticket.
*/
public const CONTEXT_CREATION = 'creation';
public const CONTEXT_TICKET_VIEW = 'ticket_view';
protected const CONTEXT_SETTINGS_KEYS = [
self::CONTEXT_CREATION => 'bookstack_allowed_shelf_ids_creation',
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
];
public function enabled(): bool
{
return Settings::bool('bookstack_enabled')
&& Settings::get('bookstack_base_url')
&& Settings::get('bookstack_token_id');
}
/**
* Suggested-article lookup, shared by the ticket-creation wizard and the
* operator ticket-view sidebar returns [] whenever the integration is
* off/unconfigured, the query is empty, or no shelf has been allow-listed
* yet for the given $context (an empty allow-list means "search nothing",
* not "search everything" an admin has to opt specific shelves in
* before any content is ever suggested, independently per context).
* Cached briefly since the same category/subcategory query repeats
* across every ticket created/viewed with that combination. Respects the
* admin-configured bookstack_search_types setting ('both'|'page'|'book')
* via BookStack's own `{type:x}` query syntax. The cache key folds in the
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected
* immediately, instead of possibly serving a pre-change result for up to
* 10 minutes.
*
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
*/
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array
{
$query = trim($query);
$allowedShelfIds = $this->allowedShelfIds($context);
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
return [];
}
$typeFilter = Settings::get('bookstack_search_types', 'both');
if (in_array($typeFilter, ['page', 'book'], true)) {
$query .= " {type:{$typeFilter}}";
}
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds));
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
try {
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
if (! $response->successful()) {
return [];
}
$shelfMap = $this->shelfBookMap();
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
$bookShelfNames = $this->bookShelfNames($shelfMap);
return collect($response->json('data', []))
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
$type = $item['type'] ?? null;
if ($type === 'bookshelf') {
return in_array($item['id'] ?? null, $allowedShelfIds, true);
}
if ($type === 'book') {
return in_array($item['id'] ?? null, $allowedBookIds, true);
}
// pages/chapters carry the id of the book they live in
return isset($item['book_id']) && in_array($item['book_id'], $allowedBookIds, true);
})
->map(function (array $item) use ($bookShelfNames) {
$bookId = $item['book_id'] ?? (($item['type'] ?? null) === 'book' ? $item['id'] : null);
return [
'name' => $item['name'] ?? '',
'url' => $item['url'] ?? null,
'type' => $item['type'] ?? 'page',
'book' => $item['book']['name'] ?? null,
'shelf' => $bookId ? ($bookShelfNames[$bookId] ?? null) : null,
];
})
->filter(fn (array $item) => $item['name'] !== '')
->values()
->all();
} catch (\Throwable) {
return [];
}
});
}
/**
* Drops the cached shelf list and shelf>book membership map used by
* the admin's "Odśwież listę półek" button so a shelf renamed/added/
* removed in BookStack shows up immediately instead of after up to 30
* minutes. Doesn't touch the per-query search-result cache (10 min TTL,
* self-invalidates on the next config save via the allow-list in its key).
*/
public function clearShelfCache(): void
{
Cache::forget('bookstack:shelves');
Cache::forget('bookstack:shelf-book-map');
}
/**
* Bookshelves for the admin's two "dozwolone półki" checklists cached
* since shelf structure changes rarely and this is fetched on every
* Admin > Konfiguracja page load while the BookStack section is expanded.
*
* @return array<int, array{id: int, name: string}>
*/
public function shelves(): array
{
if (! $this->enabled()) {
return [];
}
return Cache::remember('bookstack:shelves', now()->addMinutes(30), function () {
try {
$response = $this->client()->get('/api/shelves', ['count' => 200]);
if (! $response->successful()) {
return [];
}
return collect($response->json('data', []))
->map(fn (array $s) => ['id' => $s['id'], 'name' => $s['name']])
->values()
->all();
} catch (\Throwable) {
return [];
}
});
}
/**
* @return int[]
*/
protected function allowedShelfIds(string $context): array
{
$key = self::CONTEXT_SETTINGS_KEYS[$context] ?? self::CONTEXT_SETTINGS_KEYS[self::CONTEXT_CREATION];
$raw = Settings::get($key, '');
return collect(explode(',', (string) $raw))
->map(fn ($v) => (int) trim($v))
->filter()
->values()
->all();
}
/**
* Every shelf's book membership, fetched once and cached the single
* source both shelf-exclusion and the "Shelf > Book" breadcrumb are
* derived from, so there's only one place that talks to /api/shelves/{id}.
*
* @return array<int, array{name: string, bookIds: int[]}>
*/
protected function shelfBookMap(): array
{
return Cache::remember('bookstack:shelf-book-map', now()->addMinutes(30), function () {
$map = [];
foreach ($this->shelves() as $shelf) {
$bookIds = [];
try {
$response = $this->client()->get("/api/shelves/{$shelf['id']}");
if ($response->successful()) {
$bookIds = collect($response->json('books', []))->pluck('id')->all();
}
} catch (\Throwable) {
// Skip an unreachable/deleted shelf rather than failing the whole search.
}
$map[$shelf['id']] = ['name' => $shelf['name'], 'bookIds' => $bookIds];
}
return $map;
});
}
/**
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
* @param int[] $shelfIds
* @return int[]
*/
protected function bookIdsForShelves(array $shelfMap, array $shelfIds): array
{
$ids = [];
foreach ($shelfIds as $shelfId) {
$ids = [...$ids, ...($shelfMap[$shelfId]['bookIds'] ?? [])];
}
return $ids;
}
/**
* Book id -> owning shelf name, for the suggestion list's breadcrumb. A
* book that sits on more than one shelf just shows whichever is last in
* the map there's no single "correct" shelf to prefer in that case.
*
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
* @return array<int, string>
*/
protected function bookShelfNames(array $shelfMap): array
{
$names = [];
foreach ($shelfMap as $shelf) {
foreach ($shelf['bookIds'] as $bookId) {
$names[$bookId] = $shelf['name'];
}
}
return $names;
}
/**
* Tests unsaved admin-form values directly, rather than whatever's
* currently stored mirrors testLdapConnection()/testMailConnection()
* in Admin\Panel. Returns a message alongside the ok/error flag (BookStack's
* API returns a specific, useful reason e.g. missing "Access System API"
* role permission that a plain boolean would hide from the admin.
*
* @return array{ok: bool, message: ?string}
*/
public function testConnection(string $baseUrl, string $tokenId, string $tokenSecret, bool $verifySsl = true): array
{
try {
$response = Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
->withOptions(['verify' => $verifySsl])
->timeout(6)
->get(rtrim($baseUrl, '/').'/api/search', ['query' => 'test', 'count' => 1]);
if ($response->successful()) {
return ['ok' => true, 'message' => null];
}
return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => $e->getMessage()];
}
}
protected function client()
{
$tokenId = Settings::get('bookstack_token_id');
$tokenSecret = Settings::get('bookstack_token_secret');
return Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
->withOptions(['verify' => Settings::bool('bookstack_verify_ssl')])
->timeout(4)
->baseUrl(rtrim(Settings::get('bookstack_base_url'), '/'));
}
}

View File

@@ -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)
@@ -75,11 +113,43 @@ class TicketService
$ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]); $ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]);
$ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey)); $ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey));
$this->notify($ticket, 'status_changed'); // A transition to "closed" fires its own dedicated notification
// instead of the generic status-changed one, so closing a ticket
if ($statusKey === 'closed') { // doesn't send the customer/operator two emails for one event.
// Checked via the status's stage (not the literal key) since admins
// can rename/replace which key maps to the "closed" stage.
if (Status::stageFor($statusKey) === 'closed') {
$this->notify($ticket, 'ticket_closed'); $this->notify($ticket, 'ticket_closed');
// Time tracking only applies to open work — checkpoint and pause
// the running segment (if any) the moment a ticket is closed,
// regardless of which flow triggered the status change.
$ticket->stopTimer();
// A closed ticket that reopens later should give every automation
// rule a clean slate rather than staying latched from before.
$ticket->automationRuleLogs()->delete();
} else {
$this->notify($ticket, 'status_changed');
} }
TicketQueueChanged::dispatch($ticket->id, 'status_changed', Auth::id());
}
public function submitCsat(Ticket $ticket, int $rating, ?string $comment = null): void
{
if (! $ticket->csatSubmittable()) {
return;
}
$rating = max(1, min(5, $rating));
$ticket->update([
'csat_rating' => $rating,
'csat_comment' => $comment,
'csat_rated_at' => now(),
]);
$ticket->addHistory('Klient ocenił obsługę: '.$rating.'/5');
} }
public function setPriority(Ticket $ticket, string $priorityKey): void public function setPriority(Ticket $ticket, string $priorityKey): void
@@ -87,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
@@ -96,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
@@ -103,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
@@ -138,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);
@@ -153,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
@@ -164,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);
} }
/** /**
@@ -188,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;
} }
@@ -253,20 +341,29 @@ class TicketService
} }
$other->update(['status_key' => 'closed']); $other->update(['status_key' => 'closed']);
$other->stopTimer();
$note = $other->messages()->create([ $note = $other->messages()->create([
'author_name' => 'System', 'author_name' => 'System',
'internal' => true, 'internal' => true,
'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());
} }
/** /**
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle * Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
* event raised from within this service) can trigger the same way. * event raised from within this service) can trigger the same way.
*
* Routes through the recipient's own User model (so it lands in the
* in-app notification bell in addition to e-mail) whenever one exists;
* falls back to an anonymous mail-only route for a guest customer with
* no account. One shared NotificationSetting.enabled flag gates both
* channels there's no separate in-app on/off switch.
*/ */
public function notify(Ticket $ticket, string $triggerKey): void public function notify(Ticket $ticket, string $triggerKey): void
{ {
@@ -276,6 +373,14 @@ class TicketService
return; return;
} }
$notifiable = $setting->recipient === 'operator' ? $ticket->assignee : $ticket->customer;
if ($notifiable) {
$notifiable->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
return;
}
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email; $email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
if (! $email) { if (! $email) {
@@ -283,6 +388,6 @@ class TicketService
} }
Notification::route('mail', $email) Notification::route('mail', $email)
->notify(new TicketNotification($ticket, $setting->email_template_id)); ->notify(new TicketNotification($ticket, $setting->email_template_id, $setting->recipient));
} }
} }

View File

@@ -39,6 +39,15 @@ class Settings
'mail_smtp_encryption' => 'tls', 'mail_smtp_encryption' => 'tls',
'mail_from_address' => '', 'mail_from_address' => '',
'mail_from_name' => '', 'mail_from_name' => '',
'bookstack_enabled' => '0',
'bookstack_base_url' => '',
'bookstack_token_id' => '',
'bookstack_token_secret' => '',
'bookstack_verify_ssl' => '1',
'bookstack_show_to_guests' => '0',
'bookstack_search_types' => 'both',
'bookstack_allowed_shelf_ids_creation' => '',
'bookstack_allowed_shelf_ids_ticket_view' => '',
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>', 'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
'accent_color' => '#7c6fd6', 'accent_color' => '#7c6fd6',
'login_notice_type' => 'info', 'login_notice_type' => 'info',
@@ -51,7 +60,7 @@ class Settings
.'</div>', .'</div>',
]; ];
protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password']; protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret'];
public static function get(string $key, ?string $default = null): ?string public static function get(string $key, ?string $default = null): ?string
{ {

View File

@@ -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 {

View File

@@ -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
View File

@@ -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",

View File

@@ -27,6 +27,18 @@ return [
'author_contact' => env('AUTHOR_CONTACT'), 'author_contact' => env('AUTHOR_CONTACT'),
/*
|--------------------------------------------------------------------------
| Version
|--------------------------------------------------------------------------
|
| Shown on the Admin > About tab. Not a framework setting set VERSION in
| .env, bump it alongside the CHANGELOG.md entry/git tag on each release.
|
*/
'version' => env('VERSION'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Application Environment | Application Environment

View File

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

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

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

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedTinyInteger('csat_rating')->nullable()->after('time_spent_seconds');
$table->text('csat_comment')->nullable()->after('csat_rating');
$table->timestamp('csat_rated_at')->nullable()->after('csat_comment');
});
// FULLTEXT indexes power full-text ticket search — MariaDB/MySQL only,
// the sqlite driver used by tests has no equivalent (search falls back
// to LIKE there, see Ticket::scopeSearch()).
if (Schema::getConnection()->getDriverName() === 'mysql') {
Schema::table('tickets', function (Blueprint $table) {
$table->fullText(['subject', 'body']);
});
Schema::table('ticket_messages', function (Blueprint $table) {
$table->fullText('body');
});
}
}
public function down(): void
{
if (Schema::getConnection()->getDriverName() === 'mysql') {
Schema::table('tickets', function (Blueprint $table) {
$table->dropFullText(['subject', 'body']);
});
Schema::table('ticket_messages', function (Blueprint $table) {
$table->dropFullText(['body']);
});
}
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn(['csat_rating', 'csat_comment', 'csat_rated_at']);
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('saved_queue_views', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->json('filters');
$table->boolean('is_default')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('saved_queue_views');
}
};

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Appends a "rate our support" CTA to the existing seeded ticket_closed
* e-mail template, matching what a fresh install's seeder now produces
* (see DatabaseSeeder::seedEmailTemplatesAndNotifications()). Guarded by
* a "does it already contain {ocena}" check so re-running (or a fresh
* seed that already has it) is a no-op, and skipped entirely if the
* admin has since customized the template away from the seeded wording.
*/
public function up(): void
{
$template = DB::table('email_templates')->where('key', 'tpl-closed')->first();
if (! $template || str_contains($template->body, '{ocena}')) {
return;
}
$seededBody = '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p><p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p>';
if (! str_starts_with($template->body, $seededBody)) {
return;
}
$csatLink = '<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>';
$rest = substr($template->body, strlen($seededBody));
DB::table('email_templates')->where('id', $template->id)->update([
'body' => $seededBody.$csatLink.$rest,
'updated_at' => now(),
]);
}
public function down(): void
{
$template = DB::table('email_templates')->where('key', 'tpl-closed')->first();
if (! $template) {
return;
}
DB::table('email_templates')->where('id', $template->id)->update([
'body' => str_replace('<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>', '', $template->body),
'updated_at' => now(),
]);
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ class DatabaseSeeder extends Seeder
['key' => 'operator', 'label' => 'Operator'], ['key' => '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);
@@ -287,6 +292,7 @@ class DatabaseSeeder extends Seeder
{ {
$footer = '<p>Pozdrawiamy,<br>Zespół Wsparcia</p>'; $footer = '<p>Pozdrawiamy,<br>Zespół Wsparcia</p>';
$link = '<p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p>'; $link = '<p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p>';
$csatLink = '<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>';
$templates = [ $templates = [
'tpl-new' => [ 'tpl-new' => [
@@ -322,7 +328,7 @@ class DatabaseSeeder extends Seeder
'tpl-closed' => [ 'tpl-closed' => [
'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte', 'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte',
'subject' => 'Zgłoszenie #{numer} zostało zamknięte', 'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer,
], ],
'tpl-reply' => [ 'tpl-reply' => [
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział', 'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział',
@@ -334,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'],
@@ -358,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
View File

@@ -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",

View File

@@ -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",

View File

@@ -313,9 +313,16 @@ body {
.queue-filters { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; } .queue-filters { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
.queue-filters-search { width: 220px; } .queue-filters-search { width: 220px; }
.queue-filters-columns { position: relative; margin-left: auto; } .queue-filters-columns { position: relative; margin-left: auto; }
.queue-filters-saved { position: relative; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.spin { display: inline-block; animation: spin 0.8s linear infinite; }
.main-col { flex: 1; min-width: 320px; } .main-col { flex: 1; min-width: 320px; }
.aside-col { width: 300px; flex: none; } .aside-col { width: 300px; flex: none; }
/* Opt-in wider sidebar (operator ticket view, which also shows BookStack
suggestions) at least 50% wider than the default .aside-col. */
.aside-col-wide { width: 460px; }
.wizard-step { width: 80px; flex: none; } .wizard-step { width: 80px; flex: none; }
.wizard-connector { width: 56px; flex: none; } .wizard-connector { width: 56px; flex: none; }
@@ -336,9 +343,26 @@ 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%; }
.aside-col-wide { width: 100%; }
.wizard-step { width: 58px; } .wizard-step { width: 58px; }
.wizard-connector { width: 22px; } .wizard-connector { width: 22px; }
.dialog { padding: 18px; } .dialog { padding: 18px; }

View File

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

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

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

View File

@@ -0,0 +1,55 @@
@props(['articles', 'variant' => 'banner', 'title' => 'Może pomogą te artykuły z bazy wiedzy', 'showCopy' => false])
@php
$icons = ['book' => 'menu_book', 'chapter' => 'bookmark', 'bookshelf' => 'library_books', 'page' => 'article'];
$isSidebar = $variant === 'sidebar';
@endphp
@if (count($articles))
<div class="card" style="{{ $isSidebar ? 'padding:16px;gap:8px' : 'padding:14px;gap:10px;background:color-mix(in srgb, var(--color-accent) 6%, transparent);border-color:color-mix(in srgb, var(--color-accent) 25%, var(--color-divider))' }}">
@if ($isSidebar)
<div class="card-kicker">{{ $title }}</div>
@else
<div style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600">
<span class="material-symbols-outlined" style="font-size:16px">auto_awesome</span>
{{ $title }}
</div>
@endif
<div style="display:flex;flex-direction:column;gap:2px">
@foreach ($articles as $article)
<div
@if ($showCopy) x-data="{ copied: false }" @endif
style="display:flex;gap:6px;align-items:flex-start;padding:8px;border-radius:6px"
onmouseover="this.style.background='color-mix(in srgb, var(--color-accent) 8%, transparent)'"
onmouseout="this.style.background='transparent'"
>
<a
href="{{ $article['url'] }}"
target="_blank"
rel="noopener noreferrer"
style="display:flex;gap:10px;align-items:flex-start;flex:1;min-width:0;text-decoration:none;color:inherit"
>
<span class="material-symbols-outlined" style="font-size:18px;flex:none;margin-top:1px;color:var(--color-accent)">{{ $icons[$article['type']] ?? 'article' }}</span>
<span style="min-width:0;flex:1">
<span style="display:block;font-size:13px;font-weight:500;color:var(--color-accent)">{{ $article['name'] }}</span>
@if (! empty($article['book']) && $article['book'] !== $article['name'])
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:1px">
{{ ! empty($article['shelf']) ? $article['shelf'].' > '.$article['book'] : $article['book'] }}
</span>
@endif
</span>
</a>
@if ($showCopy)
<button
type="button"
class="btn btn-secondary"
style="flex:none;font-size:11px;padding:4px 8px;white-space:nowrap"
x-on:click="navigator.clipboard.writeText(@js($article['url'])); copied = true; setTimeout(() => copied = false, 1500)"
x-text="copied ? 'Skopiowano!' : 'Kopiuj link'"
></button>
@endif
</div>
@endforeach
</div>
</div>
@endif

View File

@@ -1,9 +1,25 @@
@props(['attachment']) @props(['attachment'])
<a @php
href="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path) }}" $url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
target="_blank" $isImage = \Illuminate\Support\Str::startsWith($attachment->mime ?? '', 'image/');
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)" @endphp
>
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }} @if ($isImage)
</a> <a href="{{ $url }}" target="_blank" style="display:block;margin-top:8px">
<img
src="{{ $url }}"
alt="{{ $attachment->original_name }}"
loading="lazy"
style="max-width:220px;max-height:160px;border-radius:8px;border:1px solid var(--color-divider);object-fit:cover;cursor:zoom-in;display:block"
>
</a>
@else
<a
href="{{ $url }}"
target="_blank"
style="display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:5px 10px;border:1px solid var(--color-divider);border-radius:6px;font-size:12.5px;color:inherit;text-decoration:none;background:color-mix(in srgb, var(--color-text) 5%, transparent)"
>
<span class="material-symbols-outlined" style="font-size:15px">attach_file</span>{{ $attachment->original_name }}
</a>
@endif

View File

@@ -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)

View File

@@ -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')">

View File

@@ -18,5 +18,9 @@
{{ $slot }} {{ $slot }}
@auth
<livewire:notification-bell />
@endauth
<x-profile-menu /> <x-profile-menu />
</div> </div>

View File

@@ -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

View File

@@ -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 &ge; {{ $rule->condition_minutes }} min. {{ match($rule->action_type) { 'change_priority' => 'zmień priorytet', 'change_status' => 'zmień status', 'change_team' => 'zmień zespół', 'change_assignee' => 'zmień przypisanie', default => $rule->action_type } }}</span>
</div>
<div style="display:flex;gap:6px;flex:none;align-items:center">
<label style="display:flex;align-items:center;gap:4px;font-size:12px">
<input type="checkbox" wire:click="toggleAutomationRuleEnabled({{ $rule->id }})" @checked($rule->enabled)>
Aktywna
</label>
<button class="btn btn-ghost" type="button" wire:click="editAutomationRule({{ $rule->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeAutomationRule({{ $rule->id }})">Usuń</button>
</div>
</div>
</div>
@endforeach
</div>
@else
<p class="text-muted" style="font-size:13px">Brak reguł automatyzacji. Dodaj pierwszą używając przycisku wyżej.</p>
@endif
@endif
{{-- ================= STATUSES ================= --}} {{-- ================= STATUSES ================= --}}
@if ($tab === 'statuses') @if ($tab === 'statuses')
<h3 style="margin:0 0 6px">Statusy</h3> <h3 style="margin:0 0 6px">Statusy</h3>
@@ -654,6 +688,86 @@ $tabGroups = [
@endif @endif
</form> </form>
<form wire:submit="saveBookstackConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Baza wiedzy BookStack</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Po włączeniu, podczas tworzenia zgłoszenia klientom i operatorom podpowiadane będą pasujące artykuły z BookStack na podstawie wybranej kategorii/podkategorii.</span>
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację z BookStack</strong></label>
@if ($bookstackConfig['enabled'])
<div class="field"><label>Adres instancji BookStack</label><input class="input" placeholder="https://wiki.firma.pl" wire:model="bookstackConfig.baseUrl"></div>
<div class="field"><label>Token ID</label><input class="input" wire:model="bookstackConfig.tokenId"></div>
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil &rarr; Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
<div class="field"><label>Przeszukuj</label>
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes">
<option value="both">Strony i książki</option>
<option value="page">Tylko strony</option>
<option value="book">Tylko książki</option>
</select>
</div>
<div style="display:flex;justify-content:flex-end">
<button type="button" class="btn btn-secondary" style="display:flex;align-items:center;gap:6px;font-size:12.5px" wire:click="refreshBookstackShelves" wire:loading.attr="disabled" wire:target="refreshBookstackShelves">
<span class="material-symbols-outlined" style="font-size:16px" wire:loading.class="spin" wire:target="refreshBookstackShelves">refresh</span>
Odśwież listę półek
</button>
</div>
<div class="field">
<label>Dozwolone półki podpowiedzi przy tworzeniu zgłoszenia</label>
@if (count($this->bookstackShelves))
<div style="display:flex;flex-direction:column;gap:2px;border:1px solid var(--color-divider);border-radius:8px;padding:8px">
@foreach ($this->bookstackShelves as $shelf)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:4px 6px;border-radius:5px;cursor:pointer">
<input type="checkbox" @checked(in_array($shelf['id'], $bookstackConfig['allowedShelfIdsCreation'])) wire:click="toggleBookstackAllowedShelf('allowedShelfIdsCreation', {{ $shelf['id'] }})">
{{ $shelf['name'] }}
</label>
@endforeach
</div>
@else
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Brak półek do wyświetlenia sprawdź, czy połączenie działa (przycisk „Testuj połączenie” niżej), albo zapisz konfigurację, żeby odświeżyć listę.</p>
@endif
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Tylko książki/strony z zaznaczonych półek mogą pojawić się jako podpowiedzi podczas tworzenia zgłoszenia (klient, operator, formularz gościa). Jeśli żadna półka nie jest zaznaczona, podpowiedzi się nie pojawią.</p>
</div>
<div class="field">
<label>Dozwolone półki panel operatora przy zgłoszeniu</label>
@if (count($this->bookstackShelves))
<div style="display:flex;flex-direction:column;gap:2px;border:1px solid var(--color-divider);border-radius:8px;padding:8px">
@foreach ($this->bookstackShelves as $shelf)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:4px 6px;border-radius:5px;cursor:pointer">
<input type="checkbox" @checked(in_array($shelf['id'], $bookstackConfig['allowedShelfIdsTicketView'])) wire:click="toggleBookstackAllowedShelf('allowedShelfIdsTicketView', {{ $shelf['id'] }})">
{{ $shelf['name'] }}
</label>
@endforeach
</div>
@else
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Brak półek do wyświetlenia sprawdź, czy połączenie działa (przycisk „Testuj połączenie” niżej), albo zapisz konfigurację, żeby odświeżyć listę.</p>
@endif
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Niezależna lista kontroluje, co widzi operator w bocznym panelu po otwarciu istniejącego zgłoszenia (link do artykułu lub przycisk kopiowania linku). Jeśli żadna półka nie jest zaznaczona, panel się nie pokaże.</p>
</div>
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.showToGuests" style="position:static;opacity:1;width:auto;height:auto">Pokazuj podpowiedzi także niezalogowanym (formularz zgłoszenia na stronie głównej)</label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez zaznaczenia podpowiedzi widoczne tylko przy tworzeniu zgłoszenia przez zalogowanego klienta lub operatora.</span>
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($bookstackTestResult === 'ok')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
@elseif ($bookstackTestResult === 'error')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $bookstackTestMessage ? ': '.$bookstackTestMessage : '' }}</div>
@endif
</div>
@else
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
@endif
</form>
</div> </div>
@endif @endif
@@ -667,7 +781,7 @@ $tabGroups = [
<h3 style="margin:0 0 14px">O aplikacji</h3> <h3 style="margin:0 0 14px">O aplikacji</h3>
<div class="card" style="padding:18px;gap:10px;max-width:420px"> <div class="card" style="padding:18px;gap:10px;max-width:420px">
<div style="display:flex;justify-content:space-between"><span class="text-muted">Aplikacja</span><span>{{ \App\Support\Settings::get('company_name') }}</span></div> <div style="display:flex;justify-content:space-between"><span class="text-muted">Aplikacja</span><span>{{ \App\Support\Settings::get('company_name') }}</span></div>
<div style="display:flex;justify-content:space-between"><span class="text-muted">Wersja</span><span>1.0.0</span></div> <div style="display:flex;justify-content:space-between"><span class="text-muted">Wersja</span><span>{{ config('app.version') ?: '—' }}</span></div>
<div style="display:flex;justify-content:space-between"><span class="text-muted">Kontakt wsparcia</span><span>{{ config('app.author_contact') ?: '—' }}</span></div> <div style="display:flex;justify-content:space-between"><span class="text-muted">Kontakt wsparcia</span><span>{{ config('app.author_contact') ?: '—' }}</span></div>
</div> </div>
@endif @endif
@@ -723,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">

View File

@@ -15,11 +15,11 @@
<div class="field"> <div class="field">
<label>Nazwa użytkownika</label> <label>Nazwa użytkownika</label>
<input class="input" wire:model="username" autofocus> <input class="input" wire:model="username" autofocus required>
</div> </div>
<div class="field"> <div class="field">
<label>Hasło</label> <label>Hasło</label>
<input class="input" type="password" wire:model="password"> <input class="input" type="password" wire:model="password" required>
</div> </div>
<button class="btn btn-primary btn-block" type="submit">Zaloguj się</button> <button class="btn btn-primary btn-block" type="submit">Zaloguj się</button>
</form> </form>

View File

@@ -7,9 +7,12 @@
<a href="{{ route('client.new') }}" wire:navigate class="btn btn-primary">+ Nowe zgłoszenie</a> <a href="{{ route('client.new') }}" wire:navigate class="btn btn-primary">+ Nowe zgłoszenie</a>
</div> </div>
<div class="seg" style="align-self:flex-start"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->count() }})</label> <div class="seg">
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->count() }})</label> <label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->count() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->count() }})</label>
</div>
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
</div> </div>
<div style="display:flex;flex-direction:column;gap:10px"> <div style="display:flex;flex-direction:column;gap:10px">

View File

@@ -59,6 +59,11 @@
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span> <span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button> <button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div> </div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field"> <div class="field">
<label>Temat</label> <label>Temat</label>
<input class="input" wire:model="subject"> <input class="input" wire:model="subject">
@@ -76,8 +81,16 @@
<div class="field"> <div class="field">
<label>Załączniki</label> <label>Załączniki</label>
<div style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"> <div
x-data="{ dragging: false }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
<span class="text-muted" style="font-size:12px">lub przeciągnij pliki tutaj</span>
@forelse ($attachments as $i => $file) @forelse ($attachments as $i => $file)
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px"> <span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
{{ $file->getClientOriginalName() }} {{ $file->getClientOriginalName() }}

View File

@@ -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">&larr; 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">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap"> <div 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 }} &middot; {{ \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 }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@@ -63,7 +79,14 @@
<textarea class="input" placeholder="Napisz odpowiedź…" wire:model="reply"></textarea> <textarea class="input" placeholder="Napisz odpowiedź…" wire:model="reply"></textarea>
@error('reply') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror @error('reply') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"> <div
x-data="{ dragging: false }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
@forelse ($attachments as $i => $file) @forelse ($attachments as $i => $file)
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0"> <span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
@@ -87,22 +110,56 @@
<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())
<button type="button" class="btn btn-secondary btn-block" wire:click="reopen">Otwórz ponownie</button> <button type="button" class="btn btn-secondary btn-block" wire:click="reopen">Otwórz ponownie</button>
@endif @endif
</div> </div>
<div id="csat" class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Ocena obsługi</div>
@if ($ticket->hasCsatRating())
<div style="display:flex;gap:2px">
@for ($i = 1; $i <= 5; $i++)
<span class="material-symbols-outlined" style="font-size:20px;color:{{ $i <= $ticket->csat_rating ? 'var(--color-accent)' : 'var(--color-divider)' }}">star</span>
@endfor
</div>
@if ($ticket->csat_comment)
<p style="font-size:13px;margin:0;white-space:pre-wrap">{{ $ticket->csat_comment }}</p>
@endif
@elseif ($ticket->csatSubmittable())
<div style="display:flex;gap:4px" wire:key="csat-stars-{{ $csatRating }}">
@for ($i = 1; $i <= 5; $i++)
<span
class="material-symbols-outlined"
style="font-size:24px;cursor:pointer;color:{{ $csatRating && $i <= $csatRating ? 'var(--color-accent)' : 'var(--color-divider)' }}"
wire:click="$set('csatRating', {{ $i }})"
>star</span>
@endfor
</div>
@error('csatRating') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
<textarea class="input" placeholder="Komentarz (opcjonalnie)" wire:model="csatComment" style="min-height:60px"></textarea>
<button type="button" class="btn btn-primary btn-block" wire:click="submitCsat">Wyślij ocenę</button>
@else
<p class="text-muted" style="font-size:12px;margin:0">Ocena będzie dostępna po zamknięciu zgłoszenia.</p>
@endif
</div>
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Historia zmian</div> <div class="card-kicker">Historia zmian</div>
@forelse ($ticket->histories as $h) @forelse ($ticket->histories as $h)
<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
@@ -134,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>

View File

@@ -89,6 +89,11 @@
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span> <span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button> <button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div> </div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field"> <div class="field">
<label>Temat</label> <label>Temat</label>
<input class="input" placeholder="Krótki opis problemu" wire:model="subject"> <input class="input" placeholder="Krótki opis problemu" wire:model="subject">

View File

@@ -0,0 +1,38 @@
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.30s="$refresh">
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
<span class="material-symbols-outlined" style="font-size:18px">notifications</span>
@if ($this->unreadCount)
<span style="position:absolute;top:2px;right:2px;min-width:16px;height:16px;padding:0 3px;border-radius:8px;background:var(--color-accent);color:#fff;font-size:10px;line-height:16px;text-align:center">{{ $this->unreadCount > 9 ? '9+' : $this->unreadCount }}</span>
@endif
</button>
<div
x-show="open"
x-cloak
class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);width:320px;max-height:420px;overflow-y:auto;z-index:30"
>
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--color-divider)">
<span style="font-size:12.5px;font-weight:600">Powiadomienia</span>
@if ($this->unreadCount)
<button type="button" wire:click="markAllAsRead" style="font-size:11.5px;background:none;border:none;color:var(--color-accent);cursor:pointer;padding:0">Oznacz wszystkie jako przeczytane</button>
@endif
</div>
@forelse ($this->notifications as $notification)
<a
wire:key="notification-{{ $notification->id }}"
href="{{ $notification->data['url'] ?? '#' }}"
wire:navigate
wire:click="markAsRead('{{ $notification->id }}')"
@click="open = false"
style="display:block;padding:10px 14px;text-decoration:none;color:var(--color-text);border-bottom:1px solid var(--color-divider);font-size:12.5px;background:color-mix(in srgb, var(--color-accent) 6%, transparent)"
>
<div>{{ $notification->data['message'] ?? '' }}</div>
<div style="font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:2px">{{ $notification->created_at->diffForHumans() }}</div>
</a>
@empty
<div style="padding:20px 14px;text-align:center;font-size:12.5px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">Brak powiadomień</div>
@endforelse
</div>
</div>

View File

@@ -65,6 +65,11 @@
<span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span> <span class="tag tag-outline">{{ $this->selectedCategory?->name }} / {{ $this->selectedSubcategory?->name }}</span>
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button> <button type="button" class="btn btn-ghost" style="padding:0" wire:click="backToSubcategory">Zmień</button>
</div> </div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div class="field"> <div class="field">
<label>Temat</label> <label>Temat</label>
<input class="input" wire:model="subject"> <input class="input" wire:model="subject">
@@ -82,8 +87,16 @@
<div class="field"> <div class="field">
<label>Załączniki</label> <label>Załączniki</label>
<div style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"> <div
x-data="{ dragging: false }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
<span class="text-muted" style="font-size:12px">lub przeciągnij pliki tutaj</span>
@forelse ($attachments as $i => $file) @forelse ($attachments as $i => $file)
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px"> <span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
{{ $file->getClientOriginalName() }} {{ $file->getClientOriginalName() }}

View File

@@ -74,6 +74,34 @@
@endforeach @endforeach
</select> </select>
<div class="queue-filters-saved" x-data="{ open: false, adding: false }">
<button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
Zapisane widoki
</button>
<div x-show="open" x-cloak @click.outside="open = false; adding = false" style="position:absolute;top:100%;left:0;margin-top:4px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);z-index:30;padding:6px;min-width:220px">
@forelse ($this->savedViews as $view)
<div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px;{{ $savedViewId === $view->id ? 'background:color-mix(in srgb, var(--color-accent) 12%, transparent)' : '' }}">
<button type="button" wire:click="applySavedView({{ $view->id }})" style="flex:1;min-width:0;text-align:left;background:none;border:none;cursor:pointer;padding:6px 0;font-size:13px;color:{{ $savedViewId === $view->id ? 'var(--color-accent)' : 'inherit' }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $view->name }}</button>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;flex:none;opacity:{{ $view->is_default ? '1' : '0.4' }};color:{{ $view->is_default ? 'var(--color-accent)' : 'inherit' }}" title="Ustaw jako domyślny" wire:click="setDefaultView({{ $view->id }})">star</span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;flex:none;opacity:0.6" title="Usuń" wire:click="deleteSavedView({{ $view->id }})">delete</span>
</div>
@empty
<p class="text-muted" style="font-size:12px;margin:2px 8px">Brak zapisanych widoków.</p>
@endforelse
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<template x-if="! adding">
<button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
</template>
<div x-show="adding" style="display:flex;gap:6px;padding:4px 2px">
<input class="input" style="flex:1;font-size:12.5px" placeholder="Nazwa widoku" wire:model="newViewName" @keydown.enter="$wire.saveCurrentView(); adding = false">
<button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
</div>
</div>
</div>
<div class="queue-filters-columns" x-data="{ open: false }"> <div class="queue-filters-columns" x-data="{ open: false }">
<button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px"> <button type="button" class="btn btn-secondary" @click="open = ! open" style="display:flex;align-items:center;justify-content:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">view_column</span> <span class="material-symbols-outlined" style="font-size:18px">view_column</span>
@@ -88,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">
@@ -115,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>

View File

@@ -52,10 +52,17 @@
<option value="{{ $u->id }}">{{ $u->name }}</option> <option value="{{ $u->id }}">{{ $u->name }}</option>
@endforeach @endforeach
</select> </select>
<button type="button" class="btn btn-secondary" style="margin-left:auto;display:flex;align-items:center;gap:6px" wire:click="export">
<span class="material-symbols-outlined" style="font-size:18px">download</span>
Eksportuj CSV
</button>
</div> </div>
{{-- KPI tiles --}} {{-- KPI tiles --}}
@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>
@@ -85,85 +92,206 @@
</div> </div>
<div class="stat-tile-meta">{{ $kpis['sla']['breached'] }} / {{ $kpis['sla']['total'] }} zgłoszeń</div> <div class="stat-tile-meta">{{ $kpis['sla']['breached'] }} / {{ $kpis['sla']['total'] }} zgłoszeń</div>
</div> </div>
<div class="stat-tile">
<div class="stat-tile-label">Ocena obsługi (CSAT)</div>
<div class="stat-tile-value">{{ $kpis['csat']['avg'] !== null ? $kpis['csat']['avg'].' / 5' : '—' }}</div>
<div class="stat-tile-meta">{{ $kpis['csat']['count'] }} ocen{{ $kpis['csat']['responseRate'] !== null ? ' · '.$kpis['csat']['responseRate'].'% odpowiedzi' : '' }}</div>
</div>
</div>
</div> </div>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start"> {{-- Rozkład zgłoszeń --}}
{{-- By status --}} <div style="display:flex;flex-direction:column;gap:12px">
<div class="card" style="padding:16px"> <h3 style="margin:0">Rozkład zgłoszeń</h3>
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div> <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
@php($max = max($this->byStatus->max('count'), 1)) <div class="card" style="padding:16px">
@forelse ($this->byStatus as $row) <div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}"> @php($max = max($this->byStatus->max('count'), 1))
<div class="bar-row-label">{{ $row['label'] }}</div> @forelse ($this->byStatus as $row)
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div> <div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-value">{{ $row['count'] }}</div> <div class="bar-row-label">{{ $row['label'] }}</div>
</div> <div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
@empty <div class="bar-row-value">{{ $row['count'] }}</div>
<div class="card-meta">Brak danych w wybranym okresie.</div> </div>
@endforelse @empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
@php($max = max($this->byPriority->max('count'), 1))
@forelse ($this->byPriority as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
@php($cats = $this->byCategory)
@php($max = max($cats->max('count') ?? 0, 1))
@forelse ($cats as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg podkategorii</div>
@php($subs = $this->bySubcategory)
@php($max = max($subs->max('count') ?? 0, 1))
@forelse ($subs as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
{{-- Obciążenie --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Obciążenie</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
@php($teamRows = $this->byTeam)
@php($max = max($teamRows->max('count') ?? 0, 1))
@forelse ($teamRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
@php($opRows = $this->byAssignee)
@php($max = max($opRows->max('count') ?? 0, 1))
@forelse ($opRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div>
</div>
{{-- Klienci --}}
<div style="display:flex;flex-direction:column;gap:12px">
<h3 style="margin:0">Klienci</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Najaktywniejsi klienci (Top 10)</div>
@php($customerRows = $this->byCustomer)
@php($max = max($customerRows->max('count') ?? 0, 1))
@forelse ($customerRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div>
</div> </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:4px">Klienci wg podkategorii</div>
@php($max = max($this->byPriority->max('count'), 1)) <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>
@forelse ($this->byPriority as $row) @php($matrix = $this->customerSubcategoryMatrix)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}"> @if (count($matrix['rows']))
<div class="bar-row-label">{{ $row['label'] }}</div> <div class="table-wrap">
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:{{ $row['color'] }}"></div></div> <table class="table">
<div class="bar-row-value">{{ $row['count'] }}</div> <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> </div>
@empty @else
<div class="card-meta">Brak danych w wybranym okresie.</div> <div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse @endif
</div> </div>
</div>
{{-- By category --}} {{-- Ocena obsługi (CSAT) --}}
<div class="card" style="padding:16px"> <div style="display:flex;flex-direction:column;gap:12px">
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div> <h3 style="margin:0">Ocena obsługi (CSAT)</h3>
@php($cats = $this->byCategory) <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
@php($max = max($cats->max('count') ?? 0, 1)) <div class="card" style="padding:16px">
@forelse ($cats as $row) <div class="card-title" style="margin-bottom:12px">CSAT wg zespołu</div>
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}"> @php($csatTeamRows = $this->csatByTeam)
<div class="bar-row-label">{{ $row['label'] }}</div> @forelse ($csatTeamRows as $row)
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div> <div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
<div class="bar-row-value">{{ $row['count'] }}</div> <div class="bar-row-label">{{ $row['label'] }}</div>
</div> <div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
@empty <div class="bar-row-value">{{ $row['avg'] }} / 5</div>
<div class="card-meta">Brak danych w wybranym okresie.</div> </div>
@endforelse @empty
</div> <div class="card-meta">Brak ocen w wybranym okresie.</div>
@endforelse
</div>
{{-- By team --}} <div class="card" style="padding:16px">
<div class="card" style="padding:16px"> <div class="card-title" style="margin-bottom:12px">CSAT wg operatora</div>
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div> @php($csatOpRows = $this->csatByAssignee)
@php($teamRows = $this->byTeam) @forelse ($csatOpRows as $row)
@php($max = max($teamRows->max('count') ?? 0, 1)) <div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
@forelse ($teamRows as $row) <div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}"> <div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
<div class="bar-row-label">{{ $row['label'] }}</div> <div class="bar-row-value">{{ $row['avg'] }} / 5</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div> </div>
<div class="bar-row-value">{{ $row['count'] }}</div> @empty
</div> <div class="card-meta">Brak ocen w wybranym okresie.</div>
@empty @endforelse
<div class="card-meta">Brak danych w wybranym okresie.</div> </div>
@endforelse
</div>
{{-- By assignee --}}
<div class="card" style="padding:16px">
<div class="card-title" style="margin-bottom:12px">Obciążenie operatorów</div>
@php($opRows = $this->byAssignee)
@php($max = max($opRows->max('count') ?? 0, 1))
@forelse ($opRows as $row)
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
<div class="bar-row-label">{{ $row['label'] }}</div>
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent-2)"></div></div>
<div class="bar-row-value">{{ $row['count'] }}</div>
</div>
@empty
<div class="card-meta">Brak danych w wybranym okresie.</div>
@endforelse
</div> </div>
</div> </div>
@@ -171,6 +299,8 @@
@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>
@@ -213,5 +343,6 @@
</div> </div>
@endif @endif
</div> </div>
</div>
</div> </div>
</div> </div>

View File

@@ -2,8 +2,24 @@
<x-topbar /> <x-topbar />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto"> <div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1020px;margin:0 auto"> <div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">&larr; Wróć do listy</a> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}}
<div
class="btn btn-secondary"
style="cursor:default;gap:6px"
x-data="{ remaining: 30, total: 30 }"
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
title="Zgłoszenie odświeża się automatycznie"
>
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span>
</div>
</div>
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap"> <div 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 }} &middot; {{ \App\Support\Rel::format($m->created_at) }}</div> <div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} &middot; {{ \App\Support\Rel::format($m->created_at) }}</div>
@if ($m->role === 'operator') @if ($m->role === 'operator')
@@ -96,7 +112,14 @@
@if ($addingNote) @if ($addingNote)
<textarea class="input" placeholder="Dodaj notatkę widoczną tylko dla zespołu…" wire:model="noteDraft"></textarea> <textarea class="input" placeholder="Dodaj notatkę widoczną tylko dla zespołu…" wire:model="noteDraft"></textarea>
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"> <div
x-data="{ dragging: false }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label>
@forelse ($noteAttachments as $i => $file) @forelse ($noteAttachments as $i => $file)
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0"> <span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
@@ -123,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 }} &middot; {{ \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 }} &middot; {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
@@ -160,7 +183,14 @@
</select> </select>
<textarea class="input" placeholder="Napisz odpowiedź do klienta…" wire:model="reply"></textarea> <textarea class="input" placeholder="Napisz odpowiedź do klienta…" wire:model="reply"></textarea>
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"> <div
x-data="{ dragging: false }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; const input = $el.querySelector('input[type=file]'); input.files = $event.dataTransfer.files; input.dispatchEvent(new Event('change'))"
:style="{ borderColor: dragging ? 'var(--color-accent)' : undefined, background: dragging ? 'color-mix(in srgb, var(--color-accent) 8%, transparent)' : undefined }"
style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
>
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label>
@forelse ($replyAttachments as $i => $file) @forelse ($replyAttachments as $i => $file)
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0"> <span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
@@ -190,7 +220,7 @@
</form> </form>
</div> </div>
<div class="aside-col" style="display:flex;flex-direction:column;gap:14px"> <div class="aside-col aside-col-wide" style="display:flex;flex-direction:column;gap:14px">
<div class="card" style="padding:16px;gap:6px"> <div class="card" style="padding:16px;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div class="card-kicker">Zgłaszający</div> <div class="card-kicker">Zgłaszający</div>
@@ -272,11 +302,29 @@
</div> </div>
</div> </div>
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</div>
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">SLA</div> <div class="card-kicker">SLA</div>
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div> <div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div>
</div> </div>
@if ($ticket->hasCsatRating())
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Ocena obsługi</div>
<div style="display:flex;gap:2px">
@for ($i = 1; $i <= 5; $i++)
<span class="material-symbols-outlined" style="font-size:18px;color:{{ $i <= $ticket->csat_rating ? 'var(--color-accent)' : 'var(--color-divider)' }}">star</span>
@endfor
</div>
@if ($ticket->csat_comment)
<p style="font-size:12.5px;margin:0;white-space:pre-wrap">{{ $ticket->csat_comment }}</p>
@endif
</div>
@endif
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">MONITOR CZASU PRACY</div> <div class="card-kicker">MONITOR CZASU PRACY</div>
<div <div
@@ -344,14 +392,21 @@
<span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span> <span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span> <span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
</div> </div>
<div style="display:flex;gap:6px"> @if ($ticket->isClosed())
@if ($ticket->timer_started_at) <div style="display:flex;flex-direction:column;gap:6px;align-items:flex-start">
<button type="button" class="btn btn-secondary" wire:click="stopTimer" @click="running = false; clearInterval(tick)">Zatrzymaj</button> <span style="font-size:12px;opacity:0.7">Zgłoszenie zamknięte zliczanie wstrzymane</span>
@else <button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
<button type="button" class="btn btn-secondary" wire:click="resumeTimer" @click="running = true; start()">Wznów</button> </div>
@endif @else
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button> <div style="display:flex;gap:6px">
</div> @if ($ticket->timer_started_at)
<button type="button" class="btn btn-secondary" wire:click="stopTimer" @click="running = false; clearInterval(tick)">Zatrzymaj</button>
@else
<button type="button" class="btn btn-secondary" wire:click="resumeTimer" @click="running = true; start()">Wznów</button>
@endif
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
</div>
@endif
@endif @endif
</div> </div>
</div> </div>
@@ -398,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
View File

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

View File

@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
})->purpose('Display an inspiring quote'); })->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();

View File

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

View File

@@ -3,13 +3,15 @@
use App\Livewire\Admin\Panel; use App\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',

View File

@@ -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']);
@@ -90,14 +92,14 @@ test('changing the subcategory fires category_changed, but re-saving details wit
Notification::assertSentOnDemandTimes(TicketNotification::class, 1); Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
}); });
test('closing a ticket fires both status_changed and ticket_closed', function () { test('closing a ticket fires only ticket_closed, not status_changed, so it does not double-notify', function () {
Notification::fake(); Notification::fake();
seedStatusesAndPriorities(); $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 both triggers // control so this test's "did it wrongly fire" assertion isn't tied to
// actually have something to send, 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',
]); ]);
@@ -107,12 +109,28 @@ test('closing a ticket fires both status_changed and ticket_closed', function ()
app(TicketService::class)->setStatus($ticket, 'closed'); app(TicketService::class)->setStatus($ticket, 'closed');
Notification::assertSentOnDemandTimes(TicketNotification::class, 2); Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
});
test('a non-closing status change still fires status_changed as usual', function () {
Notification::fake();
$this->seed();
$statusTemplate = EmailTemplate::query()->create([
'key' => 'tpl-status-test-2', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
]);
NotificationSetting::query()->where('trigger_key', 'status_changed')->update(['email_template_id' => $statusTemplate->id]);
$ticket = makeTicket();
app(TicketService::class)->setStatus($ticket, 'open');
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
}); });
test('an operator reply fires operator_replied once enabled, independent of any status change', function () { test('an operator reply fires operator_replied once enabled, independent of any status change', function () {
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']]);
@@ -121,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);
});

View File

@@ -1,10 +1,12 @@
<?php <?php
use App\Ldap\LldapUser; use App\Ldap\LldapUser;
use App\Livewire\Auth\Login;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use LdapRecord\Laravel\Testing\DirectoryEmulator; use LdapRecord\Laravel\Testing\DirectoryEmulator;
use Livewire\Livewire;
afterEach(function () { afterEach(function () {
DirectoryEmulator::tearDown(); DirectoryEmulator::tearDown();
@@ -63,3 +65,22 @@ test('an unknown username does not authenticate', function () {
expect(Auth::attempt(['uid' => 'someone.else', 'password' => 'whatever']))->toBeFalse(); expect(Auth::attempt(['uid' => 'someone.else', 'password' => 'whatever']))->toBeFalse();
}); });
test('submitting the login form with a blank username or password shows an error and never attempts to authenticate', function () {
Livewire::test(Login::class)
->set('username', '')
->set('password', '')
->call('submit')
->assertSet('error', 'Podaj nazwę użytkownika i hasło.');
expect(Auth::check())->toBeFalse();
// Whitespace-only counts as blank for the username too.
Livewire::test(Login::class)
->set('username', ' ')
->set('password', 'somepassword')
->call('submit')
->assertSet('error', 'Podaj nazwę użytkownika i hasło.');
expect(Auth::check())->toBeFalse();
});

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Livewire\Operator\Queue; use App\Livewire\Operator\Queue;
use App\Models\Team;
use Livewire\Livewire; use Livewire\Livewire;
test('the operator queue has a dedicated tab listing only closed tickets', function () { test('the operator queue has a dedicated tab listing only closed tickets', function () {
@@ -31,34 +32,62 @@ test('the "Otwarte" tab never shows closed tickets', function () {
->assertDontSee($closed->number); ->assertDontSee($closed->number);
}); });
test('the "Otwarte" tab does not offer "Zamknięte" as a status filter option', function () { test('no tab other than "Zamknięte" offers "Zamknięte" as a status filter option', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();
$operator = operatorUser('all-no-closed-filter@example.com'); $operator = operatorUser('no-closed-filter-elsewhere@example.com');
$keys = Livewire::actingAs($operator)->test(Queue::class) foreach (['all', 'mine', 'unassigned'] as $queue) {
->instance()->filterableStatuses->pluck('key')->all(); $keys = Livewire::actingAs($operator)->test(Queue::class)
->call('setQueue', $queue)
->instance()->filterableStatuses->pluck('key')->all();
expect($keys)->not->toContain('closed'); expect($keys)->not->toContain('closed');
}
}); });
test('other tabs still offer "Zamknięte" as a status filter option', function () { test('the "Zamknięte" tab offers "Zamknięte" as a status filter option', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();
$operator = operatorUser('mine-has-closed-filter@example.com'); $operator = operatorUser('closed-tab-has-closed-filter@example.com');
$keys = Livewire::actingAs($operator)->test(Queue::class) $keys = Livewire::actingAs($operator)->test(Queue::class)
->call('setQueue', 'mine') ->call('setQueue', 'closed')
->instance()->filterableStatuses->pluck('key')->all(); ->instance()->filterableStatuses->pluck('key')->all();
expect($keys)->toContain('closed'); expect($keys)->toContain('closed');
}); });
test('switching to "Otwarte" resets an active "Zamknięte" status filter, since it would always be empty there', function () { test('"Moje zgłoszenia", "Nieprzypisane" and team tabs never show closed tickets, only "Zamknięte" does', function () {
seedStatusesAndPriorities();
$team = Team::query()->create(['name' => 'Support']);
$operator = operatorUser('closed-excluded-everywhere@example.com');
$operator->teams()->attach($team->id);
$mine = makeTicket(['number' => '3001', 'status_key' => 'closed', 'assignee_id' => $operator->id]);
$unassigned = makeTicket(['number' => '3002', 'status_key' => 'closed', 'assignee_id' => null]);
$teamTicket = makeTicket(['number' => '3003', 'status_key' => 'closed', 'team_id' => $team->id]);
foreach (['mine', 'unassigned', 'team:'.$team->id] as $queue) {
Livewire::actingAs($operator)->test(Queue::class)
->call('setQueue', $queue)
->assertDontSee($mine->number)
->assertDontSee($unassigned->number)
->assertDontSee($teamTicket->number);
}
Livewire::actingAs($operator)->test(Queue::class)
->call('setQueue', 'closed')
->assertSee($mine->number)
->assertSee($unassigned->number)
->assertSee($teamTicket->number);
});
test('switching away from "Zamknięte" resets an active "Zamknięte" status filter, since it would always be empty elsewhere', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();
$operator = operatorUser('reset-filter@example.com'); $operator = operatorUser('reset-filter@example.com');
Livewire::actingAs($operator)->test(Queue::class) Livewire::actingAs($operator)->test(Queue::class)
->call('setQueue', 'closed') ->call('setQueue', 'closed')
->set('filterStatus', 'closed') ->set('filterStatus', 'closed')
->call('setQueue', 'all') ->call('setQueue', 'mine')
->assertSet('filterStatus', 'all'); ->assertSet('filterStatus', 'all');
}); });

View File

@@ -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();

View File

@@ -21,7 +21,7 @@ test('the SLA-breach check does nothing while its trigger is disabled (the defau
test('an overdue ticket with an assigned operator gets notified once the trigger is enabled, and only once', function () { 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 () {

View File

@@ -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');

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<?php <?php
use App\Livewire\Operator\TicketShow as OperatorTicketShow; use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Services\TicketService;
use Livewire\Livewire; use Livewire\Livewire;
test('opening a ticket for the first time auto-starts the timer', function () { test('opening a ticket for the first time auto-starts the timer', function () {
@@ -186,6 +187,47 @@ test('the stop-timer beacon endpoint checkpoints and stops a running timer', fun
->and($ticket->time_spent_seconds)->toBe(50); ->and($ticket->time_spent_seconds)->toBe(50);
}); });
test('opening a closed ticket does not auto-start the timer', function () {
seedStatusesAndPriorities();
$operator = operatorUser('timer-closed-open@example.com');
$ticket = makeTicket(['status_key' => 'closed', 'time_spent_seconds' => 30, 'timer_started_at' => null]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
$ticket->refresh();
expect($ticket->timer_started_at)->toBeNull()
->and($ticket->time_spent_seconds)->toBe(30);
});
test('manually resuming a closed ticket does not start the timer', function () {
seedStatusesAndPriorities();
$operator = operatorUser('timer-closed-resume@example.com');
$ticket = makeTicket(['status_key' => 'closed', 'timer_started_at' => null]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->call('resumeTimer');
expect($ticket->fresh()->timer_started_at)->toBeNull();
});
test('closing a ticket via TicketService::setStatus checkpoints and stops a running timer', function () {
seedStatusesAndPriorities();
$this->travelTo(now());
$operator = operatorUser('timer-close-via-status@example.com');
$ticket = makeTicket();
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket]);
$ticket->refresh();
$this->travel(70)->seconds();
app(TicketService::class)->setStatus($ticket, 'closed');
$ticket->refresh();
expect($ticket->timer_started_at)->toBeNull()
->and($ticket->time_spent_seconds)->toBe(70);
});
test('cancelling the timer edit leaves the tracked time untouched', function () { test('cancelling the timer edit leaves the tracked time untouched', function () {
seedStatusesAndPriorities(); seedStatusesAndPriorities();
$operator = operatorUser('timer-edit-cancel@example.com'); $operator = operatorUser('timer-edit-cancel@example.com');

View File

@@ -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]);
}
} }
} }

View File

@@ -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,9 +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,
dopóki ktoś nie wybierze nowego. „Zmiana statusu” i „zgłoszenie zamknięte” się
wzajemnie wykluczają dla tej samej zmiany — zamknięcie zgłoszenia wysyła
wyłącznie powiadomienie „zgłoszenie zamknięte”, żeby nie dublować maila.
„Nowe zgłoszenie w zespole” (domyślnie włączone) trafia do **każdego**
operatora w zespole, którego podkategorie pasują do nowego zgłoszenia, nie
tylko do jednej przypisanej osoby. **Ten sam przełącznik kontroluje zarówno
e-mail, jak i powiadomienie w dzwoneczku w aplikacji** — nie ma osobnego
ustawienia dla powiadomień w apce, a dzwoneczek pokazuje tylko nieprzeczytane
(znikają po kliknięciu/oznaczeniu).
## Wygląd / Branding ## Wygląd / Branding
@@ -107,6 +138,30 @@ ważne + treść HTML).
> `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed > `changeme-*-password`) — koniecznie podmień je na rzeczywiste dane przed
> oddaniem systemu do użytku. > oddaniem systemu do użytku.
- **Baza wiedzy BookStack** — opcjonalna integracja, **domyślnie wyłączona**.
Po włączeniu:
- **Adres instancji, Token ID, Token Secret** — token API generuje się w
BookStacku: Profil → Ustawienia API. Rola/użytkownik właściciela tokenu
musi mieć w BookStacku uprawnienie **„Access System API”**, inaczej
zapytania kończą się błędem 403 mimo poprawnych danych logowania.
- **Weryfikuj certyfikat SSL** — włączone domyślnie; wyłącz tylko jeśli
instancja BookStack korzysta z certyfikatu self-signed/prywatnego CA.
- **Przeszukuj** — strony i książki / tylko strony / tylko książki.
- **Dozwolone półki** — dwie **niezależne** checklisty: jedna dla podpowiedzi
przy tworzeniu zgłoszenia (klient, operator, formularz gościa na stronie
głównej), druga dla panelu bocznego operatora na widoku istniejącego
zgłoszenia. **Dopóki żadna półka nie jest zaznaczona w danej liście,
wyszukiwanie w tym kontekście nic nie zwraca** — trzeba świadomie
wskazać, które półki wolno przeszukiwać. Przycisk **„Odśwież listę
półek”** wymusza ponowne pobranie listy z BookStacka (inaczej może się
odświeżyć samoistnie po do 30 minutach po zmianie w BookStacku).
- **Pokazuj podpowiedzi także niezalogowanym** — domyślnie wyłączone; bez
zaznaczenia podpowiedzi przy tworzeniu zgłoszenia widzą tylko zalogowani
klienci/operatorzy, nie formularz gościa na stronie głównej.
- Przycisk **„Testuj połączenie”** sprawdza niezapisane wartości formularza
(analogicznie do LDAP/SMTP) i pokazuje dokładny komunikat błędu z
BookStacka, jeśli połączenie się nie powiedzie.
## API ## API
Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API Panel `/admin/api-docs` udostępnia interaktywną dokumentację (Swagger) REST API

View File

@@ -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
@@ -14,10 +15,15 @@ profilu w prawym górnym rogu).
mogą pojawić się dodatkowe pola (np. numer inwentarzowy sprzętu, kwota, mogą pojawić się dodatkowe pola (np. numer inwentarzowy sprzętu, kwota,
data potrzebna, zgoda przełożonego) — administrator skonfigurował je specjalnie data potrzebna, zgoda przełożonego) — administrator skonfigurował je specjalnie
dla tej podkategorii. dla tej podkategorii.
4. Opcjonalnie dodaj **załączniki** (limit rozmiaru/liczby/plików ustala admin — 4. Opcjonalnie dodaj **załączniki** — przeciągnij pliki na pole załączników albo
kliknij, żeby wybrać je z dysku (limit rozmiaru/liczby/plików ustala admin —
komunikat o błędzie poinformuje, jeśli coś przekracza limit). komunikat o błędzie poinformuje, jeśli coś przekracza limit).
5. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są 5. Jeśli administrator włączył podpowiedzi z bazy wiedzy, przy wyborze
włączone) z linkiem do podglądu. kategorii/podkategorii może pojawić się lista pasujących artykułów — warto
je sprawdzić, zanim wyślesz zgłoszenie.
6. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są
włączone) z linkiem do podglądu, oraz — jeśli jesteś zalogowany — powiadomienie
w dzwoneczku w górnym pasku.
Zgłoszenie można też wysłać **bez logowania** ze strony głównej — wystarczy podać Zgłoszenie można też wysłać **bez logowania** ze strony głównej — wystarczy podać
e-mail; konto zostanie założone automatycznie (jeśli administrator włączył e-mail; konto zostanie założone automatycznie (jeśli administrator włączył
@@ -31,19 +37,39 @@ Dashboard klienta dzieli zgłoszenia na dwie zakładki:
- **Bieżące** — zgłoszenia jeszcze nie zamknięte. - **Bieżące** — zgłoszenia jeszcze nie zamknięte.
- **Archiwum** — zgłoszenia zamknięte. - **Archiwum** — zgłoszenia zamknięte.
Pole wyszukiwania nad listą przeszukuje numer, temat i treść zgłoszenia (oraz
odpowiedzi w wątku).
Otwórz dowolne zgłoszenie, by zobaczyć: Otwórz dowolne zgłoszenie, by zobaczyć:
- aktualny **status** i **priorytet**, - aktualny **status** i **priorytet**, 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), 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
W widoku zgłoszenia można dopisać kolejną wiadomość w dowolnym momencie (np. dodać W widoku zgłoszenia można dopisać kolejną wiadomość w dowolnym momencie (np. dodać
brakujące informacje albo potwierdzić rozwiązanie) — operator zobaczy ją i, jeśli brakujące informacje albo potwierdzić rozwiązanie) — operator zobaczy ją i, jeśli
powiadomienia są włączone, dostaniesz e-mail przy każdej zmianie statusu lub nowej powiadomienia są włączone, dostaniesz e-mail przy każdej zmianie statusu lub nowej
odpowiedzi operatora. odpowiedzi operatora oraz powiadomienie w dzwoneczku w górnym pasku (kliknięcie
przenosi od razu do zgłoszenia).
## Ocena obsługi
Po zamknięciu zgłoszenia w panelu bocznym pojawia się prośba o ocenę (15 gwiazdek
+ opcjonalny komentarz) — jednorazowa, bez możliwości zmiany po wysłaniu. Mail o
zamknięciu zgłoszenia zawiera też bezpośredni link do oceny.
## Najczęstsze pytania ## Najczęstsze pytania

View File

@@ -1,36 +1,57 @@
# Przewodnik — Operator # Przewodnik — Operator
Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd, Panel operatora (`/operator`) to miejsce pracy z kolejką zgłoszeń: przegląd,
odpowiadanie, zmiana statusu/priorytetu/przypisania oraz — nowość — statystyki odpowiadanie, zmiana statusu/priorytetu/przypisania oraz statystyki zespołu.
zespołu.
Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do Domyślnie każde konto ląduje po zalogowaniu w panelu Klienta; przełącz się do
panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę. panelu Operatora przez menu profilu (prawy górny róg), jeśli konto ma tę rolę.
Dzwoneczek powiadomień w górnym pasku (widoczny we wszystkich panelach) pokazuje
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:
- **Otwarte** — wszystkie zgłoszenia jeszcze nie zamknięte, widoczne dla Ciebie. - **Otwarte** — wszystkie zgłoszenia jeszcze nie zamknięte, widoczne dla Ciebie.
- **Moje zgłoszenia** — przypisane do Ciebie. - **Moje zgłoszenia** — przypisane do Ciebie (bez zamkniętych).
- **Nieprzypisane** — czekają na przejęcie przez kogoś z zespołu. - **Nieprzypisane** — czekają na przejęcie przez kogoś z zespołu (bez zamkniętych).
- **Zamknięte** — archiwum. - **Zamknięte** — archiwum; jedyna zakładka, w której pojawiają się zamknięte
zgłoszenia.
- **Zespoły** — osobna zakładka na każdy zespół, do którego należysz (administrator - **Zespoły** — osobna zakładka na każdy zespół, do którego należysz (administrator
widzi wszystkie zespoły). widzi wszystkie zespoły; też bez zamkniętych).
Nie-administratorzy widzą tylko zgłoszenia swoich zespołów, zgłoszenia bez Nie-administratorzy widzą tylko zgłoszenia swoich zespołów, zgłoszenia bez
przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich. przypisanego zespołu, oraz wszystko przypisane bezpośrednio do nich.
**Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/ **Filtry** nad tabelą: status, priorytet, kategoria, wyszukiwanie po numerze/
temacie/kliencie. **Kolumny** można dowolnie włączać/wyłączać przyciskiem temacie/kliencie/treści zgłoszenia i odpowiedzi w wątku. **Kolumny** można dowolnie
„Kolumny”, a nagłówki kolumn sortują listę. włączać/wyłączać przyciskiem „Kolumny”, a nagłówki kolumn sortują listę.
**Zapisane widoki** — przycisk „Zapisane widoki” pozwala zapisać bieżącą
kombinację zakładki/filtrów/sortowania/kolumn pod własną nazwą, oznaczyć jeden z
zapisanych widoków jako domyślny (ładuje się automatycznie przy wejściu do
kolejki) i usuwać niepotrzebne. Widoki są prywatne — każdy operator widzi tylko
swoje.
**Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze **Akcje zbiorcze**: zaznacz kilka zgłoszeń checkboxami, by je **scalić** (pierwsze
zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje zaznaczone staje się główne, reszta trafia do niego jako wiadomości i zostaje
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
@@ -40,14 +61,25 @@ W widoku pojedynczego zgłoszenia:
jedną **szybką akcją** (np. „Wyślij i zamknij”) zamiast dwóch osobnych kroków. jedną **szybką akcją** (np. „Wyślij i zamknij”) zamiast dwóch osobnych kroków.
- **Notatka wewnętrzna** — widoczna tylko dla operatorów/adminów, np. do - **Notatka wewnętrzna** — widoczna tylko dla operatorów/adminów, np. do
przekazania kontekstu innemu operatorowi. przekazania kontekstu innemu operatorowi.
- **Załączniki** — do odpowiedzi/notatki, w granicach limitów ustawionych przez - **Załączniki** — do odpowiedzi/notatki, przeciągnij pliki na pole załączników
administratora. albo kliknij, żeby wybrać je z dysku, w granicach limitów ustawionych przez
administratora; załączone obrazy pokazują się jako miniatury w wątku.
- **Baza wiedzy** (jeśli administrator włączył integrację z BookStack) — panel
boczny podpowiada artykuły pasujące do kategorii/podkategorii zgłoszenia;
kliknięcie otwiera artykuł, przycisk „Kopiuj link" kopiuje adres bez
wychodzenia ze zgłoszenia (np. do wklejenia w odpowiedzi).
- **Ocena obsługi** — jeśli klient już ocenił zgłoszenie, ocena (gwiazdki +
ewentualny komentarz) pokazuje się w panelu bocznym, tylko do odczytu.
- **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się - **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się
automatycznie nawet przy zamknięciu karty (mechanizm `sendBeacon`). automatycznie nawet przy zamknięciu karty (mechanizm `sendBeacon`). Zliczanie
jest automatycznie wstrzymywane, gdy zgłoszenie ma status zamknięty — nie
uruchomi się przy otwarciu zamkniętego zgłoszenia ani nie będzie dalej biec
po jego zamknięciu; wcześniej naliczony czas można wciąż ręcznie skorygować.
- **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe; - **Edycja danych zgłoszenia** — temat, opis, podkategoria, pola dodatkowe;
zmiana kategorii może wysłać powiadomienie do klienta. zmiana kategorii może wysłać powiadomienie do klienta.
- **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z - **Historia** — log każdej zmiany (status, priorytet, zespół, przypisanie) z
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`)
@@ -71,16 +103,38 @@ zmianie filtra, bez przeładowania strony.
| Śr. czas 1. odpowiedzi | średni czas od utworzenia do pierwszej odpowiedzi operatora | | Śr. czas 1. odpowiedzi | średni czas od utworzenia do pierwszej odpowiedzi operatora |
| Śr. czas rozwiązania | średni czas od utworzenia do zamknięcia (przybliżony — brak osobnej daty "rozwiązano", liczony do ostatniej aktualizacji zamkniętego zgłoszenia) | | Śr. czas rozwiązania | średni czas od utworzenia do zamknięcia (przybliżony — brak osobnej daty "rozwiązano", liczony do ostatniej aktualizacji zamkniętego zgłoszenia) |
| Naruszenia SLA | % zgłoszeń, które przekroczyły czas rozwiązania wg priorytetu (zgodnie z regułami SLA w Admin > Statusy/Priorytety) | | Naruszenia SLA | % zgłoszeń, które przekroczyły czas rozwiązania wg priorytetu (zgodnie z regułami SLA w Admin > Statusy/Priorytety) |
| Ocena obsługi (CSAT) | średnia ocen klientów (15) w wybranym zakresie/filtrach + % zamkniętych zgłoszeń, które zostały ocenione |
**Wykresy** (paski poziome, kolor = ta sama identyfikacja co w kolejce dla statusu/ Przycisk **„Eksportuj CSV"** pobiera listę zgłoszeń (jeden wiersz na zgłoszenie) z
priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość): uwzględnieniem aktualnie wybranego zakresu dat i filtrów.
Reszta strony jest podzielona na sekcje:
**Rozkład zgłoszeń** (paski poziome, kolor = ta sama identyfikacja co w kolejce
dla statusu/priorytetu; najedź kursorem na pasek, by zobaczyć dokładną wartość):
- Zgłoszenia wg statusu - Zgłoszenia wg 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).