Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63178b366e | |||
| ab90abcaa3 | |||
| 0b06687ea1 | |||
| def7c70887 | |||
| 90fae0a4de | |||
| 4e8f17189a |
@@ -24,12 +24,19 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
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
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.kzbikowski.pl
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
214
ARCHITECTURE.md
214
ARCHITECTURE.md
@@ -38,14 +38,23 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
|
||||
├──< TicketHistory
|
||||
├── customer/assignee → User
|
||||
├── status → Status (fixed stages: new/open/closed)
|
||||
└── priority → Priority → SlaRule (response/resolution minutes)
|
||||
├── priority → Priority → SlaRule (response/resolution minutes)
|
||||
└── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)
|
||||
|
||||
User ─< UserFieldValue >─ UserField
|
||||
User ─< SavedQueueView (operator's own saved queue filter/sort/column presets)
|
||||
User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
|
||||
ApiClient (Sanctum token owner, ability-scoped)
|
||||
Setting (single-row-per-key config store, see below)
|
||||
ReplyQuickAction, ResponseTemplate, EmailTemplate, NotificationSetting
|
||||
```
|
||||
|
||||
`tickets.subject`/`tickets.body` and `ticket_messages.body` carry a MySQL/MariaDB
|
||||
`FULLTEXT` index (added in a later migration, MySQL-only — absent on the sqlite
|
||||
connection the test suite runs on) — `Ticket::scopeSearch()` uses
|
||||
`whereFullText()` when the active connection is `mysql` and falls back to a
|
||||
portable `LIKE` otherwise, so the same call site works in both places.
|
||||
|
||||
`Ticket` (`app/Models/Ticket.php`) is the largest model — it owns SLA math
|
||||
(`slaInfo()`, `isOverdue()`, `resolutionDeadline()`), status/priority display
|
||||
helpers (`statusLabel()`, `tagStyleFromColor()`), operator-visibility scoping
|
||||
@@ -54,13 +63,59 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
|
||||
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
|
||||
rather than spreading it across Livewire components.
|
||||
|
||||
## Ticket numbering & URLs
|
||||
|
||||
A ticket carries three distinct identifiers, each with a different job:
|
||||
|
||||
- **`id`** — the DB primary key. Never shown to users; the REST API
|
||||
(`routes/api.php`) is deliberately pinned to it (`{ticket:id}` explicit
|
||||
binding on every `{ticket}` route) so external integrations have a stable
|
||||
contract regardless of the numbering settings below.
|
||||
- **`number`** — a plain sequential string (`Ticket::nextNumber()`, max+1
|
||||
starting at 1001), unique but otherwise unremarkable. Backs `scopeSearch()`
|
||||
and the numeric sort in `Operator/Queue.php` regardless of display mode.
|
||||
- **`checksum`** — a 6-digit HMAC-derived value (salted with `app.key`,
|
||||
keyed off `id`), assigned once in a `Ticket::booted()` `created` listener
|
||||
and never changed afterward. Collisions are handled for real, not just
|
||||
assumed away: `Ticket::generateUniqueChecksum()` walks a nonce forward
|
||||
until the candidate is free (checked against the DB), and the column has a
|
||||
`unique()` constraint as a hard backstop.
|
||||
|
||||
`Ticket::displayNumber()`/`formattedNumber()` pick between `number` (zero-padded
|
||||
to `Settings::get('ticket_number_min_length')`) and `checksum` based on
|
||||
`Settings::bool('ticket_number_obfuscate')` — the "Ukryj kolejność zgłoszeń"
|
||||
toggle in Admin > Konfiguracja. `Ticket` also overrides `getRouteKey()` and
|
||||
`resolveRouteBinding()` to mirror that same choice, so **the web routes**
|
||||
(`routes/web.php`, all plain `{ticket}` implicit bindings — no explicit field)
|
||||
resolve and generate URLs against whichever column is currently the display
|
||||
number: flip the setting and both the visible number *and* every link
|
||||
(`route('client.ticket', $ticket)` etc.) switch together, and a bookmarked URL
|
||||
built under the old mode stops resolving. This is why the API routes need the
|
||||
explicit `{ticket:id}` override — without it, the same global `getRouteKey()`
|
||||
change would silently start requiring `number`/`checksum` in API path params
|
||||
too, breaking the documented `integer` "Ticket id" contract.
|
||||
|
||||
The `{numer}` placeholder available in admin-editable e-mail templates
|
||||
(Admin > Szablony e-mail / Wyzwalacze) resolves to `formattedNumber()`
|
||||
*without* `displayNumber()`'s prefix — those templates already hardcode their
|
||||
own `#{numer}`, so adding the prefix there too would double it up or clash
|
||||
with a non-default prefix.
|
||||
|
||||
## Roles & permissions
|
||||
|
||||
Roles are a plain array on the user (`$user->roles`), not a separate pivot-backed
|
||||
package — checked via `EnsureRole` at the route level. Every account gets
|
||||
`client` by default (`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned
|
||||
accounts); staff switch areas via the header role switcher, but always land on
|
||||
`/client` first after login.
|
||||
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
|
||||
it's a **virtual attribute** (`User::getAttribute()`/`setAttribute()`
|
||||
overrides) backed by a real `roles` lookup table + `role_user` pivot, not an
|
||||
actual column — assigning `'roles' => [...]` on create/update stashes the keys
|
||||
until the model's `saved` hook resolves them against `roles.key` and syncs the
|
||||
pivot. This matters for tests/seeders: a role key must exist in the `roles`
|
||||
table *before* it can be assigned this way, or the assignment silently becomes
|
||||
a no-op (`Tests\TestCase::setUp()` seeds the 3 fixed roles for exactly this
|
||||
reason, since almost every test creates a role-bearing user). Checked via
|
||||
`EnsureRole` at the route level. Every account gets `client` by default
|
||||
(`App\Ldap\Handlers\AssignDefaultRole` for LDAP-provisioned accounts); staff
|
||||
switch areas via the header role switcher, but always land on `/client` first
|
||||
after login.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -82,7 +137,8 @@ attributes.
|
||||
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
|
||||
over the `settings` table, with hardcoded defaults for every key (company name,
|
||||
LDAP/SMTP connection details, attachment limits, session lifetime, timezone,
|
||||
branding/email HTML, etc.). Admin > Konfiguracja writes to this table, and
|
||||
branding/email HTML, etc.). Admin > Konfiguracja (general/attachments/session),
|
||||
E-MAIL (SMTP) and Integracje (LDAP, BookStack) all write to this same table, and
|
||||
`AppServiceProvider::boot()` re-applies the relevant subset of it over
|
||||
`config()` on every request — meaning **`Setting` rows win over `.env`** for
|
||||
LDAP, mail, session lifetime and timezone once they're non-empty. This is by
|
||||
@@ -91,6 +147,104 @@ source of the "seeded placeholder overrides real `.env` values" gotcha
|
||||
documented in [install.md](install.md) — anything touching LDAP/mail/session/
|
||||
timezone config should go through `Settings`, not raw `config()`/`.env` reads.
|
||||
|
||||
## Notifications
|
||||
|
||||
`TicketService::notify(Ticket $ticket, string $triggerKey)` is the single
|
||||
fan-out point for every ticket lifecycle event (see the `NotificationSetting`
|
||||
rows seeded per trigger key) — it resolves the configured recipient
|
||||
(`$ticket->assignee` or `$ticket->customer`) to a real `User` when one exists
|
||||
and calls `$user->notify(new TicketNotification(...))`, which fires **both**
|
||||
the `mail` and `database` channels (`App\Notifications\TicketNotification`) —
|
||||
there's no separate on/off switch for in-app vs. e-mail, the same
|
||||
`NotificationSetting.enabled` flag gates both. A guest customer with no
|
||||
account still gets routed anonymously (`Notification::route('mail', $email)`,
|
||||
mail-only — the database channel needs a real notifiable to attach the row
|
||||
to). `TicketNotification` is constructed with an explicit `$recipientRole`
|
||||
('client'|'operator') rather than inferring it from the notifiable's roles,
|
||||
since one account can hold both — this decides whether the ticket link (both
|
||||
the e-mail body and the in-app notification's `url`) points into `/client/...`
|
||||
or `/operator/...`.
|
||||
|
||||
## Real-time broadcasting (Reverb)
|
||||
|
||||
Two private channels, authorized in `routes/channels.php`:
|
||||
|
||||
- **`operator.queue`** — one shared channel for every operator/admin (not
|
||||
scoped per team/ticket), so the receiving `Operator\Queue` component just
|
||||
re-queries through its own already-correct `Ticket::scopeVisibleToOperator()`
|
||||
on any event instead of the channel-auth callback needing to duplicate that
|
||||
ACL logic. Payloads stay minimal (ticket id + reason + actor id) for the
|
||||
same reason.
|
||||
- **`ticket.{id}`** — per-ticket channel for the message thread and detail
|
||||
changes, authorized for an operator with `isVisibleToOperator()` **or** the
|
||||
ticket's own customer (OR, not else-if — the one real account in this app
|
||||
holds both roles at once). An internal note broadcasts on the same channel
|
||||
a client can subscribe to, but the payload never carries the message body —
|
||||
each side's Livewire component only ever re-queries whatever its own
|
||||
already-authorized computed property returns, so there's nothing to leak.
|
||||
|
||||
Two events, both `App\Events\TicketQueueChanged` (broadcasts on **both**
|
||||
channels above — a status/priority/team/assignee change needs to reach a
|
||||
client watching their own ticket too) and `App\Events\TicketMessagePosted`
|
||||
(broadcasts on `ticket.{id}` only). Both implement `ShouldBroadcastNow`, not
|
||||
`ShouldBroadcast` — this app runs with no queue worker by design (see
|
||||
`TicketNotification`), so broadcasting happens synchronously within the
|
||||
request like everything else here. `TicketService` dispatches both from every
|
||||
ticket-mutating method (create/setStatus/setPriority/setAssignee/setTeam/
|
||||
operatorReply/operatorNote/clientReply/apiMessage/merge); the two ad hoc
|
||||
delete call sites (`Operator\Queue::confirmDeleteSelected()`,
|
||||
`Operator\TicketShow::confirmDeleteTicket()`) dispatch `TicketQueueChanged`
|
||||
directly since there's no `TicketService::delete()` to hook into.
|
||||
|
||||
Browser side, `resources/js/echo.js` bridges Reverb events into plain
|
||||
Livewire events (`Livewire.dispatch('queue-changed', ...)` /
|
||||
`'ticket-message-posted'`) rather than using the `#[On('echo-private:...')]`
|
||||
attribute directly — version-agnostic, and each Livewire component just
|
||||
declares a plain `#[On(...)]` listener that no-ops if the payload's `actorId`
|
||||
matches the viewer's own id (self-echo suppression) or the ticket id doesn't
|
||||
match the component's own ticket. Two easy-to-reintroduce bugs to know about
|
||||
if "nothing updates live" ever comes back:
|
||||
|
||||
1. **CSRF on `/broadcasting/auth`.** Echo's private-channel subscription
|
||||
POSTs there under the app's normal CSRF middleware; the `Echo` constructor
|
||||
must pass `auth.headers['X-CSRF-TOKEN']` (read from the `<meta
|
||||
name="csrf-token">` tag in `layouts/app.blade.php`) or every subscription
|
||||
attempt is silently rejected.
|
||||
2. **Script load order.** `resources/js/app.js` (which imports `echo.js`)
|
||||
loads via `@vite` as `type="module"`, which the HTML spec defers until
|
||||
after the document is parsed — meaning Livewire's own bootstrap script
|
||||
(`@livewireScripts`, a plain synchronous `<script>` near the end of
|
||||
`<body>`) has already run by the time `echo.js` executes. Don't gate
|
||||
anything in `echo.js` behind `document.addEventListener('livewire:init',
|
||||
...)` — that event fires as part of Livewire's own (earlier) script, so a
|
||||
listener registered this late permanently misses it. The one place this
|
||||
still matters is the per-ticket subscription triggered from a Livewire
|
||||
`@script` block in the ticket-show views, which can run before or after
|
||||
`echo.js` depending on exactly when Livewire processes it — it queues the
|
||||
ticket id onto `window.__pendingTicketChannelIds` if `echo.js` hasn't
|
||||
defined `window.subscribeToTicketChannel` yet, and `echo.js` flushes that
|
||||
queue once it has.
|
||||
|
||||
As a defense against a dropped websocket connection (backgrounded tab,
|
||||
network blip), the operator queue and both ticket-detail views also poll
|
||||
themselves every 30–60 seconds via a small Alpine countdown calling
|
||||
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is
|
||||
best-effort, not the only way these views ever update.
|
||||
|
||||
A third private channel, **`App.Models.User.{id}`** (Laravel's default
|
||||
per-notifiable convention, kept verbatim rather than a shorter alias),
|
||||
carries realtime bell delivery: `AppServiceProvider::broadcastBellNotifications()`
|
||||
listens for the framework's own `NotificationSent` event, and — only for the
|
||||
`database` channel of a `TicketNotification` — dispatches `NotificationCreated`
|
||||
on the recipient's own channel. This is a single choke point rather than
|
||||
threading a broadcast call into every `TicketService` notification call site
|
||||
(including the Trigger engine's `send_notification` action, below).
|
||||
`resources/js/echo.js` bridges it into a `bell-notification-received` Livewire
|
||||
event (refreshing `NotificationBell` instantly) and, if the viewer opted in via
|
||||
the toggle on `/settings/notifications`, also raises a native in-tab
|
||||
`Notification` popup — no service worker or push subscription, so this only
|
||||
fires while the tab is open, same limitation as the other Echo listeners here.
|
||||
|
||||
## SLA
|
||||
|
||||
`SlaRule` holds per-priority response/resolution targets in minutes. The
|
||||
@@ -100,6 +254,28 @@ tickets and can notify the assigned operator — see [install.md](install.md) fo
|
||||
why this requires an external cron entry (the Docker image ships no
|
||||
cron/supervisor of its own).
|
||||
|
||||
## SLA automation rules
|
||||
|
||||
`AutomationRule` (label, `condition_minutes`, optional `scope_priority_key`/
|
||||
`scope_subcategory_id`/`scope_team_id`, `action_type` + `action_value`) lets an
|
||||
admin configure "if a ticket has been silent for N minutes, change its
|
||||
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
|
||||
scheduled command `automation:run-rules` (also every 15 minutes) evaluates
|
||||
every enabled rule against `Ticket.last_customer_activity_at` (falling back to
|
||||
`created_at` if never set — mirrors how `resolutionDeadline()` treats a
|
||||
missing `SlaRule` as "no SLA" rather than backfilling one), and applies a
|
||||
match through the same `TicketService` setters a manual operator action would
|
||||
use, so the automated change gets the same history entry, notification, and
|
||||
broadcast for free. Idempotency is a per-(rule, ticket) row in
|
||||
`automation_rule_ticket_logs`, cleared by `TicketService` whenever the silence
|
||||
that triggered it is broken (a fresh `clientReply()`) or the ticket
|
||||
closes/reopens (`setStatus()`) — so a rule can fire again after a new period
|
||||
of silence instead of being permanently latched. Multiple matching rules on
|
||||
the same ticket in the same run all fire independently, in `id` order; a rule
|
||||
that closes the ticket doesn't block earlier-ordered rules already applied
|
||||
this run, but a later rule's own query naturally excludes an already-closed
|
||||
ticket.
|
||||
|
||||
## API
|
||||
|
||||
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
|
||||
@@ -110,3 +286,27 @@ tighter per-IP limit for unauthenticated requests
|
||||
(`AppServiceProvider::configureApiRateLimiting()`). Interactive docs are
|
||||
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
|
||||
reference in-repo.
|
||||
|
||||
## BookStack integration
|
||||
|
||||
`App\Services\BookStackClient` is the only outbound HTTP client in the
|
||||
codebase (Laravel's `Http` facade) — everything else here only ever receives
|
||||
requests. It's entirely `Settings`-driven, no `.env`/`config()` involved:
|
||||
`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/
|
||||
`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords),
|
||||
`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and
|
||||
**two independent** allow-lists of BookStack shelf IDs —
|
||||
`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and
|
||||
`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an
|
||||
existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` /
|
||||
`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list
|
||||
means "search nothing"**, not "search everything" — nothing is ever
|
||||
suggested until an admin explicitly opts shelves in, independently per
|
||||
context. BookStack has no "which shelf is this book on" field in its own
|
||||
search response, so `BookStackClient` fetches `/api/shelves` +
|
||||
`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both
|
||||
to resolve the allow-list to book IDs and to build the "Shelf > Book"
|
||||
breadcrumb shown next to each suggestion. Per-query search results are cached
|
||||
10 minutes, keyed on the query text **and** the active allow-list, so toggling
|
||||
which shelves are allowed is reflected immediately instead of serving a
|
||||
pre-change result for up to 10 minutes.
|
||||
|
||||
227
CHANGELOG.md
227
CHANGELOG.md
@@ -3,6 +3,233 @@
|
||||
All notable changes to this project are documented in this file. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [1.1.4] - 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Configurable ticket numbering** (Admin > Konfiguracja > Ogólne) — an
|
||||
admin-set prefix (default `#`) and a minimum zero-padded length for the
|
||||
ticket number.
|
||||
- **"Ukryj kolejność zgłoszeń"** — an opt-in mode that displays a stable,
|
||||
HMAC-derived checksum instead of the sequential ticket number, so the
|
||||
number shown gives no indication of ticket volume or creation order. Every
|
||||
ticket gets its checksum assigned once, on creation, guaranteed unique.
|
||||
When this mode is on, ticket URLs switch to the same checksum too (custom
|
||||
`Ticket::getRouteKey()`/`resolveRouteBinding()`), so a link and the number
|
||||
on the page it points to always match — and a URL built under the other
|
||||
mode stops resolving. The REST API is unaffected; it's pinned to `id`
|
||||
regardless of this setting. Search (queue/dashboard) now also matches
|
||||
against the checksum. A live preview against a real ticket from the
|
||||
database shows exactly how the number will look before saving.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Attachments**: dropped the inline image thumbnail preview in the message
|
||||
thread — every attachment (images included) now shows as just its
|
||||
filename, opening in a new tab on click, consistent with how non-image
|
||||
attachments already worked.
|
||||
|
||||
## [1.1.3] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
|
||||
immediately on a ticket lifecycle event (created, any field updated, status/
|
||||
priority/assignee/team/category changed, new public reply). AND-combined
|
||||
conditions gate a sequence of ordered actions (set status/priority/team/
|
||||
assignee, or send an e-mail). Ships with its own dedicated, freely
|
||||
add/edit/delete-able trigger e-mail templates — kept separate from the
|
||||
fixed, per-event system templates, which stay exactly as fixed as before.
|
||||
Complements the time-based SLA automation rules rather than replacing them,
|
||||
guarded against runaway loops (a depth limit plus a same-value no-op check).
|
||||
- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it
|
||||
regardless of assignment or team.
|
||||
- **Real-time notification bell** — the bell now updates the instant a
|
||||
notification is created (broadcast on a new private per-user channel),
|
||||
with the existing 30s poll kept as a fallback for a dropped websocket.
|
||||
Optionally also raises a native in-tab browser push notification.
|
||||
- **Per-user notification preferences** (`/settings/notifications`) — each
|
||||
operator/admin chooses, per event category (new ticket, ticket update,
|
||||
escalation), which scope of tickets (mine, unassigned, watched, all)
|
||||
notifies them via the bell and whether that also sends an e-mail, plus an
|
||||
opt-in toggle for the browser push notifications above.
|
||||
- **Admin > Integracje** — new tab hosting LDAP/AD and BookStack
|
||||
configuration, split out of Konfiguracja so that tab is just general
|
||||
system settings (attachments, session, timezone).
|
||||
- Operator queue: three more optional columns (off by default, toggle via
|
||||
"Kolumny") — Podkategoria, Zespół, Utworzono.
|
||||
|
||||
### Changed
|
||||
|
||||
- The "Obserwuj" button on the operator ticket view moved next to the
|
||||
auto-refresh countdown badge, both now grouped on the right.
|
||||
- `/settings/notifications`: added a "← Wróć" link back to the operator/admin
|
||||
area, the browser-push card now spans the full page width, and the
|
||||
preferences table sits in a bordered card like the rest of the app.
|
||||
- Trigger conditions on Podkategoria/Zgłaszający now show a name dropdown
|
||||
instead of a raw ID field.
|
||||
- The admin panel's active tab and the operator queue's active view now
|
||||
persist across a plain page refresh (bound to the URL query string), so
|
||||
reloading no longer bounces back to the first tab.
|
||||
|
||||
## [1.1.2] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Real-time updates (Laravel Reverb)** — the operator ticket queue now
|
||||
updates live: new tickets appear, status/priority/team/assignee changes
|
||||
and new replies re-sort/refresh the affected row, and closed/deleted/
|
||||
reassigned-away tickets disappear, all without a manual refresh. The
|
||||
ticket message thread is now "live chat": a reply from either side
|
||||
appears for the other party instantly. Clients also see status/priority/
|
||||
team/assignee changes and new history entries on their own ticket live.
|
||||
Backed by two private channels (`operator.queue`, `ticket.{id}`) and two
|
||||
broadcast events (`TicketQueueChanged`, `TicketMessagePosted`), sent
|
||||
synchronously (no queue worker needed, consistent with how e-mail
|
||||
notifications already work in this app).
|
||||
- **Periodic fallback refresh** — since a websocket connection can drop
|
||||
silently (backgrounded tab, network blip), the operator queue and both
|
||||
ticket-detail views also poll themselves every 30–60 seconds regardless
|
||||
of broadcasting, with a small visible countdown badge so it's clear the
|
||||
page is still refreshing on its own.
|
||||
- **SLA automation rules** (Admin > Automatyzacja SLA) — configurable rules
|
||||
that act on a ticket after N minutes of customer silence (optionally
|
||||
scoped to a priority/category/team): change priority, status, team, or
|
||||
assignee. Reuses the same `TicketService` setters a manual operator
|
||||
action would, so automated changes get the same history entry,
|
||||
notification, and (now) live broadcast as a human doing it. A new
|
||||
scheduled command (`automation:run-rules`, every 15 minutes) evaluates
|
||||
all enabled rules; each rule only fires once per ticket until a fresh
|
||||
reply or a close/reopen resets it.
|
||||
- **New notification: ticket landed in your team** — every operator on a
|
||||
team whose subcategories match a newly created ticket now gets notified
|
||||
(enabled by default; toggle like any other trigger in Admin > Szablony
|
||||
e-mail).
|
||||
- **BookStack knowledge-base sidebar on the client's own ticket view** —
|
||||
previously only shown to operators; clients now see the same
|
||||
category/subcategory-matched suggestions on their ticket page that they
|
||||
saw while creating it.
|
||||
- **Operator ticket view / client ticket view now show the assigned
|
||||
operator and team** in the "Status i priorytet" card (client side).
|
||||
- **Stats dashboard**: new breakdowns — tickets by subcategory, CSAT
|
||||
average by team and by operator, top 10 clients by ticket volume (+ one
|
||||
"Goście" bucket for guest submissions), and a client × subcategory
|
||||
cross-tab (top 10 clients × top 5 subcategories, rest folded into
|
||||
"Inne"). The whole dashboard is now organized into labeled sections
|
||||
(Podsumowanie / Rozkład zgłoszeń / Obciążenie / Klienci / Ocena obsługi /
|
||||
Trend) instead of one flat wall of cards.
|
||||
|
||||
### Changed
|
||||
|
||||
- BookStack suggestions (ticket-creation wizards, guest landing page,
|
||||
operator/client ticket views) now load in a beat after the page's first
|
||||
paint (`wire:init`) instead of blocking the initial render on BookStack's
|
||||
API response.
|
||||
- The notification bell shows **unread notifications only** — reading one
|
||||
(by clicking it or "mark all as read") now removes it from the list
|
||||
instead of just dimming it.
|
||||
- The client ticket-view page is now the same width as the operator's
|
||||
(1180px, up from 920px) — the main message-thread column is unaffected.
|
||||
- Mobile: the theme/notifications/profile-menu dropdowns in the top nav
|
||||
now expand to the full screen width below 640px instead of a fixed
|
||||
narrow width that could overflow off-screen.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Two bugs that silently disabled all real-time updates from the moment
|
||||
Reverb was first wired in: (1) the Echo client never sent a CSRF token
|
||||
when authorizing private channels, so every subscription attempt was
|
||||
rejected by the app's own CSRF middleware; (2) the Echo setup script
|
||||
(loaded as a deferred ES module) raced against Livewire's own
|
||||
synchronously-loaded bootstrap script and could miss the `livewire:init`
|
||||
event entirely, silently skipping the operator-queue subscription.
|
||||
- The `mariadb` container's healthcheck was failing (`Access denied ...
|
||||
using password: NO`) because its auto-generated credentials file had
|
||||
world-writable permissions (an artifact of this stack's NFS-backed bind
|
||||
mount) and MariaDB's client refuses to read credentials from a file
|
||||
anyone can modify — the healthcheck now passes.
|
||||
- Several pre-existing test-suite gaps found while working in this area:
|
||||
`User::roles` (a virtual attribute backed by the `roles`/`role_user`
|
||||
pivot, not a plain column) needs the `roles` table seeded before
|
||||
assigning a role by key — now seeded automatically for every test in
|
||||
`Tests\TestCase`. A handful of other tests were missing
|
||||
`NotificationSetting`/`ReplyQuickAction` seed data, asserting a stale set
|
||||
of default-enabled notification triggers, or using the wrong Notification
|
||||
Fake assertion for a real (non-guest) recipient.
|
||||
|
||||
## [1.1.0] - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- **In-app notifications** — a bell in the top bar backed by Laravel's
|
||||
database notification channel, alongside existing e-mail notifications
|
||||
(same per-trigger toggle drives both; ticket links now correctly point into
|
||||
the recipient's own area instead of always linking to the client view).
|
||||
- **Drag-and-drop attachments** on every upload form (ticket creation, replies,
|
||||
internal notes), plus inline image thumbnails in the message thread instead
|
||||
of a plain download link.
|
||||
- **Customer satisfaction (CSAT)** rating — clients rate a closed ticket 1–5
|
||||
stars with an optional comment; shown read-only to operators, surfaced as a
|
||||
KPI on the stats dashboard, and linked from the "ticket closed" e-mail.
|
||||
- **Saved queue views** — operators can save/apply/delete named filter+sort+
|
||||
column presets in the ticket queue and mark one as their default.
|
||||
- **Full-text search** — MySQL/MariaDB `FULLTEXT` search (portable `LIKE`
|
||||
fallback on sqlite) across ticket subject/body and reply message bodies, now
|
||||
also available on the client's own ticket list (previously operator-only,
|
||||
and previously subject/number/name/email only).
|
||||
- **Stats CSV export** — exports the currently filtered ticket set from the
|
||||
operator stats dashboard.
|
||||
- **BookStack knowledge-base integration**, optional and off by default —
|
||||
suggests relevant articles by category/subcategory while creating a ticket,
|
||||
and in a separate sidebar on an existing ticket for operators (with a
|
||||
copy-link button). Configurable from Admin > Konfiguracja: connection + API
|
||||
token (encrypted), optional SSL-verification bypass for self-signed
|
||||
instances, page/book search-type filter, and two independent per-shelf
|
||||
allow-lists (nothing is ever searched until specific shelves are opted in,
|
||||
separately for ticket-creation suggestions vs. the operator sidebar) with a
|
||||
manual refresh button for the shelf list.
|
||||
|
||||
### Changed
|
||||
|
||||
- Closed tickets are no longer shown in the "Moje zgłoszenia" / "Nieprzypisane"
|
||||
/ per-team queue tabs — they now only ever appear under "Zamknięte", matching
|
||||
how the "Otwarte" tab already worked.
|
||||
- The operator ticket-view sidebar is ~50% wider (to fit the BookStack
|
||||
suggestions panel); the page itself grew to match, so the ticket
|
||||
content/thread column keeps its previous width.
|
||||
- The "Resetuj" work-timer button sits below the "Zgłoszenie zamknięte —
|
||||
zliczanie wstrzymane" notice instead of beside it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `TicketService::setStatus()` now checks a status's `stage` (via
|
||||
`Status::stageFor()`) rather than the literal key `'closed'` to decide
|
||||
whether to fire the "ticket closed" notification/stop the timer — correct
|
||||
even if an admin renames or replaces which key maps to the closed stage.
|
||||
|
||||
## [1.0.2] - 2026-07-22
|
||||
|
||||
- Fixed `.gitea/workflows/build.yml`: registry login was failing with
|
||||
`unauthorized` because Gitea's auto-injected `secrets.GITHUB_TOKEN` isn't
|
||||
granted push access to its own container registry on this instance. Now
|
||||
uses a dedicated `REGISTRY_TOKEN` secret (a Gitea access token with
|
||||
`write:package`/`read:package` scope), and the workflow fails fast with a
|
||||
clear `::error::` message before attempting login if that secret isn't
|
||||
configured, instead of surfacing Docker's opaque `unauthorized` error.
|
||||
- Fixed: opening or manually resuming a **closed** ticket no longer starts its
|
||||
work timer, and closing a ticket (via the status dropdown, a reply "quick
|
||||
action" transition, the REST API, or merge) now checkpoints and stops any
|
||||
running timer. Time tracking only ever accrues while a ticket is open.
|
||||
- Fixed: the login form accepted an empty username/password, submitting them
|
||||
straight to the auth provider. The form fields are now `required` (blocks
|
||||
submission client-side) and `Login::submit()` also rejects blank/
|
||||
whitespace-only credentials server-side before attempting authentication,
|
||||
showing "Podaj nazwę użytkownika i hasło." instead.
|
||||
- Fixed: closing a ticket sent two separate notification e-mails
|
||||
(`status_changed` and `ticket_closed`) for the same event. Closing now only
|
||||
fires `ticket_closed`; every other status transition still fires
|
||||
`status_changed` as before.
|
||||
|
||||
## [1.0.1] - 2026-07-22
|
||||
|
||||
Documentation and deployment/CI overhaul — no application behavior changes.
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -47,6 +47,18 @@ with no rebuild or restart:
|
||||
`view:cache`), clear them again afterward (`config:clear`/`view:clear`) — this
|
||||
app normally runs uncached so edits apply live; leaving a cache on silently
|
||||
breaks that workflow.
|
||||
- **`sudo docker exec` runs as `root`, not `www-data`.** Apache's worker
|
||||
processes run as `www-data`; anything you run via a plain `docker exec`
|
||||
(`php artisan test`, `tinker`, `view:cache`, etc.) runs as `root`. On this
|
||||
NFS-backed mount, a file Blade compiles/caches while running as `root`
|
||||
(`storage/framework/views/*.php`) can't later be overwritten by `www-data`
|
||||
when a real request needs to recompile it (the source changed) — this
|
||||
surfaces in production as a 500 with `touch(): Utime failed: Operation not
|
||||
permitted`. If you ran `php artisan test`/`tinker`/any artisan command via
|
||||
`docker exec` in a session where you also edited Blade files afterward,
|
||||
finish with `sudo docker exec servicedesk-servicedesk-1 php artisan
|
||||
view:clear` to flush any root-owned compiled views before ending the
|
||||
session — don't wait for a report of a broken page to catch it.
|
||||
|
||||
## Apache `/icons/` alias trap
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ RUN apt-get update && apt-get install -y \
|
||||
unzip \
|
||||
git \
|
||||
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
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
106
README.md
106
README.md
@@ -13,7 +13,7 @@ The app has three areas, gated by role (a user can hold more than one at once):
|
||||
|---|---|---|---|
|
||||
| Client | `/client` | `client` | Submit tickets, track status, reply, see resolution |
|
||||
| Operator | `/operator` | `operator` | Work the ticket queue, reply/resolve, see team statistics |
|
||||
| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, branding, LDAP/SMTP |
|
||||
| Admin | `/admin` | `admin` | Configure categories, users, teams, SLA, templates, triggers, branding, LDAP/SMTP/BookStack |
|
||||
|
||||
Every account gets the `client` role by default (see `AssignDefaultRole` for LDAP-provisioned
|
||||
accounts), and always lands on `/client` first after login regardless of what other
|
||||
@@ -29,6 +29,18 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
- **SLA** — per-priority response/resolution time targets; a scheduled command
|
||||
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
|
||||
the assigned operator.
|
||||
- **SLA automation rules** (Admin > Automatyzacja SLA) — configurable rules that
|
||||
change a ticket's priority/status/team/assignee after N minutes of customer
|
||||
silence (optionally scoped to a priority/category/team), evaluated every 15
|
||||
minutes (`automation:run-rules`). Reuses the same `TicketService` setters a
|
||||
manual operator action would, so an automated change gets the same history
|
||||
entry, notification, and live broadcast as a human doing it; each rule fires
|
||||
once per ticket until a fresh customer reply or a close/reopen resets it.
|
||||
- **Real-time updates** — the operator ticket queue and both ticket-detail views
|
||||
(operator and client) update live over WebSockets (Laravel Reverb): new
|
||||
tickets, status/priority/team/assignee changes, and new replies ("live chat")
|
||||
all show up without a manual refresh. A periodic fallback refresh (with a
|
||||
visible countdown) covers a dropped websocket connection.
|
||||
- **Categories & custom fields** — admin-defined categories/subcategories, each with
|
||||
its own set of custom fields (text/textarea/select/checkbox/date/number) and an
|
||||
optional default priority.
|
||||
@@ -41,37 +53,99 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
|
||||
team changed, closed, operator replied, SLA breached), each independently
|
||||
enable/disable-able.
|
||||
- **Operator statistics** (`/operator/stats`) — filterable dashboard (date range,
|
||||
team, priority, category, assignee) with KPI tiles (volume, SLA breach rate,
|
||||
average first-response/resolution time) and breakdowns by status, priority,
|
||||
category, team and operator workload, plus a daily created-vs-closed trend.
|
||||
team, priority, category, assignee) organized into sections: KPI tiles (volume,
|
||||
SLA breach rate, average first-response/resolution time, CSAT); breakdowns by
|
||||
status, priority, category and subcategory; team/operator workload; top 10
|
||||
clients by ticket volume plus a client × subcategory cross-tab (top 5
|
||||
subcategories, rest folded into "Inne"); CSAT average by team and by operator;
|
||||
and a daily created-vs-closed trend.
|
||||
- **Branding & config** — company name/logo/favicon/accent color, login notice,
|
||||
e-mail layout/footer, LDAP connection + user sync, SMTP connection, attachment
|
||||
limits, session lifetime, timezone — all editable from Admin > Konfiguracja.
|
||||
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
|
||||
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
|
||||
sync + BookStack (Admin > Integracje).
|
||||
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`,
|
||||
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back
|
||||
to e-mail + local password when the LDAP bind doesn't match.
|
||||
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
|
||||
immediately on a ticket lifecycle event (created, any field updated, status/
|
||||
priority/assignee/team/category changed, new public reply): AND-combined
|
||||
conditions gate a sequence of actions (set status/priority/team/assignee, or
|
||||
send an e-mail using a dedicated set of freely add/edit/delete-able trigger
|
||||
e-mail templates, kept separate from the fixed per-event system templates).
|
||||
Complements the time-based SLA automation rules above rather than replacing
|
||||
them.
|
||||
- **Ticket watching** — operators can star/"Obserwuj" any ticket to follow it
|
||||
regardless of assignment/team, which feeds the "Obserwowane zgłoszenia" scope
|
||||
in their notification preferences.
|
||||
- **Per-user notification preferences** (`/settings/notifications`) — each
|
||||
operator/admin chooses, per event category (new ticket, ticket update,
|
||||
escalation), which scope of tickets (mine, unassigned, watched, all) notifies
|
||||
them via the in-app bell, and whether that also sends an e-mail; plus an
|
||||
opt-in toggle for native in-tab browser push notifications.
|
||||
- **REST API** (`/api/v1/...`, Sanctum token auth, ability-scoped: `tickets:read`,
|
||||
`tickets:write`, `dictionaries:read`, `users:read`) for tickets/messages/users/
|
||||
categories/statuses/priorities/teams — issued via admin-managed API clients.
|
||||
Interactive docs (L5-Swagger) at `/admin/api-docs`.
|
||||
- **PWA** — installable manifest + icons for the client-facing area.
|
||||
- **In-app notifications** — a bell in the top bar (client/operator/admin areas)
|
||||
backed by Laravel's database notification channel, alongside the existing
|
||||
e-mail notifications (same per-trigger enable toggle drives both); shows
|
||||
unread notifications only — reading one removes it from the list. Updates
|
||||
live over WebSockets the moment a notification is created (with a 30s
|
||||
fallback poll), and can optionally raise a native browser push notification
|
||||
while the tab is open (see per-user notification preferences above).
|
||||
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); every
|
||||
attachment shows in the message thread as just its filename, opening in a new
|
||||
tab on click (no inline image preview).
|
||||
- **Configurable ticket numbering** (Admin > Konfiguracja) — a custom prefix and
|
||||
minimum zero-padded length for the ticket number, plus an optional "hide
|
||||
ticket order" mode that displays a stable per-ticket checksum instead of the
|
||||
sequential number. When enabled, ticket URLs switch to the same checksum too,
|
||||
so the number in the link always matches the one on the page; the REST API is
|
||||
unaffected and always addresses tickets by `id`.
|
||||
- **Customer satisfaction (CSAT)** — clients rate a ticket 1–5 stars (+ optional
|
||||
comment) once it's closed; average/response-rate surfaced as a KPI on the
|
||||
operator stats dashboard, with a link in the "ticket closed" e-mail.
|
||||
- **Saved queue views** — operators can save their current filter/sort/column
|
||||
combination in the ticket queue, mark one as default, and switch between them.
|
||||
- **Full-text search** — MySQL/MariaDB `FULLTEXT` search (with a portable `LIKE`
|
||||
fallback) across ticket subject/body and reply message bodies, available in
|
||||
both the operator queue and the client's own ticket list.
|
||||
- **Stats export** — the operator stats dashboard can export the currently
|
||||
filtered ticket set as CSV.
|
||||
- **BookStack knowledge-base integration** *(optional, off by default)* —
|
||||
suggests relevant BookStack articles by category/subcategory while a ticket
|
||||
is being created, and in a separate sidebar panel on an existing ticket for
|
||||
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 > Integracje: connection + API token, optional SSL-verification
|
||||
bypass for self-signed instances, page/book search-type filter, and two
|
||||
independent per-shelf allow-lists (nothing is searched until an admin opts
|
||||
specific shelves in, separately for ticket-creation suggestions vs. the
|
||||
operator/client ticket-view sidebar).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
|
||||
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
|
||||
API docs.
|
||||
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
|
||||
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)).
|
||||
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
|
||||
v4 via Vite for `resources/css/app.css`. No JS charting library — the statistics
|
||||
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
|
||||
(`resources/js/echo.js`) for Reverb. No JS charting library — the statistics
|
||||
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
|
||||
build step beyond the CSS bundle.
|
||||
- **Database**: MariaDB.
|
||||
- **Deployment**: `compose.yaml` — `servicedesk` (source bind-mounted from `./src`,
|
||||
no image rebuild needed for PHP/Blade/route changes) + `mariadb`, fronted by
|
||||
Traefik with a private-CA TLS cert. The `servicedesk` image itself is built and
|
||||
pushed by Gitea Actions (`.gitea/workflows/build.yml`) to the Gitea container
|
||||
registry whenever `Dockerfile` changes — `compose.yaml` just pulls a tag, it
|
||||
never builds locally.
|
||||
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
|
||||
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA
|
||||
TLS cert (the websocket path is routed to `reverb` by a higher-priority
|
||||
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image
|
||||
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
|
||||
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
|
||||
just pulls a tag, it never builds locally.
|
||||
|
||||
See **[install.md](install.md)** for full step-by-step deployment instructions —
|
||||
both via Docker Compose (this stack) and directly on a server with Apache/Nginx,
|
||||
@@ -104,14 +178,18 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
|
||||
src/ Laravel application
|
||||
app/Livewire/ Client/Operator/Admin Livewire components
|
||||
app/Models/ Eloquent models
|
||||
app/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
|
||||
database/migrations/ Schema (one file per table group, final shape)
|
||||
database/seeders/ DatabaseSeeder — reference data, no ticket data
|
||||
resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
|
||||
resources/js/echo.js Laravel Echo/Reverb client + broadcast → Livewire event bridge
|
||||
resources/views/ Blade templates
|
||||
routes/web.php Client/Operator/Admin routes (role-gated)
|
||||
routes/api.php REST API (Sanctum, ability-gated)
|
||||
routes/channels.php Broadcasting channel authorization (operator.queue, ticket.{id})
|
||||
wiki/
|
||||
client/ How-to guide for the Client role
|
||||
operator/ How-to guide for the Operator role
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -45,6 +45,16 @@ credential-equivalent.
|
||||
- **TLS**: production traffic terminates at Traefik with a private CA
|
||||
certificate (not publicly trusted) — this is expected for this deployment, not
|
||||
a misconfiguration.
|
||||
- **BookStack integration** (`App\Services\BookStackClient`, optional, off by
|
||||
default): the only outbound HTTP client in the codebase. The target
|
||||
`bookstack_base_url` and the SSL-verification bypass (`bookstack_verify_ssl`)
|
||||
are both admin-configurable — restrict Admin-role accounts accordingly, same
|
||||
reasoning as the LDAP/SMTP settings override above (a compromised admin
|
||||
account could point it at an arbitrary host, or disable TLS verification
|
||||
against one). The API token secret is stored encrypted (same as the LDAP
|
||||
bind/SMTP passwords). Nothing is ever searched/suggested until an admin
|
||||
explicitly allow-lists specific BookStack shelves — the default (no shelves
|
||||
allowed) returns no results without making any outbound request.
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
123
install.md
123
install.md
@@ -74,7 +74,7 @@ APP_LOCALE=pl
|
||||
APP_FALLBACK_LOCALE=pl
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
|
||||
VERSION=1.0.0 # rezerwa na przyszłość, jeszcze nigdzie nie wyświetlane
|
||||
VERSION=1.1.3 # widoczne w Admin > O aplikacji
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
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:
|
||||
`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
|
||||
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
|
||||
Gitea), zanim spróbujesz `docker compose pull` na serwerze.
|
||||
|
||||
### 1.3b. Real-time (Laravel Reverb)
|
||||
|
||||
Realtime (kolejka operatora, czat na żywo) wymaga trzeciej usługi w
|
||||
`compose.yaml`, `reverb` — tego samego obrazu `servicedesk`, tylko z innym
|
||||
`command: php artisan reverb:start --host=0.0.0.0 --port=8080`. Obraz musi
|
||||
mieć rozszerzenia PHP `pcntl`/`posix` (potrzebne serwerowi Reverb do obsługi
|
||||
sygnałów) — jeśli `Dockerfile` ich nie instaluje, `reverb` będzie się zapętlać
|
||||
w restartach z błędem `Undefined constant "...SIGINT"`; dodaj `pcntl posix` do
|
||||
listy w `docker-php-ext-install` i poczekaj na przebudowanie obrazu przez CI.
|
||||
|
||||
Traefik musi kierować ścieżkę websocketu (`/app*`) do `reverb`, a resztę do
|
||||
`servicedesk` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
|
||||
|
||||
```yaml
|
||||
reverb:
|
||||
image: gitea.kzbikowski.pl/kzbkowski/servicedesk:${IMAGE_TAG:-latest}
|
||||
command: php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||
volumes:
|
||||
- ./src:/var/www/html
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal
|
||||
- traefik_public
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik_public
|
||||
- traefik.http.routers.reverb.rule=Host(`${HOSTNAME}`) && PathPrefix(`/app`)
|
||||
- traefik.http.routers.reverb.priority=1000
|
||||
- traefik.http.routers.reverb.entrypoints=websecure
|
||||
- traefik.http.routers.reverb.tls=true
|
||||
- traefik.http.services.reverb.loadbalancer.server.port=8080
|
||||
```
|
||||
|
||||
`priority=1000` jest ważne — Traefik domyślnie liczy priorytet reguły na
|
||||
podstawie długości jej zapisu, co może dać samemu `Host(...)` z `servicedesk`
|
||||
wyższy priorytet niż oczekiwano, przez co ścieżkowa reguła `reverb` przegrywa i
|
||||
nic nie działa mimo poprawnej konfiguracji.
|
||||
|
||||
W `src/.env` — `BROADCAST_CONNECTION=reverb` plus:
|
||||
|
||||
```env
|
||||
REVERB_APP_ID=wygeneruj-losowy-id
|
||||
REVERB_APP_KEY=wygeneruj-losowy-klucz
|
||||
REVERB_APP_SECRET=wygeneruj-losowy-sekret
|
||||
REVERB_HOST=reverb # nazwa usługi compose — ruch serwer-serwer po sieci internal
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST=servicedesk.twoja-domena.pl # publiczna domena — to, z czym łączy się przeglądarka
|
||||
VITE_REVERB_PORT=443
|
||||
VITE_REVERB_SCHEME=https
|
||||
```
|
||||
|
||||
`REVERB_HOST` (serwer→serwer, wewnętrzna sieć Docker) i `VITE_REVERB_HOST`
|
||||
(przeglądarka→Traefik, publiczna domena) to celowo dwie różne wartości —
|
||||
pomylenie ich to najczęstszy błąd przy pierwszym wdrożeniu tej funkcji.
|
||||
|
||||
Po zmianie zmiennych `VITE_REVERB_*` trzeba przebudować front-end (krok 1.5) —
|
||||
te wartości są wypiekane w zbudowany bundle JS, nie czytane w runtime.
|
||||
|
||||
### 1.4. Instalacja aplikacji wewnątrz kontenera
|
||||
|
||||
```bash
|
||||
@@ -219,6 +298,16 @@ użytku:
|
||||
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz
|
||||
polegać na logowaniu przez katalog.
|
||||
|
||||
### Integracje opcjonalne (BookStack)
|
||||
|
||||
Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w
|
||||
panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w
|
||||
`.env` — całość konfiguruje się w **Admin > Konfiguracja**: adres instancji,
|
||||
Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku
|
||||
uprawnienie „Access System API”), oraz osobne listy dozwolonych półek dla
|
||||
podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna
|
||||
półka nie jest zaznaczona, wyszukiwanie nic nie zwraca.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wdrożenie bezpośrednio na serwerze (Apache/Nginx, bez Dockera)
|
||||
@@ -260,7 +349,7 @@ APP_LOCALE=pl
|
||||
APP_FALLBACK_LOCALE=pl
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
|
||||
VERSION=1.0.0
|
||||
VERSION=1.1.3
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
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`
|
||||
jako bezpieczny domyślny driver na przyszłość.
|
||||
|
||||
Realtime (patrz 1.3b) potrzebuje tu **długo działającego procesu**
|
||||
`php artisan reverb:start` — PHP musi mieć rozszerzenia `pcntl`/`posix`
|
||||
(standardowo dostępne, ale sprawdź `php -m`). Najprościej pod systemd:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/servicedesk-reverb.service
|
||||
[Unit]
|
||||
Description=Servicedesk Reverb websocket server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/servicedesk/src
|
||||
ExecStart=/usr/bin/php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
systemctl enable --now servicedesk-reverb
|
||||
```
|
||||
|
||||
Ustaw też te same zmienne `.env` co w 1.3b (`BROADCAST_CONNECTION=reverb`,
|
||||
`REVERB_*`, `VITE_REVERB_*` — tu `REVERB_HOST` to po prostu `127.0.0.1`, nie
|
||||
nazwa usługi compose) i skieruj serwer WWW tak, by ścieżka `/app*` trafiała do
|
||||
portu 8080 zamiast do PHP-FPM/Apache (osobny `location`/`VirtualHost` dla tej
|
||||
jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
|
||||
|
||||
### 2.7. Pierwsze logowanie i dalsza konfiguracja
|
||||
|
||||
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
|
||||
VERSION=1.0.1
|
||||
VERSION=1.1.4
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -40,6 +40,24 @@ BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
# Reverb (real-time broadcasting) — set BROADCAST_CONNECTION=reverb to enable.
|
||||
# REVERB_HOST/PORT/SCHEME are server-to-server (this container calling the
|
||||
# "reverb" compose service over the internal docker network) — REVERB_HOST
|
||||
# should be the compose service name ("reverb"), not a public hostname.
|
||||
# VITE_REVERB_* is what gets baked into the built frontend bundle and is what
|
||||
# the browser connects to — same public hostname/TLS as the rest of the app.
|
||||
REVERB_APP_ID=wygeneruj-losowy-id
|
||||
REVERB_APP_KEY=wygeneruj-losowy-klucz
|
||||
REVERB_APP_SECRET=wygeneruj-losowy-sekret
|
||||
REVERB_HOST=reverb
|
||||
REVERB_PORT=8080
|
||||
REVERB_SCHEME=http
|
||||
|
||||
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||
VITE_REVERB_HOST=servicedesk.twoja-domena.pl
|
||||
VITE_REVERB_PORT=443
|
||||
VITE_REVERB_SCHEME=https
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
|
||||
80
src/app/Console/Commands/RunAutomationRules.php
Normal file
80
src/app/Console/Commands/RunAutomationRules.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\AutomationRuleTicketLog;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RunAutomationRules extends Command
|
||||
{
|
||||
protected $signature = 'automation:run-rules';
|
||||
|
||||
protected $description = 'Apply enabled SLA automation rules to tickets that have been silent past their condition threshold';
|
||||
|
||||
public function handle(TicketService $tickets): int
|
||||
{
|
||||
$rules = AutomationRule::query()->where('enabled', true)->orderBy('id')->get();
|
||||
$fired = 0;
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$query = Ticket::query()->whereNotIn('status_key', Status::closedKeys());
|
||||
|
||||
if ($rule->scope_priority_key) {
|
||||
$query->where('priority_key', $rule->scope_priority_key);
|
||||
}
|
||||
if ($rule->scope_subcategory_id) {
|
||||
$query->where('subcategory_id', $rule->scope_subcategory_id);
|
||||
}
|
||||
if ($rule->scope_team_id) {
|
||||
$query->where('team_id', $rule->scope_team_id);
|
||||
}
|
||||
|
||||
$candidates = $query->get()->filter(function (Ticket $ticket) use ($rule) {
|
||||
if ($rule->hasFiredFor($ticket)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$since = $ticket->last_customer_activity_at ?? $ticket->created_at;
|
||||
|
||||
// Carbon 3 defaults diffInMinutes() to a signed result (negative
|
||||
// when $since is in the past) rather than always-absolute — be
|
||||
// explicit, same trap as Ticket::secondsSinceTimerStarted().
|
||||
return now()->diffInMinutes($since, absolute: true) >= $rule->condition_minutes;
|
||||
});
|
||||
|
||||
foreach ($candidates as $ticket) {
|
||||
$this->apply($tickets, $rule, $ticket);
|
||||
$fired++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Ran automation rules: {$fired} action(s) applied.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function apply(TicketService $tickets, AutomationRule $rule, Ticket $ticket): void
|
||||
{
|
||||
match ($rule->action_type) {
|
||||
'change_priority' => $tickets->setPriority($ticket, $rule->action_value),
|
||||
'change_status' => $tickets->setStatus($ticket, $rule->action_value),
|
||||
'change_team' => $tickets->setTeam($ticket, Team::query()->find($rule->action_value)),
|
||||
'change_assignee' => $tickets->setAssignee($ticket, User::query()->find($rule->action_value)),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$ticket->addHistory("Automatyzacja: {$rule->label}");
|
||||
|
||||
AutomationRuleTicketLog::query()->create([
|
||||
'automation_rule_id' => $rule->id,
|
||||
'ticket_id' => $ticket->id,
|
||||
'triggered_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
src/app/Events/NotificationCreated.php
Normal file
47
src/app/Events/NotificationCreated.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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 once per database (bell) notification actually created for a real
|
||||
* user — see the NotificationSent listener in AppServiceProvider::boot(),
|
||||
* which is the single choke point that dispatches this regardless of which
|
||||
* of the several TicketService call sites created the underlying
|
||||
* notification. Drives both the realtime bell badge (NotificationBell) and,
|
||||
* when the viewing browser has granted permission, an in-tab
|
||||
* `Notification` API popup.
|
||||
*/
|
||||
class NotificationCreated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public string $notificationId,
|
||||
public string $message,
|
||||
public string $url,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('App.Models.User.'.$this->userId)];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'notificationId' => $this->notificationId,
|
||||
'message' => $this->message,
|
||||
'url' => $this->url,
|
||||
];
|
||||
}
|
||||
}
|
||||
52
src/app/Events/TicketMessagePosted.php
Normal file
52
src/app/Events/TicketMessagePosted.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Fired on ticket.{id} whenever a new message (public reply, internal note,
|
||||
* or API message) is posted — drives the live message thread ("live chat")
|
||||
* for both operator and client views of the same ticket.
|
||||
*
|
||||
* The payload deliberately never carries the message body, only metadata —
|
||||
* both public and internal messages broadcast on the same per-ticket
|
||||
* channel, and a client subscriber must never be able to read an internal
|
||||
* note's content from the socket frame itself. Each side's Livewire
|
||||
* component only ever re-queries whatever its own already-authorized
|
||||
* computed property returns (the client's never touches internalMessages()
|
||||
* regardless of which event arrived), so this is safe by construction.
|
||||
*/
|
||||
class TicketMessagePosted implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(
|
||||
public int $ticketId,
|
||||
public int $messageId,
|
||||
public bool $internal,
|
||||
public ?int $actorId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('ticket.'.$this->ticketId)];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'ticketId' => $this->ticketId,
|
||||
'messageId' => $this->messageId,
|
||||
'internal' => $this->internal,
|
||||
'actorId' => $this->actorId,
|
||||
];
|
||||
}
|
||||
}
|
||||
56
src/app/Events/TicketQueueChanged.php
Normal file
56
src/app/Events/TicketQueueChanged.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
/**
|
||||
* Fired on operator.queue whenever a ticket is created, changes in a way
|
||||
* that affects the operator queue table (status/priority/team/assignee, or
|
||||
* a new message bumping its updated_at, which the queue sorts by), or is
|
||||
* deleted. Payload stays minimal — the receiving Queue component just
|
||||
* re-queries through its own already-correct visibleToOperator() scope
|
||||
* rather than this event needing to encode per-viewer visibility itself.
|
||||
*
|
||||
* Also broadcast on the ticket's own channel, so a client (who can't
|
||||
* subscribe to operator.queue at all — see routes/channels.php) still sees
|
||||
* status/priority/team/assignee changes live while viewing that one ticket.
|
||||
*
|
||||
* ShouldBroadcastNow (not ShouldBroadcast) — this app runs with no queue
|
||||
* worker by design (see TicketNotification), so broadcasts happen
|
||||
* synchronously within the request like everything else here.
|
||||
*/
|
||||
class TicketQueueChanged implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets;
|
||||
|
||||
public function __construct(
|
||||
public int $ticketId,
|
||||
public string $reason,
|
||||
public ?int $actorId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('operator.queue'),
|
||||
new PrivateChannel('ticket.'.$this->ticketId),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'ticketId' => $this->ticketId,
|
||||
'reason' => $this->reason,
|
||||
'actorId' => $this->actorId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\EmailTemplate;
|
||||
@@ -13,16 +14,20 @@ use App\Models\SlaRule;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\LdapUserProvisioner;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use LdapRecord\Connection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
@@ -30,6 +35,7 @@ class Panel extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
#[Url]
|
||||
public string $tab = 'categories';
|
||||
|
||||
// ---- categories ----
|
||||
@@ -98,6 +104,15 @@ class Panel extends Component
|
||||
|
||||
public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => ''];
|
||||
|
||||
// ---- automation rules ----
|
||||
public bool $automationRuleFormOpen = false;
|
||||
|
||||
public array $automationRuleForm = [
|
||||
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
|
||||
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
|
||||
'action_type' => 'change_priority', 'action_value' => '',
|
||||
];
|
||||
|
||||
// ---- templates ----
|
||||
public ?int $editingTemplateId = null;
|
||||
|
||||
@@ -137,6 +152,12 @@ class Panel extends Component
|
||||
|
||||
public ?string $mailTestResult = null;
|
||||
|
||||
public array $bookstackConfig = [];
|
||||
|
||||
public ?string $bookstackTestResult = null;
|
||||
|
||||
public ?string $bookstackTestMessage = null;
|
||||
|
||||
// ---- generic pending-delete confirm ----
|
||||
public ?string $pendingDeleteType = null;
|
||||
|
||||
@@ -161,6 +182,9 @@ class Panel extends Component
|
||||
'attachmentAllowedTypes' => Settings::get('attachment_allowed_types'),
|
||||
'sessionLifetimeMinutes' => Settings::get('session_lifetime_minutes'),
|
||||
'timezone' => Settings::timezone(),
|
||||
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
|
||||
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
|
||||
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
|
||||
];
|
||||
|
||||
$this->ldapConfig = [
|
||||
@@ -187,6 +211,18 @@ class Panel extends Component
|
||||
'fromAddress' => Settings::get('mail_from_address'),
|
||||
'fromName' => Settings::get('mail_from_name'),
|
||||
];
|
||||
|
||||
$this->bookstackConfig = [
|
||||
'enabled' => Settings::bool('bookstack_enabled'),
|
||||
'baseUrl' => Settings::get('bookstack_base_url'),
|
||||
'tokenId' => Settings::get('bookstack_token_id'),
|
||||
'tokenSecret' => Settings::get('bookstack_token_secret'),
|
||||
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
|
||||
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
|
||||
'searchTypes' => Settings::get('bookstack_search_types', 'both'),
|
||||
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
|
||||
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
@@ -834,6 +870,96 @@ class Panel extends Component
|
||||
$this->requestDelete('response-template', $id, 'Szablon odpowiedzi zostanie usunięty z listy dostępnej operatorom.');
|
||||
}
|
||||
|
||||
// ===================== AUTOMATION RULES =====================
|
||||
|
||||
#[Computed]
|
||||
public function automationRules()
|
||||
{
|
||||
return AutomationRule::query()->orderBy('id')->get();
|
||||
}
|
||||
|
||||
public function openAutomationRuleForm(): void
|
||||
{
|
||||
$this->automationRuleForm = [
|
||||
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
|
||||
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
|
||||
'action_type' => 'change_priority', 'action_value' => '',
|
||||
];
|
||||
$this->automationRuleFormOpen = true;
|
||||
}
|
||||
|
||||
public function editAutomationRule(int $id): void
|
||||
{
|
||||
$rule = AutomationRule::query()->findOrFail($id);
|
||||
|
||||
$this->automationRuleForm = [
|
||||
'id' => $rule->id,
|
||||
'label' => $rule->label,
|
||||
'enabled' => $rule->enabled,
|
||||
'condition_minutes' => $rule->condition_minutes,
|
||||
'scope_priority_key' => $rule->scope_priority_key ?? '',
|
||||
'scope_subcategory_id' => $rule->scope_subcategory_id ?? '',
|
||||
'scope_team_id' => $rule->scope_team_id ?? '',
|
||||
'action_type' => $rule->action_type,
|
||||
'action_value' => $rule->action_value,
|
||||
];
|
||||
$this->automationRuleFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeAutomationRuleForm(): void
|
||||
{
|
||||
$this->automationRuleFormOpen = false;
|
||||
}
|
||||
|
||||
// Switching action_type invalidates whichever action_value was picked for
|
||||
// the previous type (e.g. a priority key isn't a valid team id).
|
||||
public function updatedAutomationRuleFormActionType(): void
|
||||
{
|
||||
$this->automationRuleForm['action_value'] = '';
|
||||
}
|
||||
|
||||
public function submitAutomationRule(): void
|
||||
{
|
||||
$this->validate([
|
||||
'automationRuleForm.label' => 'required|string|max:255',
|
||||
'automationRuleForm.condition_minutes' => 'required|integer|min:1',
|
||||
'automationRuleForm.action_type' => 'required|in:'.implode(',', AutomationRule::ACTION_TYPES),
|
||||
'automationRuleForm.action_value' => 'required|string',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'label' => $this->automationRuleForm['label'],
|
||||
'enabled' => (bool) $this->automationRuleForm['enabled'],
|
||||
'condition_minutes' => (int) $this->automationRuleForm['condition_minutes'],
|
||||
'scope_priority_key' => $this->automationRuleForm['scope_priority_key'] ?: null,
|
||||
'scope_subcategory_id' => $this->automationRuleForm['scope_subcategory_id'] ?: null,
|
||||
'scope_team_id' => $this->automationRuleForm['scope_team_id'] ?: null,
|
||||
'action_type' => $this->automationRuleForm['action_type'],
|
||||
'action_value' => $this->automationRuleForm['action_value'],
|
||||
];
|
||||
|
||||
if ($this->automationRuleForm['id']) {
|
||||
AutomationRule::query()->find($this->automationRuleForm['id'])?->update($data);
|
||||
} else {
|
||||
AutomationRule::query()->create($data);
|
||||
}
|
||||
|
||||
$this->automationRuleFormOpen = false;
|
||||
unset($this->automationRules);
|
||||
}
|
||||
|
||||
public function toggleAutomationRuleEnabled(int $id): void
|
||||
{
|
||||
$rule = AutomationRule::query()->find($id);
|
||||
$rule?->update(['enabled' => ! $rule->enabled]);
|
||||
unset($this->automationRules);
|
||||
}
|
||||
|
||||
public function removeAutomationRule(int $id): void
|
||||
{
|
||||
$this->requestDelete('automation-rule', $id, 'Reguła automatyzacji zostanie usunięta.');
|
||||
}
|
||||
|
||||
// ===================== STATUSES / PRIORITIES =====================
|
||||
|
||||
#[Computed]
|
||||
@@ -965,7 +1091,7 @@ class Panel extends Component
|
||||
* key-primary-keyed, sort_order-ordered lists edited the same way: swap
|
||||
* this row's sort_order with its immediate neighbor in the given direction.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection<int, Status|Priority> $ordered
|
||||
* @param Collection<int, Status|Priority> $ordered
|
||||
*/
|
||||
protected function swapAdjacentSortOrder($ordered, string $key, int $direction): void
|
||||
{
|
||||
@@ -1235,6 +1361,33 @@ class Panel extends Component
|
||||
if (in_array($this->systemConfig['timezone'], \DateTimeZone::listIdentifiers(), true)) {
|
||||
Settings::set('timezone', $this->systemConfig['timezone']);
|
||||
}
|
||||
|
||||
Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
|
||||
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
|
||||
Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview for the "Numeracja zgłoszeń" settings — renders a real
|
||||
* ticket's id/number against the form's current (not-yet-saved) values,
|
||||
* so the admin sees exactly how numbers will look before hitting Zapisz.
|
||||
*/
|
||||
#[Computed]
|
||||
public function ticketNumberPreview(): array
|
||||
{
|
||||
$ticket = Ticket::query()->latest('id')->first();
|
||||
$id = $ticket->id ?? 1;
|
||||
$raw = $ticket->number ?? '1001';
|
||||
$checksum = $ticket->checksum ?? Ticket::generateUniqueChecksum($id);
|
||||
$obfuscate = (bool) ($this->systemConfig['ticketNumberObfuscate'] ?? false);
|
||||
$minLength = max(1, (int) ($this->systemConfig['ticketNumberMinLength'] ?? 4));
|
||||
|
||||
$number = $obfuscate ? $checksum : str_pad($raw, $minLength, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'formatted' => trim((string) ($this->systemConfig['ticketNumberPrefix'] ?? '')).$number,
|
||||
];
|
||||
}
|
||||
|
||||
// ===================== LDAP CONFIG =====================
|
||||
@@ -1288,6 +1441,90 @@ class Panel extends Component
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== BOOKSTACK CONFIG =====================
|
||||
|
||||
public function saveBookstackConfig(): void
|
||||
{
|
||||
Settings::set('bookstack_enabled', $this->bookstackConfig['enabled'] ? '1' : '0');
|
||||
Settings::set('bookstack_base_url', $this->bookstackConfig['baseUrl']);
|
||||
Settings::set('bookstack_token_id', $this->bookstackConfig['tokenId']);
|
||||
|
||||
if ($this->bookstackConfig['tokenSecret']) {
|
||||
Settings::set('bookstack_token_secret', $this->bookstackConfig['tokenSecret']);
|
||||
}
|
||||
|
||||
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
|
||||
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0');
|
||||
|
||||
if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) {
|
||||
Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']);
|
||||
}
|
||||
|
||||
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
|
||||
Settings::set('bookstack_allowed_shelf_ids_ticket_view', implode(',', $this->bookstackConfig['allowedShelfIdsTicketView']));
|
||||
|
||||
$this->bookstackTestResult = null;
|
||||
$this->bookstackTestMessage = null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function bookstackShelves(): array
|
||||
{
|
||||
return app(BookStackClient::class)->shelves();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached shelf list (and its dependent shelf>book map) so a
|
||||
* shelf added/renamed/removed in BookStack shows up in both checklists
|
||||
* right away, then re-fetches — shared by both "Dozwolone półki"
|
||||
* checklists since they list the exact same shelves.
|
||||
*/
|
||||
public function refreshBookstackShelves(): void
|
||||
{
|
||||
app(BookStackClient::class)->clearShelfCache();
|
||||
unset($this->bookstackShelves);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
protected function parseShelfIds(string $raw): array
|
||||
{
|
||||
return collect(explode(',', $raw))->map(fn ($v) => (int) trim($v))->filter()->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* $field is 'allowedShelfIdsCreation' or 'allowedShelfIdsTicketView' —
|
||||
* the two independent allow-lists (ticket-creation suggestions vs. the
|
||||
* operator ticket-view sidebar) toggled by their own checklist.
|
||||
*/
|
||||
public function toggleBookstackAllowedShelf(string $field, int $id): void
|
||||
{
|
||||
$ids = $this->bookstackConfig[$field];
|
||||
|
||||
$this->bookstackConfig[$field] = in_array($id, $ids, true)
|
||||
? array_values(array_diff($ids, [$id]))
|
||||
: [...$ids, $id];
|
||||
}
|
||||
|
||||
public function testBookstackConnection(): void
|
||||
{
|
||||
$cfg = $this->bookstackConfig;
|
||||
|
||||
if (empty($cfg['baseUrl']) || empty($cfg['tokenId'])) {
|
||||
$this->bookstackTestResult = 'error';
|
||||
$this->bookstackTestMessage = 'Uzupełnij adres instancji i Token ID.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenSecret = $cfg['tokenSecret'] ?: Settings::get('bookstack_token_secret');
|
||||
$result = app(BookStackClient::class)->testConnection($cfg['baseUrl'], $cfg['tokenId'], $tokenSecret ?? '', (bool) $cfg['verifySsl']);
|
||||
|
||||
$this->bookstackTestResult = $result['ok'] ? 'ok' : 'error';
|
||||
$this->bookstackTestMessage = $result['message'];
|
||||
}
|
||||
|
||||
// ===================== MAIL / SMTP CONFIG =====================
|
||||
|
||||
public function saveMailConfig(): void
|
||||
@@ -1387,10 +1624,11 @@ class Panel extends Component
|
||||
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
|
||||
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
|
||||
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
|
||||
'automation-rule' => AutomationRule::query()->find($this->pendingDeleteId)?->delete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings);
|
||||
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings, $this->automationRules);
|
||||
|
||||
$this->cancelPendingDelete();
|
||||
}
|
||||
|
||||
342
src/app/Livewire/Admin/Triggers.php
Normal file
342
src/app/Livewire/Admin/Triggers.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Triggers extends Component
|
||||
{
|
||||
public bool $formOpen = false;
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public array $form = [
|
||||
'name' => '',
|
||||
'enabled' => true,
|
||||
'event' => 'ticket_created',
|
||||
'conditions' => [],
|
||||
'actions' => [],
|
||||
];
|
||||
|
||||
public bool $templateFormOpen = false;
|
||||
|
||||
public ?int $editingTemplateId = null;
|
||||
|
||||
public array $templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
|
||||
public static function eventLabels(): array
|
||||
{
|
||||
return [
|
||||
'ticket_created' => 'Zgłoszenie utworzone',
|
||||
'ticket_updated' => 'Zgłoszenie zaktualizowane (dowolne pole)',
|
||||
'status_changed' => 'Zmiana statusu',
|
||||
'priority_changed' => 'Zmiana priorytetu',
|
||||
'assignee_changed' => 'Zmiana przypisanego operatora',
|
||||
'team_changed' => 'Zmiana zespołu',
|
||||
'category_changed' => 'Zmiana kategorii',
|
||||
'comment_added' => 'Nowa wiadomość (publiczna)',
|
||||
];
|
||||
}
|
||||
|
||||
public static function fieldLabels(): array
|
||||
{
|
||||
return [
|
||||
'status_key' => 'Status',
|
||||
'priority_key' => 'Priorytet',
|
||||
'team_id' => 'Zespół',
|
||||
'subcategory_id' => 'Podkategoria',
|
||||
'assignee_id' => 'Operator przypisany',
|
||||
'customer_id' => 'Zgłaszający',
|
||||
'subject' => 'Temat',
|
||||
'body' => 'Treść',
|
||||
];
|
||||
}
|
||||
|
||||
public static function operatorLabels(): array
|
||||
{
|
||||
return [
|
||||
'equals' => 'jest równe',
|
||||
'not_equals' => 'jest różne od',
|
||||
'is_empty' => 'jest puste',
|
||||
'is_not_empty' => 'nie jest puste',
|
||||
'contains' => 'zawiera',
|
||||
];
|
||||
}
|
||||
|
||||
public static function actionTypeLabels(): array
|
||||
{
|
||||
return [
|
||||
'set_status' => 'Ustaw status',
|
||||
'set_priority' => 'Ustaw priorytet',
|
||||
'set_team' => 'Ustaw zespół',
|
||||
'set_assignee' => 'Ustaw operatora',
|
||||
'send_notification' => 'Wyślij powiadomienie e-mail',
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function triggers(): Collection
|
||||
{
|
||||
return Trigger::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function statuses(): Collection
|
||||
{
|
||||
return Status::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities(): Collection
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function teams(): Collection
|
||||
{
|
||||
return Team::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function operators(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function subcategories(): Collection
|
||||
{
|
||||
return Subcategory::query()->with('category')->get()
|
||||
->sortBy(fn (Subcategory $s) => $s->category->name.' / '.$s->name, SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function customers(): Collection
|
||||
{
|
||||
return User::query()->whereHas('roleAssignments', fn ($q) => $q->where('key', 'client'))->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function emailTemplates(): Collection
|
||||
{
|
||||
return TriggerEmailTemplate::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function openForm(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->form = ['name' => '', 'enabled' => true, 'event' => 'ticket_created', 'conditions' => [], 'actions' => []];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function editTrigger(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
|
||||
$this->editingId = $trigger->id;
|
||||
$this->form = [
|
||||
'name' => $trigger->name,
|
||||
'enabled' => $trigger->enabled,
|
||||
'event' => $trigger->event,
|
||||
'conditions' => $trigger->conditions,
|
||||
'actions' => $trigger->actions,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function closeForm(): void
|
||||
{
|
||||
$this->formOpen = false;
|
||||
}
|
||||
|
||||
public function addCondition(): void
|
||||
{
|
||||
$this->form['conditions'][] = ['field' => Trigger::CONDITION_FIELDS[0], 'operator' => 'equals', 'value' => ''];
|
||||
}
|
||||
|
||||
public function removeCondition(int $index): void
|
||||
{
|
||||
unset($this->form['conditions'][$index]);
|
||||
$this->form['conditions'] = array_values($this->form['conditions']);
|
||||
}
|
||||
|
||||
public function addAction(): void
|
||||
{
|
||||
$this->form['actions'][] = ['type' => Trigger::ACTION_TYPES[0], 'value' => '', 'recipient' => 'client', 'email_template_id' => ''];
|
||||
}
|
||||
|
||||
public function removeAction(int $index): void
|
||||
{
|
||||
unset($this->form['actions'][$index]);
|
||||
$this->form['actions'] = array_values($this->form['actions']);
|
||||
}
|
||||
|
||||
public function moveActionUp(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index - 1);
|
||||
}
|
||||
|
||||
public function moveActionDown(int $index): void
|
||||
{
|
||||
$this->swapFormActions($index, $index + 1);
|
||||
}
|
||||
|
||||
protected function swapFormActions(int $a, int $b): void
|
||||
{
|
||||
if ($b < 0 || $b >= count($this->form['actions'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$this->form['actions'][$a], $this->form['actions'][$b]] = [$this->form['actions'][$b], $this->form['actions'][$a]];
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => ['required', 'string', 'max:255'],
|
||||
'form.event' => ['required', 'string', 'in:'.implode(',', Trigger::EVENTS)],
|
||||
'form.conditions' => ['array'],
|
||||
'form.conditions.*.field' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_FIELDS)],
|
||||
'form.conditions.*.operator' => ['required', 'string', 'in:'.implode(',', Trigger::CONDITION_OPERATORS)],
|
||||
'form.actions' => ['required', 'array', 'min:1'],
|
||||
'form.actions.*.type' => ['required', 'string', 'in:'.implode(',', Trigger::ACTION_TYPES)],
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $this->form['name'],
|
||||
'enabled' => (bool) $this->form['enabled'],
|
||||
'event' => $this->form['event'],
|
||||
'conditions' => array_values($this->form['conditions']),
|
||||
'actions' => array_values($this->form['actions']),
|
||||
];
|
||||
|
||||
if ($this->editingId) {
|
||||
Trigger::query()->findOrFail($this->editingId)->update($data);
|
||||
} else {
|
||||
$data['sort_order'] = (Trigger::query()->max('sort_order') ?? 0) + 1;
|
||||
Trigger::query()->create($data);
|
||||
}
|
||||
|
||||
$this->formOpen = false;
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function toggleEnabled(int $id): void
|
||||
{
|
||||
$trigger = Trigger::query()->findOrFail($id);
|
||||
$trigger->update(['enabled' => ! $trigger->enabled]);
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function removeTrigger(int $id): void
|
||||
{
|
||||
Trigger::query()->findOrFail($id)->delete();
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
public function moveUp(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, -1);
|
||||
}
|
||||
|
||||
public function moveDown(int $id): void
|
||||
{
|
||||
$this->swapAdjacentSortOrder($id, 1);
|
||||
}
|
||||
|
||||
protected function swapAdjacentSortOrder(int $id, int $direction): void
|
||||
{
|
||||
$ordered = $this->triggers;
|
||||
$index = $ordered->search(fn ($row) => $row->id === $id);
|
||||
$swapIndex = $index + $direction;
|
||||
|
||||
if ($index === false || $swapIndex < 0 || $swapIndex >= $ordered->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $ordered[$index];
|
||||
$neighbor = $ordered[$swapIndex];
|
||||
|
||||
[$rowOrder, $neighborOrder] = [$row->sort_order, $neighbor->sort_order];
|
||||
$row->update(['sort_order' => $neighborOrder]);
|
||||
$neighbor->update(['sort_order' => $rowOrder]);
|
||||
|
||||
unset($this->triggers);
|
||||
}
|
||||
|
||||
// ===================== TEMPLATES (wyzwalaczy) =====================
|
||||
|
||||
public function openTemplateForm(): void
|
||||
{
|
||||
$this->editingTemplateId = null;
|
||||
$this->templateForm = ['name' => '', 'subject' => '', 'body' => ''];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function editTemplate(int $id): void
|
||||
{
|
||||
$template = TriggerEmailTemplate::query()->findOrFail($id);
|
||||
|
||||
$this->editingTemplateId = $template->id;
|
||||
$this->templateForm = [
|
||||
'name' => $template->name,
|
||||
'subject' => $template->subject,
|
||||
'body' => $template->body,
|
||||
];
|
||||
$this->resetErrorBag();
|
||||
$this->templateFormOpen = true;
|
||||
}
|
||||
|
||||
public function closeTemplateForm(): void
|
||||
{
|
||||
$this->templateFormOpen = false;
|
||||
}
|
||||
|
||||
public function setTemplateBodyDraft(string $value): void
|
||||
{
|
||||
$this->templateForm['body'] = $value;
|
||||
}
|
||||
|
||||
public function submitTemplate(): void
|
||||
{
|
||||
$this->validate([
|
||||
'templateForm.name' => ['required', 'string', 'max:255'],
|
||||
'templateForm.subject' => ['required', 'string', 'max:255'],
|
||||
'templateForm.body' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($this->editingTemplateId) {
|
||||
TriggerEmailTemplate::query()->findOrFail($this->editingTemplateId)->update($this->templateForm);
|
||||
} else {
|
||||
TriggerEmailTemplate::query()->create($this->templateForm);
|
||||
}
|
||||
|
||||
$this->templateFormOpen = false;
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function removeTemplate(int $id): void
|
||||
{
|
||||
TriggerEmailTemplate::query()->findOrFail($id)->delete();
|
||||
unset($this->emailTemplates);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.triggers');
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ class Login extends Component
|
||||
{
|
||||
$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();
|
||||
|
||||
// Local accounts (created with a password from the admin panel) don't
|
||||
|
||||
@@ -11,10 +11,13 @@ class Dashboard extends Component
|
||||
{
|
||||
public string $tab = 'current';
|
||||
|
||||
public string $search = '';
|
||||
|
||||
#[Computed]
|
||||
public function tickets()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()
|
||||
->search($this->search)
|
||||
->with('subcategory.category')
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -29,6 +30,16 @@ class NewTicket extends Component
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
@@ -60,6 +71,21 @@ class NewTicket extends Component
|
||||
$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
|
||||
{
|
||||
$this->step = 1;
|
||||
|
||||
@@ -4,10 +4,12 @@ namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
@@ -27,6 +29,20 @@ class TicketShow extends Component
|
||||
|
||||
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
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketMessagePosted broadcast on this ticket's own
|
||||
* channel (see resources/js/echo.js) — an operator's reply appears
|
||||
* without the client refreshing, the "live chat" effect. Ignores events
|
||||
* for any other ticket id since Livewire dispatches are page-wide.
|
||||
*/
|
||||
#[On('ticket-message-posted')]
|
||||
public function onTicketMessagePosted(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->ticketMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketQueueChanged broadcast on this ticket's own
|
||||
* channel — lets the client see a status/priority/team/assignee change
|
||||
* (and the resulting history entry) made by an operator, or by an
|
||||
* automation rule firing in the background, without refreshing.
|
||||
*/
|
||||
#[On('queue-changed')]
|
||||
public function onQueueChanged(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic fallback refresh (see the countdown badge in the blade view)
|
||||
* — broadcasting is best-effort, a dropped websocket connection
|
||||
* shouldn't mean the thread/ticket silently stops updating.
|
||||
*/
|
||||
public function refreshTicketData(): void
|
||||
{
|
||||
unset($this->ticketMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function otherTickets()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Same allow-listed shelves (CONTEXT_CREATION) and category/subcategory
|
||||
* query the ticket-creation wizard used, so the client sees the same
|
||||
* suggestions here as they did while writing the ticket.
|
||||
*
|
||||
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||
*/
|
||||
#[Computed]
|
||||
public function suggestedArticles(): array
|
||||
{
|
||||
if (! $this->suggestedArticlesLoaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subcategory = $this->ticket->subcategory;
|
||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
@@ -86,6 +166,14 @@ class TicketShow extends Component
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function submitCsat(): void
|
||||
{
|
||||
$this->validate(['csatRating' => 'required|integer|between:1,5']);
|
||||
|
||||
app(TicketService::class)->submitCsat($this->ticket, $this->csatRating, $this->csatComment ?: null);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function startEdit(int $messageId, string $body): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($messageId);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\LdapUserProvisioner;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
@@ -35,6 +36,16 @@ class Landing extends Component
|
||||
|
||||
public ?int $submittedTicketId = null;
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
@@ -72,6 +83,27 @@ class Landing extends Component
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike the logged-in Client/Operator wizards, this guest-facing form
|
||||
* only shows suggestions when the admin has explicitly opted into
|
||||
* exposing them to anonymous visitors (bookstack_show_to_guests) —
|
||||
* suggested KB article titles/links could otherwise leak internal
|
||||
* content to the public.
|
||||
*
|
||||
* @return array<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
|
||||
{
|
||||
$this->step = 1;
|
||||
|
||||
58
src/app/Livewire/NotificationBell.php
Normal file
58
src/app/Livewire/NotificationBell.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationBell extends Component
|
||||
{
|
||||
/**
|
||||
* Fired by echo.js the moment a NotificationCreated broadcast arrives on
|
||||
* this user's private channel — refreshes the badge/list instantly
|
||||
* instead of waiting for the next 30s poll, which stays in place below
|
||||
* as a fallback for dropped websocket connections.
|
||||
*/
|
||||
#[On('bell-notification-received')]
|
||||
public function onBellNotification(): void
|
||||
{
|
||||
unset($this->notifications, $this->unreadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Operator;
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\User;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -32,6 +33,16 @@ class NewTicket extends Component
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function clients()
|
||||
{
|
||||
@@ -69,6 +80,21 @@ class NewTicket extends Component
|
||||
$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
|
||||
{
|
||||
$this->step = 1;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Events\TicketQueueChanged;
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
@@ -11,11 +12,13 @@ use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
class Queue extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $queue = 'all';
|
||||
|
||||
public string $filterStatus = 'all';
|
||||
@@ -41,6 +44,152 @@ class Queue extends Component
|
||||
|
||||
public bool $pendingDeleteSelected = false;
|
||||
|
||||
#[Url]
|
||||
public ?int $savedViewId = null;
|
||||
|
||||
public string $newViewName = '';
|
||||
|
||||
/**
|
||||
* A bare visit (no explicit ?savedViewId=... in the URL, i.e. Livewire
|
||||
* never bound one) auto-applies the operator's default saved view, if
|
||||
* they have one — an explicit savedViewId in the URL always wins.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
if ($this->savedViewId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$default = Auth::user()->savedQueueViews()->where('is_default', true)->first();
|
||||
|
||||
if ($default) {
|
||||
$this->applyViewFilters($default->filters);
|
||||
$this->savedViewId = $default->id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
public function statuses()
|
||||
{
|
||||
@@ -55,9 +204,9 @@ class Queue extends Component
|
||||
#[Computed]
|
||||
public function filterableStatuses()
|
||||
{
|
||||
return $this->queue === 'all'
|
||||
? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed')
|
||||
: $this->statuses;
|
||||
return $this->queue === 'closed'
|
||||
? $this->statuses
|
||||
: $this->statuses->reject(fn (Status $s) => $s->stage === 'closed');
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
@@ -101,15 +250,15 @@ class Queue extends Component
|
||||
|
||||
$defs = [
|
||||
'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)],
|
||||
'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())],
|
||||
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')],
|
||||
'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())->whereNotIn('status_key', $closedKeys)],
|
||||
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')->whereNotIn('status_key', $closedKeys)],
|
||||
'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)],
|
||||
];
|
||||
|
||||
foreach ($this->teams as $team) {
|
||||
$defs['team:'.$team->id] = [
|
||||
'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły',
|
||||
'filter' => fn ($q) => $q->where('team_id', $team->id),
|
||||
'filter' => fn ($q) => $q->where('team_id', $team->id)->whereNotIn('status_key', $closedKeys),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -155,14 +304,10 @@ class Queue extends Component
|
||||
$query->where('customer_id', $this->filterCustomerId);
|
||||
}
|
||||
if (trim($this->search) !== '') {
|
||||
$term = '%'.trim($this->search).'%';
|
||||
$query->where(fn ($q) => $q->where('number', 'like', $term)
|
||||
->orWhere('subject', 'like', $term)
|
||||
->orWhere('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term));
|
||||
$query->search($this->search);
|
||||
}
|
||||
|
||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
|
||||
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status', 'team'])->get();
|
||||
|
||||
return $this->sortTickets($tickets);
|
||||
}
|
||||
@@ -186,6 +331,9 @@ class Queue extends Component
|
||||
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
||||
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'team' => $tickets->sortBy(fn (Ticket $t) => $t->team?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
||||
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
|
||||
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
||||
};
|
||||
|
||||
@@ -202,10 +350,13 @@ class Queue extends Component
|
||||
'subject' => 'Temat',
|
||||
'customer' => 'Klient',
|
||||
'category' => 'Kategoria',
|
||||
'subcategory' => 'Podkategoria',
|
||||
'priority' => 'Priorytet',
|
||||
'status' => 'Status',
|
||||
'sla' => 'SLA',
|
||||
'assignee' => 'Przypisany',
|
||||
'team' => 'Zespół',
|
||||
'created' => 'Utworzono',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -216,7 +367,7 @@ class Queue extends Component
|
||||
*/
|
||||
public function sortableColumns(): array
|
||||
{
|
||||
return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee'];
|
||||
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created'];
|
||||
}
|
||||
|
||||
public function sortByColumn(string $column): void
|
||||
@@ -251,7 +402,11 @@ class Queue extends Component
|
||||
$this->queue = $key;
|
||||
$this->selectedIds = [];
|
||||
|
||||
if ($key === 'all' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
|
||||
// Every tab except "closed" now excludes closed-stage tickets (see
|
||||
// queueDefs()), so a stale closed-stage status filter would silently
|
||||
// zero out the list on any other tab — clear it on every tab switch
|
||||
// away from "closed", not just when landing on "all".
|
||||
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
|
||||
$this->filterStatus = 'all';
|
||||
}
|
||||
}
|
||||
@@ -298,7 +453,13 @@ class Queue extends Component
|
||||
|
||||
public function confirmDeleteSelected(): void
|
||||
{
|
||||
Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete();
|
||||
$ids = Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id');
|
||||
Ticket::query()->whereIn('id', $ids)->delete();
|
||||
|
||||
foreach ($ids as $id) {
|
||||
TicketQueueChanged::dispatch($id, 'deleted', Auth::id());
|
||||
}
|
||||
|
||||
$this->selectedIds = [];
|
||||
$this->pendingDeleteSelected = false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class Stats extends Component
|
||||
{
|
||||
@@ -152,6 +153,23 @@ class Stats extends Component
|
||||
'avgFirstResponseHours' => $this->avgFirstResponseHours(),
|
||||
'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
|
||||
'sla' => $this->slaBreachStats($closedKeys),
|
||||
'csat' => $this->csatStats($closedKeys),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response rate is against closed tickets (the only ones that can ever
|
||||
* be rated — see Ticket::csatSubmittable()), not the whole filtered set.
|
||||
*/
|
||||
protected function csatStats(array $closedKeys): array
|
||||
{
|
||||
$closedTotal = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count();
|
||||
$rated = (clone $this->baseQuery)->whereNotNull('csat_rating')->get(['csat_rating']);
|
||||
|
||||
return [
|
||||
'avg' => $rated->isEmpty() ? null : round($rated->avg('csat_rating'), 2),
|
||||
'count' => $rated->count(),
|
||||
'responseRate' => $closedTotal > 0 ? round($rated->count() / $closedTotal * 100, 1) : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -294,6 +312,25 @@ class Stats extends Component
|
||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One level deeper than byCategory() — same shape, but grouped by the
|
||||
* actual subcategory, labeled "Category / Subcategory" to disambiguate
|
||||
* subcategories that share a name across different parent categories.
|
||||
*/
|
||||
#[Computed]
|
||||
public function bySubcategory()
|
||||
{
|
||||
return (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
|
||||
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byTeam()
|
||||
{
|
||||
@@ -333,6 +370,170 @@ class Stats extends Component
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike teams/operators (small, fixed sets), the customer list is
|
||||
* unbounded — capped to the top 10 by ticket volume in the current
|
||||
* filtered range rather than listing every client who ever wrote in.
|
||||
* Guest submissions (no account) are summed into one "Goście" bucket
|
||||
* rather than grouped by e-mail, since a guest has no stable identity
|
||||
* to rank against registered clients.
|
||||
*/
|
||||
#[Computed]
|
||||
public function byCustomer()
|
||||
{
|
||||
$rows = (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.customer_id')
|
||||
->join('users', 'users.id', '=', 'tickets.customer_id')
|
||||
->select('users.id', 'users.name as label', DB::raw('count(*) as count'))
|
||||
->groupBy('users.id', 'users.name')
|
||||
->orderByDesc('count')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||
|
||||
$guestCount = (clone $this->baseQuery)->whereNull('tickets.customer_id')->count();
|
||||
|
||||
if ($guestCount > 0) {
|
||||
$rows->push(['label' => 'Goście (bez konta)', 'count' => $guestCount]);
|
||||
}
|
||||
|
||||
return $rows->sortByDesc('count')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Client × subcategory cross-tab — which clients' tickets fall into
|
||||
* which kind of subcategory. Both dimensions are unbounded (unlike
|
||||
* teams/operators), so this caps to the top 10 clients by overall
|
||||
* volume (rows, mirroring byCustomer()) and the top 5 subcategories by
|
||||
* overall volume (columns, mirroring assigneeSubcategoryMatrix()'s
|
||||
* "Inne" folding) — otherwise the table could grow arbitrarily in both
|
||||
* directions. Guest tickets (no customer_id) are excluded entirely
|
||||
* rather than folded into one "guest" row, since mixing a real client's
|
||||
* per-subcategory pattern with an anonymous aggregate wouldn't mean
|
||||
* anything.
|
||||
*
|
||||
* @return array{columns: array<int, string>, hasOther: bool, rows: array<int, array{label: string, cells: array<int, int>, other: ?int, total: int}>}
|
||||
*/
|
||||
#[Computed]
|
||||
public function customerSubcategoryMatrix(): array
|
||||
{
|
||||
$raw = (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.customer_id')
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||
->join('users', 'users.id', '=', 'tickets.customer_id')
|
||||
->select(
|
||||
'tickets.customer_id',
|
||||
'users.name as customer_name',
|
||||
'subcategories.id as subcategory_id',
|
||||
'categories.name as category_name',
|
||||
'subcategories.name as subcategory_name',
|
||||
DB::raw('count(*) as total'),
|
||||
)
|
||||
->groupBy('tickets.customer_id', 'users.name', 'subcategories.id', 'categories.name', 'subcategories.name')
|
||||
->get();
|
||||
|
||||
if ($raw->isEmpty()) {
|
||||
return ['columns' => [], 'hasOther' => false, 'rows' => []];
|
||||
}
|
||||
|
||||
$subcategoryTotals = $raw->groupBy('subcategory_id')->map(fn ($g) => $g->sum('total'));
|
||||
$topSubcategoryIds = $subcategoryTotals->sortDesc()->keys()->take(5);
|
||||
|
||||
$subcategoryLabels = $raw->unique('subcategory_id')->keyBy('subcategory_id')
|
||||
->map(fn ($r) => $r->category_name.' / '.$r->subcategory_name);
|
||||
|
||||
$columns = $topSubcategoryIds->map(fn ($id) => $subcategoryLabels[$id])->values()->all();
|
||||
$hasOther = $subcategoryTotals->keys()->diff($topSubcategoryIds)->isNotEmpty();
|
||||
|
||||
$byCustomer = $raw->groupBy('customer_id');
|
||||
$customerNames = $raw->unique('customer_id')->keyBy('customer_id')->map(fn ($r) => $r->customer_name);
|
||||
$topCustomerIds = $byCustomer->map(fn ($g) => $g->sum('total'))->sortDesc()->keys()->take(10);
|
||||
|
||||
$rows = $topCustomerIds
|
||||
->map(function ($customerId) use ($byCustomer, $customerNames, $topSubcategoryIds, $hasOther) {
|
||||
$entries = $byCustomer->get($customerId, collect());
|
||||
$bySubcategory = $entries->keyBy('subcategory_id');
|
||||
|
||||
return [
|
||||
'label' => $customerNames[$customerId],
|
||||
'cells' => $topSubcategoryIds->map(fn ($id) => (int) ($bySubcategory[$id]->total ?? 0))->values()->all(),
|
||||
'other' => $hasOther ? (int) $entries->whereNotIn('subcategory_id', $topSubcategoryIds->all())->sum('total') : null,
|
||||
'total' => (int) $entries->sum('total'),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return ['columns' => $columns, 'hasOther' => $hasOther, 'rows' => $rows];
|
||||
}
|
||||
|
||||
/**
|
||||
* Average CSAT rating per team, only among rated tickets in the current
|
||||
* filtered range — mirrors byTeam()'s "Bez zespołu" bucket handling, but
|
||||
* teams/buckets with zero ratings are dropped entirely (an average of
|
||||
* nothing isn't a meaningful bar to draw).
|
||||
*/
|
||||
#[Computed]
|
||||
public function csatByTeam()
|
||||
{
|
||||
$stats = (clone $this->baseQuery)
|
||||
->whereNotNull('csat_rating')
|
||||
->select('team_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
|
||||
->groupBy('team_id')
|
||||
->get();
|
||||
|
||||
$avgs = $stats->pluck('avg_rating', 'team_id');
|
||||
$counts = $stats->pluck('rated_count', 'team_id');
|
||||
|
||||
$rows = $this->teams
|
||||
->map(fn (Team $t) => [
|
||||
'label' => $t->name,
|
||||
'avg' => isset($avgs[$t->id]) ? round((float) $avgs[$t->id], 2) : null,
|
||||
'count' => (int) ($counts[$t->id] ?? 0),
|
||||
])
|
||||
->filter(fn ($row) => $row['count'] > 0)
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Bez zespołu', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows->sortByDesc('avg')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Average CSAT rating per assignee, same shape/semantics as csatByTeam().
|
||||
*/
|
||||
#[Computed]
|
||||
public function csatByAssignee()
|
||||
{
|
||||
$stats = (clone $this->baseQuery)
|
||||
->whereNotNull('csat_rating')
|
||||
->select('assignee_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
|
||||
->groupBy('assignee_id')
|
||||
->get();
|
||||
|
||||
$avgs = $stats->pluck('avg_rating', 'assignee_id');
|
||||
$counts = $stats->pluck('rated_count', 'assignee_id');
|
||||
|
||||
$rows = $this->operators
|
||||
->map(fn (User $u) => [
|
||||
'label' => $u->name,
|
||||
'avg' => isset($avgs[$u->id]) ? round((float) $avgs[$u->id], 2) : null,
|
||||
'count' => (int) ($counts[$u->id] ?? 0),
|
||||
])
|
||||
->filter(fn ($row) => $row['count'] > 0)
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Nieprzypisane', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows->sortByDesc('avg')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Daily created-vs-closed volume, capped at the most recent 60 days so a
|
||||
* wide range (or "Cały okres") never renders an unreadably thin column
|
||||
@@ -387,6 +588,40 @@ class Stats extends Component
|
||||
$this->range = $range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-per-ticket CSV of everything the active filters/date range
|
||||
* currently show — streamed directly, no temp file, no new dependency.
|
||||
*/
|
||||
public function export(): StreamedResponse
|
||||
{
|
||||
$tickets = (clone $this->baseQuery)
|
||||
->with(['subcategory.category', 'assignee', 'team', 'status', 'priority'])
|
||||
->orderBy('tickets.created_at')
|
||||
->get();
|
||||
|
||||
return response()->streamDownload(function () use ($tickets) {
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, ['Numer', 'Temat', 'Status', 'Priorytet', 'Kategoria', 'Zespół', 'Operator', 'Utworzono', 'Zaktualizowano', 'Ocena CSAT'], escape: '\\');
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
fputcsv($out, [
|
||||
$ticket->displayNumber(),
|
||||
$ticket->subject,
|
||||
$ticket->statusLabel(),
|
||||
$ticket->priorityLabel(),
|
||||
$ticket->categoryLabel(),
|
||||
$ticket->team?->name,
|
||||
$ticket->assignee?->name,
|
||||
$ticket->created_at,
|
||||
$ticket->updated_at,
|
||||
$ticket->csat_rating,
|
||||
], escape: '\\');
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
}, 'statystyki-'.now()->format('Y-m-d').'.csv');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.operator.stats');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Events\TicketQueueChanged;
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\ReplyQuickAction;
|
||||
@@ -12,10 +13,12 @@ use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
@@ -65,6 +68,16 @@ class TicketShow extends Component
|
||||
|
||||
public string $editTimerSeconds = '0';
|
||||
|
||||
// Set via wire:init (see the blade view) rather than on the initial
|
||||
// render, so the BookStack HTTP call in suggestedArticles() never
|
||||
// delays the ticket page's first paint — it loads in a beat later instead.
|
||||
public bool $suggestedArticlesLoaded = false;
|
||||
|
||||
public function loadSuggestedArticles(): void
|
||||
{
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
||||
@@ -78,6 +91,18 @@ class TicketShow extends Component
|
||||
$this->ticket->resumeTimer();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isWatching(): bool
|
||||
{
|
||||
return $this->ticket->isWatchedBy(Auth::user());
|
||||
}
|
||||
|
||||
public function toggleWatch(): void
|
||||
{
|
||||
app(TicketService::class)->toggleWatch($this->ticket, Auth::user());
|
||||
unset($this->isWatching);
|
||||
}
|
||||
|
||||
// -------- time tracking --------
|
||||
|
||||
public function stopTimer(): void
|
||||
@@ -167,6 +192,50 @@ class TicketShow extends Component
|
||||
return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketMessagePosted broadcast on this ticket's own
|
||||
* channel (see resources/js/echo.js) — a new reply/note from the other
|
||||
* party appears without the viewer refreshing, the "live chat" effect.
|
||||
* Ignores events for any other ticket id, since Livewire dispatches are
|
||||
* page-wide and this component only cares about its own ticket.
|
||||
*/
|
||||
#[On('ticket-message-posted')]
|
||||
public function onTicketMessagePosted(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
|
||||
* and Queue::onQueueChanged()) — lets a status/priority/team/assignee
|
||||
* change made by another operator, or by an automation rule firing in
|
||||
* the background, show up live on a ticket someone currently has open.
|
||||
*/
|
||||
#[On('queue-changed')]
|
||||
public function onQueueChanged(int $ticketId): void
|
||||
{
|
||||
if ($ticketId !== $this->ticket->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic fallback refresh (see the countdown badge in the blade view)
|
||||
* — broadcasting is best-effort, a dropped websocket connection
|
||||
* shouldn't mean the thread/ticket silently stops updating.
|
||||
*/
|
||||
public function refreshTicketData(): void
|
||||
{
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function statuses()
|
||||
{
|
||||
@@ -185,6 +254,22 @@ class TicketShow extends Component
|
||||
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
|
||||
* teams (mirrors the visibility scoping in Operator\Queue).
|
||||
@@ -487,7 +572,9 @@ class TicketShow extends Component
|
||||
|
||||
public function confirmDeleteTicket(): void
|
||||
{
|
||||
$ticketId = $this->ticket->id;
|
||||
$this->ticket->delete();
|
||||
TicketQueueChanged::dispatch($ticketId, 'deleted', Auth::id());
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
}
|
||||
|
||||
|
||||
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
44
src/app/Livewire/Settings/NotificationPreferences.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class NotificationPreferences extends Component
|
||||
{
|
||||
protected const SCOPE_FIELDS = ['scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(Auth::user()->isOperator() || Auth::user()->isAdmin(), 403);
|
||||
}
|
||||
|
||||
public function rows(): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return collect(NotificationPreference::CATEGORIES)
|
||||
->mapWithKeys(fn (string $category) => [$category => NotificationPreference::rowFor($user, $category)])
|
||||
->all();
|
||||
}
|
||||
|
||||
public function toggle(string $category, string $field): void
|
||||
{
|
||||
abort_unless(in_array($category, NotificationPreference::CATEGORIES, true), 404);
|
||||
abort_unless(in_array($field, self::SCOPE_FIELDS, true), 404);
|
||||
|
||||
$preference = NotificationPreference::query()->firstOrCreate(
|
||||
['user_id' => Auth::id(), 'event_category' => $category],
|
||||
array_merge(['user_id' => Auth::id(), 'event_category' => $category], NotificationPreference::DEFAULTS[$category])
|
||||
);
|
||||
|
||||
$preference->update([$field => ! $preference->$field]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]);
|
||||
}
|
||||
}
|
||||
35
src/app/Models/AutomationRule.php
Normal file
35
src/app/Models/AutomationRule.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'label', 'enabled', 'condition_minutes',
|
||||
'scope_priority_key', 'scope_subcategory_id', 'scope_team_id',
|
||||
'action_type', 'action_value',
|
||||
])]
|
||||
class AutomationRule extends Model
|
||||
{
|
||||
public const ACTION_TYPES = ['change_priority', 'change_status', 'change_team', 'change_assignee'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'condition_minutes' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutomationRuleTicketLog::class);
|
||||
}
|
||||
|
||||
public function hasFiredFor(Ticket $ticket): bool
|
||||
{
|
||||
return $this->logs()->where('ticket_id', $ticket->id)->exists();
|
||||
}
|
||||
}
|
||||
28
src/app/Models/AutomationRuleTicketLog.php
Normal file
28
src/app/Models/AutomationRuleTicketLog.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['automation_rule_id', 'ticket_id', 'triggered_at'])]
|
||||
class AutomationRuleTicketLog extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['triggered_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function rule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AutomationRule::class, 'automation_rule_id');
|
||||
}
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
}
|
||||
68
src/app/Models/NotificationPreference.php
Normal file
68
src/app/Models/NotificationPreference.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'event_category', 'scope_mine', 'scope_unassigned', 'scope_watched', 'scope_all', 'email'])]
|
||||
class NotificationPreference extends Model
|
||||
{
|
||||
public const CATEGORIES = ['new_ticket', 'ticket_update', 'escalation'];
|
||||
|
||||
/**
|
||||
* Applied whenever a user has never touched a given row — including
|
||||
* every user created after this feature ships (new hires, LDAP JIT
|
||||
* provisioning). new_ticket defaults to exactly what
|
||||
* TicketService::notifyOperatorsForNewTicket() used to do
|
||||
* unconditionally (notify every relevant operator, by mail and bell),
|
||||
* so shipping this feature doesn't silently change what the existing
|
||||
* admin account already receives. ticket_update/escalation have no
|
||||
* current staff-facing equivalent, so any default there is purely
|
||||
* additive rather than a behavior change.
|
||||
*/
|
||||
public const DEFAULTS = [
|
||||
'new_ticket' => ['scope_mine' => false, 'scope_unassigned' => false, 'scope_watched' => false, 'scope_all' => true, 'email' => true],
|
||||
'ticket_update' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => false],
|
||||
'escalation' => ['scope_mine' => true, 'scope_unassigned' => false, 'scope_watched' => true, 'scope_all' => false, 'email' => true],
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scope_mine' => 'boolean',
|
||||
'scope_unassigned' => 'boolean',
|
||||
'scope_watched' => 'boolean',
|
||||
'scope_all' => 'boolean',
|
||||
'email' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns a usable row — the persisted one if the user has ever
|
||||
* toggled this category, DEFAULTS[$category] otherwise — so callers
|
||||
* never need to null-check.
|
||||
*/
|
||||
public static function rowFor(User $user, string $category): array
|
||||
{
|
||||
$row = static::query()->where('user_id', $user->id)->where('event_category', $category)->first();
|
||||
|
||||
if (! $row) {
|
||||
return static::DEFAULTS[$category];
|
||||
}
|
||||
|
||||
return [
|
||||
'scope_mine' => $row->scope_mine,
|
||||
'scope_unassigned' => $row->scope_unassigned,
|
||||
'scope_watched' => $row->scope_watched,
|
||||
'scope_all' => $row->scope_all,
|
||||
'email' => $row->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
src/app/Models/SavedQueueView.php
Normal file
24
src/app/Models/SavedQueueView.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'name', 'filters', 'is_default'])]
|
||||
class SavedQueueView extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'filters' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,48 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
#[Fillable([
|
||||
'number', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'subject', 'body',
|
||||
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id',
|
||||
'sla_notified_at', 'time_spent_seconds', 'timer_started_at', 'created_at', 'updated_at',
|
||||
'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
|
||||
{
|
||||
/**
|
||||
* Every ticket gets a stable, unique checksum the moment its id is known
|
||||
* — it never needs to change afterward, and having it always populated
|
||||
* (regardless of whether obfuscation is currently on) means toggling the
|
||||
* "Ukryj kolejność zgłoszeń" setting doesn't need a backfill pass.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (Ticket $ticket) {
|
||||
$ticket->checksum = static::generateUniqueChecksum($ticket->id);
|
||||
$ticket->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'custom_fields' => 'array',
|
||||
'sla_notified_at' => 'datetime',
|
||||
'last_customer_activity_at' => 'datetime',
|
||||
'time_spent_seconds' => 'integer',
|
||||
'timer_started_at' => 'datetime',
|
||||
'csat_rating' => 'integer',
|
||||
'csat_rated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -51,6 +72,16 @@ class Ticket extends Model
|
||||
return $this->belongsTo(Subcategory::class);
|
||||
}
|
||||
|
||||
public function watchers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'ticket_watchers');
|
||||
}
|
||||
|
||||
public function isWatchedBy(User $user): bool
|
||||
{
|
||||
return $this->watchers()->where('users.id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function status(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Status::class, 'status_key');
|
||||
@@ -86,6 +117,11 @@ class Ticket extends Model
|
||||
return $this->hasMany(TicketHistory::class)->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function automationRuleLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutomationRuleTicketLog::class);
|
||||
}
|
||||
|
||||
public static function nextNumber(): string
|
||||
{
|
||||
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max();
|
||||
@@ -93,6 +129,89 @@ class Ticket extends Model
|
||||
return (string) (($max ?: 1000) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The number shown to users: the admin-configured prefix in front of
|
||||
* formattedNumber(). Kept separate from formattedNumber() because the
|
||||
* `{numer}` placeholder in admin-editable e-mail templates historically
|
||||
* carries no prefix (templates hardcode their own, e.g. "Zgłoszenie
|
||||
* #{numer}") — changing that would double up or mismatch a
|
||||
* non-default prefix in every existing template.
|
||||
*/
|
||||
public function displayNumber(): string
|
||||
{
|
||||
return Settings::get('ticket_number_prefix', '#').$this->formattedNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* The ticket number without any prefix: either the raw sequential
|
||||
* `number` (zero-padded to the admin-configured minimum length), or —
|
||||
* when obfuscation is enabled — this ticket's stored checksum. The
|
||||
* checksum is a fixed-width HMAC output, so minimum-length padding
|
||||
* doesn't apply to it (padding a checksum has no real meaning — it's
|
||||
* only meant to make a short *sequential* number look consistent).
|
||||
* This is also the value getRouteKey()/resolveRouteBinding() use, so
|
||||
* the number shown on the page and the one in the URL always match.
|
||||
* The underlying `number` column itself is left alone, since it still
|
||||
* backs the numeric sort in Operator/Queue.php.
|
||||
*/
|
||||
public function formattedNumber(): string
|
||||
{
|
||||
if (Settings::bool('ticket_number_obfuscate')) {
|
||||
return $this->checksum ?? $this->number;
|
||||
}
|
||||
|
||||
$minLength = max(1, (int) Settings::get('ticket_number_min_length', '4'));
|
||||
|
||||
return str_pad($this->number, $minLength, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value used when generating a URL for this ticket (route($name,
|
||||
* $ticket)) — mirrors formattedNumber() minus the prefix, so a link
|
||||
* never shows the raw sequential number while the page itself shows an
|
||||
* obfuscated one (or vice versa).
|
||||
*/
|
||||
public function getRouteKey()
|
||||
{
|
||||
return Settings::bool('ticket_number_obfuscate') ? ($this->checksum ?? $this->number) : $this->number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound counterpart to getRouteKey() — resolves a URL segment back to
|
||||
* a ticket via whichever column matches the current numbering mode.
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null)
|
||||
{
|
||||
if ($field) {
|
||||
return $this->where($field, $value)->first();
|
||||
}
|
||||
|
||||
$column = Settings::bool('ticket_number_obfuscate') ? 'checksum' : 'number';
|
||||
|
||||
return $this->where($column, $value)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, HMAC-derived checksum for this ticket, carrying no relation
|
||||
* to creation order — salted with the app key so it can't be predicted
|
||||
* or reversed back into id/creation order without server-side secrets.
|
||||
* Collisions are rare but not astronomically so at 6 digits, so this
|
||||
* walks a nonce forward until it lands on a value no other ticket
|
||||
* already has (enforced for real by the column's unique constraint).
|
||||
*/
|
||||
public static function generateUniqueChecksum(int $id): string
|
||||
{
|
||||
$nonce = 0;
|
||||
|
||||
do {
|
||||
$hash = hash_hmac('sha256', $id.'|'.$nonce, (string) config('app.key'));
|
||||
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||
$nonce++;
|
||||
} while (static::query()->where('checksum', $candidate)->exists());
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
public function categoryLabel(): string
|
||||
{
|
||||
return $this->subcategory?->label() ?? '';
|
||||
@@ -129,6 +248,47 @@ class Ticket extends Model
|
||||
|| $this->assignee_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches ticket number/subject/name/email plus subject/body and reply
|
||||
* body text. Uses MySQL FULLTEXT (natural-language mode) on MySQL/MariaDB
|
||||
* — matching the indexes added in the 2026_07_22_000141 migration — and
|
||||
* falls back to plain LIKE on sqlite (used by the test suite), which has
|
||||
* no FULLTEXT equivalent.
|
||||
*/
|
||||
public function scopeSearch(Builder $query, string $term): Builder
|
||||
{
|
||||
$term = trim($term);
|
||||
|
||||
if ($term === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$mysql = DB::connection()->getDriverName() === 'mysql';
|
||||
$like = '%'.$term.'%';
|
||||
|
||||
$messageTicketIds = DB::table('ticket_messages')
|
||||
->when(
|
||||
$mysql,
|
||||
fn ($q) => $q->whereFullText('body', $term),
|
||||
fn ($q) => $q->where('body', 'like', $like),
|
||||
)
|
||||
->pluck('ticket_id');
|
||||
|
||||
return $query->where(function (Builder $q) use ($term, $like, $mysql, $messageTicketIds) {
|
||||
if ($mysql) {
|
||||
$q->whereFullText(['subject', 'body'], $term);
|
||||
} else {
|
||||
$q->where('subject', 'like', $like)->orWhere('body', 'like', $like);
|
||||
}
|
||||
|
||||
$q->orWhere('number', 'like', $like)
|
||||
->orWhere('checksum', 'like', $like)
|
||||
->orWhere('name', 'like', $like)
|
||||
->orWhere('email', 'like', $like)
|
||||
->orWhereIn('id', $messageTicketIds);
|
||||
});
|
||||
}
|
||||
|
||||
public function addHistory(string $text): TicketHistory
|
||||
{
|
||||
return $this->histories()->create(['text' => $text, 'created_at' => now()]);
|
||||
@@ -166,6 +326,21 @@ class Ticket extends Model
|
||||
return Status::stageFor($this->status_key) === 'closed';
|
||||
}
|
||||
|
||||
public function hasCsatRating(): bool
|
||||
{
|
||||
return $this->csat_rating !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client can rate a ticket once it's closed, and only until they do —
|
||||
* there's no "change your rating" flow, mirroring how e.g. edit-message
|
||||
* doesn't apply once the underlying thing is done.
|
||||
*/
|
||||
public function csatSubmittable(): bool
|
||||
{
|
||||
return $this->isClosed() && ! $this->hasCsatRating();
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolution time of 0 minutes means "no SLA" for that priority, not
|
||||
* "due instantly" — such tickets never count down and never breach.
|
||||
@@ -283,8 +458,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
|
||||
{
|
||||
if ($this->isClosed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->timer_started_at) {
|
||||
$this->update(['timer_started_at' => now()]);
|
||||
}
|
||||
|
||||
33
src/app/Models/Trigger.php
Normal file
33
src/app/Models/Trigger.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'enabled', 'event', 'conditions', 'actions', 'sort_order'])]
|
||||
class Trigger extends Model
|
||||
{
|
||||
public const EVENTS = [
|
||||
'ticket_created', 'ticket_updated', 'status_changed', 'priority_changed',
|
||||
'assignee_changed', 'team_changed', 'category_changed', 'comment_added',
|
||||
];
|
||||
|
||||
public const CONDITION_FIELDS = [
|
||||
'status_key', 'priority_key', 'team_id', 'subcategory_id', 'assignee_id', 'customer_id', 'subject', 'body',
|
||||
];
|
||||
|
||||
public const CONDITION_OPERATORS = ['equals', 'not_equals', 'is_empty', 'is_not_empty', 'contains'];
|
||||
|
||||
public const ACTION_TYPES = ['set_status', 'set_priority', 'set_team', 'set_assignee', 'send_notification'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'conditions' => 'array',
|
||||
'actions' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
26
src/app/Models/TriggerEmailTemplate.php
Normal file
26
src/app/Models/TriggerEmailTemplate.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'subject', 'body'])]
|
||||
class TriggerEmailTemplate extends Model
|
||||
{
|
||||
public function render(array $placeholders): array
|
||||
{
|
||||
$replace = function (string $text) use ($placeholders): string {
|
||||
foreach ($placeholders as $key => $value) {
|
||||
$text = str_replace('{'.$key.'}', (string) $value, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
};
|
||||
|
||||
return [
|
||||
'subject' => $replace($this->subject),
|
||||
'body' => $replace($this->body),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -184,11 +184,21 @@ class User extends Authenticatable implements LdapAuthenticatable
|
||||
return $this->hasMany(Ticket::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function watchedTickets(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Ticket::class, 'ticket_watchers');
|
||||
}
|
||||
|
||||
public function ticketsAssigned(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'assignee_id');
|
||||
}
|
||||
|
||||
public function savedQueueViews(): HasMany
|
||||
{
|
||||
return $this->hasMany(SavedQueueView::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-defined "user field" values, stored one row per field in
|
||||
* user_field_values — see the custom_field_values virtual attribute
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace App\Notifications;
|
||||
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TriggerEmailTemplate;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@@ -13,21 +15,68 @@ class TicketNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(protected Ticket $ticket, protected int $emailTemplateId) {}
|
||||
/**
|
||||
* $recipientRole is which area the notified person is being addressed in
|
||||
* ('client' or 'operator', mirrors NotificationSetting::$recipient) — a
|
||||
* user can hold both roles at once, so this can't be inferred from the
|
||||
* notifiable itself; it decides which ticket URL (client vs operator
|
||||
* area) both the e-mail link and the in-app notification point to.
|
||||
*
|
||||
* $channels lets a caller with a real per-recipient preference (see
|
||||
* TicketService::notifyStaffForCategory()) send only 'database' (bell,
|
||||
* no e-mail) for a given recipient — defaults to the original
|
||||
* unconditional "both" behaviour so every existing call site is
|
||||
* unaffected.
|
||||
*
|
||||
* $templateSource picks which table $emailTemplateId is looked up in:
|
||||
* 'email_template' (the fixed, built-in templates) or
|
||||
* 'trigger_email_template' (the freely add/edit/delete-able templates
|
||||
* used by trigger "send_notification" actions — see TriggerEngine).
|
||||
*/
|
||||
public function __construct(
|
||||
protected Ticket $ticket,
|
||||
protected int $emailTemplateId,
|
||||
protected string $recipientRole = 'client',
|
||||
protected array $channels = ['mail', 'database'],
|
||||
protected string $templateSource = 'email_template',
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A guest customer with no account is routed anonymously (see
|
||||
* TicketService::notify()) and can only ever receive mail — the
|
||||
* "database" channel needs a real notifiable model to attach the row to.
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : $this->channels;
|
||||
}
|
||||
|
||||
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->displayNumber().' — '.$this->ticket->subject,
|
||||
'url' => $this->ticketUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = EmailTemplate::query()->find($this->emailTemplateId);
|
||||
$template = $this->templateSource === 'trigger_email_template'
|
||||
? TriggerEmailTemplate::query()->find($this->emailTemplateId)
|
||||
: EmailTemplate::query()->find($this->emailTemplateId);
|
||||
|
||||
$firstName = trim(explode(' ', $this->ticket->name)[0] ?? $this->ticket->name);
|
||||
|
||||
$rendered = $template?->render([
|
||||
'numer' => $this->ticket->number,
|
||||
'numer' => $this->ticket->formattedNumber(),
|
||||
'imie' => $firstName,
|
||||
'temat' => $this->ticket->subject,
|
||||
'status' => $this->ticket->statusLabel(),
|
||||
@@ -35,9 +84,10 @@ class TicketNotification extends Notification
|
||||
'priorytet' => $this->ticket->priorityLabel(),
|
||||
'zespol' => $this->ticket->team?->name ?? 'Brak',
|
||||
'operator' => $this->ticket->assignee?->name ?? 'Nieprzypisane',
|
||||
'link' => route('client.ticket', $this->ticket),
|
||||
'link' => $this->ticketUrl(),
|
||||
'ocena' => route('client.ticket', $this->ticket).'#csat',
|
||||
]) ?? [
|
||||
'subject' => 'Zgłoszenie #'.$this->ticket->number,
|
||||
'subject' => 'Zgłoszenie '.$this->ticket->displayNumber(),
|
||||
'body' => $this->ticket->subject,
|
||||
];
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Notifications\Events\NotificationSent;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
@@ -34,8 +39,33 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->applySessionSettingsOverride();
|
||||
$this->applyTimezoneSettingsOverride();
|
||||
$this->configureApiRateLimiting();
|
||||
$this->broadcastBellNotifications();
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single choke point for realtime bell delivery — hooks Laravel's own
|
||||
* post-send event instead of threading a broadcast dispatch into every
|
||||
* TicketService call site that creates a "database" notification
|
||||
* (client leg, staff fan-out, and eventually the Trigger engine's
|
||||
* send_notification action). $event->response is the DatabaseChannel's
|
||||
* return value: the DatabaseNotification row that was just created,
|
||||
* whose id is the same one the bell already reads.
|
||||
*/
|
||||
protected function broadcastBellNotifications(): void
|
||||
{
|
||||
Event::listen(NotificationSent::class, function (NotificationSent $event) {
|
||||
if ($event->channel !== 'database' || ! $event->notification instanceof TicketNotification) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $event->notification->toDatabase($event->notifiable);
|
||||
|
||||
NotificationCreated::dispatch($event->notifiable->id, $event->response->id, $data['message'], $data['url']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
279
src/app/Services/BookStackClient.php
Normal file
279
src/app/Services/BookStackClient.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class BookStackClient
|
||||
{
|
||||
/**
|
||||
* Two independent allow-lists, since which shelves make sense to
|
||||
* surface differs by where suggestions show up: 'creation' gates the
|
||||
* ticket-wizard suggestions (client/operator/guest), 'ticket_view'
|
||||
* gates the separate sidebar shown to an operator on an existing ticket.
|
||||
*/
|
||||
public const CONTEXT_CREATION = 'creation';
|
||||
|
||||
public const CONTEXT_TICKET_VIEW = 'ticket_view';
|
||||
|
||||
protected const CONTEXT_SETTINGS_KEYS = [
|
||||
self::CONTEXT_CREATION => 'bookstack_allowed_shelf_ids_creation',
|
||||
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
|
||||
];
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return Settings::bool('bookstack_enabled')
|
||||
&& Settings::get('bookstack_base_url')
|
||||
&& Settings::get('bookstack_token_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggested-article lookup, shared by the ticket-creation wizard and the
|
||||
* operator ticket-view sidebar — returns [] whenever the integration is
|
||||
* off/unconfigured, the query is empty, or no shelf has been allow-listed
|
||||
* yet for the given $context (an empty allow-list means "search nothing",
|
||||
* not "search everything" — an admin has to opt specific shelves in
|
||||
* before any content is ever suggested, independently per context).
|
||||
* Cached briefly since the same category/subcategory query repeats
|
||||
* across every ticket created/viewed with that combination. Respects the
|
||||
* admin-configured bookstack_search_types setting ('both'|'page'|'book')
|
||||
* via BookStack's own `{type:x}` query syntax. The cache key folds in the
|
||||
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected
|
||||
* immediately, instead of possibly serving a pre-change result for up to
|
||||
* 10 minutes.
|
||||
*
|
||||
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||
*/
|
||||
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array
|
||||
{
|
||||
$query = trim($query);
|
||||
$allowedShelfIds = $this->allowedShelfIds($context);
|
||||
|
||||
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$typeFilter = Settings::get('bookstack_search_types', 'both');
|
||||
|
||||
if (in_array($typeFilter, ['page', 'book'], true)) {
|
||||
$query .= " {type:{$typeFilter}}";
|
||||
}
|
||||
|
||||
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
||||
|
||||
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
|
||||
try {
|
||||
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$shelfMap = $this->shelfBookMap();
|
||||
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
|
||||
$bookShelfNames = $this->bookShelfNames($shelfMap);
|
||||
|
||||
return collect($response->json('data', []))
|
||||
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
|
||||
$type = $item['type'] ?? null;
|
||||
|
||||
if ($type === 'bookshelf') {
|
||||
return in_array($item['id'] ?? null, $allowedShelfIds, true);
|
||||
}
|
||||
|
||||
if ($type === 'book') {
|
||||
return in_array($item['id'] ?? null, $allowedBookIds, true);
|
||||
}
|
||||
|
||||
// pages/chapters carry the id of the book they live in
|
||||
return isset($item['book_id']) && in_array($item['book_id'], $allowedBookIds, true);
|
||||
})
|
||||
->map(function (array $item) use ($bookShelfNames) {
|
||||
$bookId = $item['book_id'] ?? (($item['type'] ?? null) === 'book' ? $item['id'] : null);
|
||||
|
||||
return [
|
||||
'name' => $item['name'] ?? '',
|
||||
'url' => $item['url'] ?? null,
|
||||
'type' => $item['type'] ?? 'page',
|
||||
'book' => $item['book']['name'] ?? null,
|
||||
'shelf' => $bookId ? ($bookShelfNames[$bookId] ?? null) : null,
|
||||
];
|
||||
})
|
||||
->filter(fn (array $item) => $item['name'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached shelf list and shelf>book membership map — used by
|
||||
* the admin's "Odśwież listę półek" button so a shelf renamed/added/
|
||||
* removed in BookStack shows up immediately instead of after up to 30
|
||||
* minutes. Doesn't touch the per-query search-result cache (10 min TTL,
|
||||
* self-invalidates on the next config save via the allow-list in its key).
|
||||
*/
|
||||
public function clearShelfCache(): void
|
||||
{
|
||||
Cache::forget('bookstack:shelves');
|
||||
Cache::forget('bookstack:shelf-book-map');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookshelves for the admin's two "dozwolone półki" checklists — cached
|
||||
* since shelf structure changes rarely and this is fetched on every
|
||||
* Admin > Konfiguracja page load while the BookStack section is expanded.
|
||||
*
|
||||
* @return array<int, array{id: int, name: string}>
|
||||
*/
|
||||
public function shelves(): array
|
||||
{
|
||||
if (! $this->enabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Cache::remember('bookstack:shelves', now()->addMinutes(30), function () {
|
||||
try {
|
||||
$response = $this->client()->get('/api/shelves', ['count' => 200]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($response->json('data', []))
|
||||
->map(fn (array $s) => ['id' => $s['id'], 'name' => $s['name']])
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
protected function allowedShelfIds(string $context): array
|
||||
{
|
||||
$key = self::CONTEXT_SETTINGS_KEYS[$context] ?? self::CONTEXT_SETTINGS_KEYS[self::CONTEXT_CREATION];
|
||||
$raw = Settings::get($key, '');
|
||||
|
||||
return collect(explode(',', (string) $raw))
|
||||
->map(fn ($v) => (int) trim($v))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every shelf's book membership, fetched once and cached — the single
|
||||
* source both shelf-exclusion and the "Shelf > Book" breadcrumb are
|
||||
* derived from, so there's only one place that talks to /api/shelves/{id}.
|
||||
*
|
||||
* @return array<int, array{name: string, bookIds: int[]}>
|
||||
*/
|
||||
protected function shelfBookMap(): array
|
||||
{
|
||||
return Cache::remember('bookstack:shelf-book-map', now()->addMinutes(30), function () {
|
||||
$map = [];
|
||||
|
||||
foreach ($this->shelves() as $shelf) {
|
||||
$bookIds = [];
|
||||
|
||||
try {
|
||||
$response = $this->client()->get("/api/shelves/{$shelf['id']}");
|
||||
|
||||
if ($response->successful()) {
|
||||
$bookIds = collect($response->json('books', []))->pluck('id')->all();
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Skip an unreachable/deleted shelf rather than failing the whole search.
|
||||
}
|
||||
|
||||
$map[$shelf['id']] = ['name' => $shelf['name'], 'bookIds' => $bookIds];
|
||||
}
|
||||
|
||||
return $map;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
|
||||
* @param int[] $shelfIds
|
||||
* @return int[]
|
||||
*/
|
||||
protected function bookIdsForShelves(array $shelfMap, array $shelfIds): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach ($shelfIds as $shelfId) {
|
||||
$ids = [...$ids, ...($shelfMap[$shelfId]['bookIds'] ?? [])];
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Book id -> owning shelf name, for the suggestion list's breadcrumb. A
|
||||
* book that sits on more than one shelf just shows whichever is last in
|
||||
* the map — there's no single "correct" shelf to prefer in that case.
|
||||
*
|
||||
* @param array<int, array{name: string, bookIds: int[]}> $shelfMap
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function bookShelfNames(array $shelfMap): array
|
||||
{
|
||||
$names = [];
|
||||
|
||||
foreach ($shelfMap as $shelf) {
|
||||
foreach ($shelf['bookIds'] as $bookId) {
|
||||
$names[$bookId] = $shelf['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests unsaved admin-form values directly, rather than whatever's
|
||||
* currently stored — mirrors testLdapConnection()/testMailConnection()
|
||||
* in Admin\Panel. Returns a message alongside the ok/error flag (BookStack's
|
||||
* API returns a specific, useful reason — e.g. missing "Access System API"
|
||||
* role permission — that a plain boolean would hide from the admin.
|
||||
*
|
||||
* @return array{ok: bool, message: ?string}
|
||||
*/
|
||||
public function testConnection(string $baseUrl, string $tokenId, string $tokenSecret, bool $verifySsl = true): array
|
||||
{
|
||||
try {
|
||||
$response = Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
|
||||
->withOptions(['verify' => $verifySsl])
|
||||
->timeout(6)
|
||||
->get(rtrim($baseUrl, '/').'/api/search', ['query' => 'test', 'count' => 1]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return ['ok' => true, 'message' => null];
|
||||
}
|
||||
|
||||
return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
protected function client()
|
||||
{
|
||||
$tokenId = Settings::get('bookstack_token_id');
|
||||
$tokenSecret = Settings::get('bookstack_token_secret');
|
||||
|
||||
return Http::withHeaders(['Authorization' => "Token {$tokenId}:{$tokenSecret}"])
|
||||
->withOptions(['verify' => Settings::bool('bookstack_verify_ssl')])
|
||||
->timeout(4)
|
||||
->baseUrl(rtrim(Settings::get('bookstack_base_url'), '/'));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\TicketMessagePosted;
|
||||
use App\Events\TicketQueueChanged;
|
||||
use App\Models\ApiClient;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
@@ -14,6 +17,7 @@ use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -45,6 +49,7 @@ class TicketService
|
||||
'team_id' => $this->autoAssignTeam($subcategory),
|
||||
'assignee_id' => $data['assignee_id'] ?? null,
|
||||
'custom_fields' => $data['custom_values'] ?? [],
|
||||
'last_customer_activity_at' => now(),
|
||||
]);
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
@@ -54,6 +59,9 @@ class TicketService
|
||||
$message->attachAuthor($customer?->id, 'client');
|
||||
|
||||
$this->notify($ticket, 'ticket_created');
|
||||
$this->notify($ticket, 'ticket_created_team');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_created');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'created', Auth::id());
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
@@ -75,11 +83,45 @@ class TicketService
|
||||
$ticket->update(['status_key' => $statusKey, 'sla_notified_at' => null]);
|
||||
$ticket->addHistory('Status zmieniony na: '.Status::labelFor($statusKey));
|
||||
|
||||
$this->notify($ticket, 'status_changed');
|
||||
|
||||
if ($statusKey === 'closed') {
|
||||
// A transition to "closed" fires its own dedicated notification
|
||||
// instead of the generic status-changed one, so closing a ticket
|
||||
// doesn't send the customer/operator two emails for one event.
|
||||
// Checked via the status's stage (not the literal key) since admins
|
||||
// can rename/replace which key maps to the "closed" stage.
|
||||
if (Status::stageFor($statusKey) === 'closed') {
|
||||
$this->notify($ticket, 'ticket_closed');
|
||||
|
||||
// Time tracking only applies to open work — checkpoint and pause
|
||||
// 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');
|
||||
}
|
||||
|
||||
app(TriggerEngine::class)->handle($ticket, 'status_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
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
|
||||
@@ -87,6 +129,9 @@ class TicketService
|
||||
$ticket->update(['priority_key' => $priorityKey]);
|
||||
$ticket->addHistory('Priorytet zmieniony na: '.Priority::labelFor($priorityKey));
|
||||
$this->notify($ticket, 'priority_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'priority_changed', Auth::id());
|
||||
}
|
||||
|
||||
public function setAssignee(Ticket $ticket, ?User $assignee): void
|
||||
@@ -96,6 +141,9 @@ class TicketService
|
||||
$ticket->update(['assignee_id' => $assignee?->id, 'sla_notified_at' => null]);
|
||||
$ticket->addHistory('Przypisano do: '.($assignee?->name ?? 'Nieprzypisane'));
|
||||
$this->notify($ticket, 'assignee_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'assignee_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'assignee_changed', Auth::id());
|
||||
}
|
||||
|
||||
public function setTeam(Ticket $ticket, ?Team $team): void
|
||||
@@ -103,6 +151,9 @@ class TicketService
|
||||
$ticket->update(['team_id' => $team?->id]);
|
||||
$ticket->addHistory('Zespół zmieniony na: '.($team?->name ?? 'Brak'));
|
||||
$this->notify($ticket, 'team_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'team_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
|
||||
}
|
||||
|
||||
public function setReporter(Ticket $ticket, User $customer): void
|
||||
@@ -125,7 +176,10 @@ class TicketService
|
||||
|
||||
if ($categoryChanged) {
|
||||
$this->notify($ticket, 'category_changed');
|
||||
app(TriggerEngine::class)->handle($ticket, 'category_changed');
|
||||
}
|
||||
|
||||
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
|
||||
}
|
||||
|
||||
public function operatorReply(Ticket $ticket, User $operator, string $body, ?string $statusAfter = null, array $attachments = []): void
|
||||
@@ -138,6 +192,9 @@ class TicketService
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
$this->notify($ticket, 'operator_replied');
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $operator->id);
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $operator->id);
|
||||
|
||||
if ($statusAfter) {
|
||||
$this->setStatus($ticket, $statusAfter);
|
||||
@@ -153,6 +210,7 @@ class TicketService
|
||||
]);
|
||||
$message->attachAuthor($operator->id, 'operator');
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, true, $operator->id);
|
||||
}
|
||||
|
||||
public function clientReply(Ticket $ticket, User $client, string $body, array $attachments = []): void
|
||||
@@ -164,6 +222,33 @@ class TicketService
|
||||
$message->attachAuthor($client->id, 'client');
|
||||
$ticket->touch();
|
||||
$this->attachFiles($ticket, $message, $attachments);
|
||||
|
||||
// A fresh customer reply breaks whatever silence an automation rule
|
||||
// fired on, so it should be able to fire again after a new period of
|
||||
// silence rather than staying latched from before.
|
||||
$ticket->update(['last_customer_activity_at' => now()]);
|
||||
$ticket->automationRuleLogs()->delete();
|
||||
|
||||
// Unlike notify(), clientReply() never had a NotificationSetting
|
||||
// trigger_key of its own — comment_added is a Trigger-engine-only
|
||||
// hook, e.g. for a rule that reopens a closed ticket on a fresh
|
||||
// customer reply.
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, false, $client->id);
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', $client->id);
|
||||
}
|
||||
|
||||
public function toggleWatch(Ticket $ticket, User $user): bool
|
||||
{
|
||||
if ($ticket->isWatchedBy($user)) {
|
||||
$ticket->watchers()->detach($user->id);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$ticket->watchers()->attach($user->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,8 +271,12 @@ class TicketService
|
||||
|
||||
if (! $internal) {
|
||||
$this->notify($ticket, 'operator_replied');
|
||||
app(TriggerEngine::class)->handle($ticket, 'comment_added');
|
||||
}
|
||||
|
||||
TicketMessagePosted::dispatch($ticket->id, $message->id, $internal, null);
|
||||
TicketQueueChanged::dispatch($ticket->id, 'message_posted', null);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
@@ -239,7 +328,7 @@ class TicketService
|
||||
|
||||
$primary->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => '#'.$o->number)->implode(', '),
|
||||
'body' => 'Scalono zgłoszenia: '.$others->map(fn (Ticket $o) => $o->displayNumber())->implode(', '),
|
||||
]);
|
||||
|
||||
foreach ($others as $other) {
|
||||
@@ -253,20 +342,54 @@ class TicketService
|
||||
}
|
||||
|
||||
$other->update(['status_key' => 'closed']);
|
||||
$other->stopTimer();
|
||||
$note = $other->messages()->create([
|
||||
'author_name' => 'System',
|
||||
'internal' => true,
|
||||
'body' => 'Scalone ze zgłoszeniem #'.$primary->number,
|
||||
'body' => 'Scalone ze zgłoszeniem '.$primary->displayNumber(),
|
||||
]);
|
||||
$note->attachAuthor(null, 'operator');
|
||||
TicketQueueChanged::dispatch($other->id, 'merged', Auth::id());
|
||||
}
|
||||
|
||||
$primary->touch();
|
||||
TicketQueueChanged::dispatch($primary->id, 'message_posted', Auth::id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the fixed NotificationSetting trigger_keys onto the 3 event
|
||||
* categories a staff member can tune on their personal notification
|
||||
* preferences page (see NotificationPreference::CATEGORIES). Triggers
|
||||
* absent from this map (currently just the client-only 'ticket_created'
|
||||
* ack) have no staff-facing leg at all.
|
||||
*/
|
||||
private const STAFF_EVENT_MAP = [
|
||||
'ticket_created_team' => 'new_ticket',
|
||||
'status_changed' => 'ticket_update',
|
||||
'priority_changed' => 'ticket_update',
|
||||
'assignee_changed' => 'ticket_update',
|
||||
'team_changed' => 'ticket_update',
|
||||
'category_changed' => 'ticket_update',
|
||||
'operator_replied' => 'ticket_update',
|
||||
'ticket_closed' => 'ticket_update',
|
||||
'sla_breached' => 'escalation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Public so the scheduled SLA-breach check (which isn't a ticket lifecycle
|
||||
* event raised from within this service) can trigger the same way.
|
||||
*
|
||||
* NotificationSetting.enabled is the global kill switch, layered above
|
||||
* every per-user preference below — disabling a trigger here silences
|
||||
* both legs regardless of what any individual staff member configured;
|
||||
* the personal matrix can only narrow within an enabled trigger, never
|
||||
* widen past it.
|
||||
*
|
||||
* Sends exactly one notification to the trigger's fixed
|
||||
* NotificationSetting.recipient (a client, or the ticket's single
|
||||
* assignee) exactly as before, then — for triggers mapped in
|
||||
* STAFF_EVENT_MAP — additionally fans out to every other operator/admin
|
||||
* whose own notification preferences put this ticket in scope.
|
||||
*/
|
||||
public function notify(Ticket $ticket, string $triggerKey): void
|
||||
{
|
||||
@@ -276,13 +399,94 @@ class TicketService
|
||||
return;
|
||||
}
|
||||
|
||||
$email = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
|
||||
$notifiable = $setting->recipient === 'operator' ? $ticket->assignee : $ticket->customer;
|
||||
$fallbackEmail = $setting->recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
|
||||
|
||||
$this->deliverTicketNotification($ticket, $setting->recipient, $setting->email_template_id, notifiable: $notifiable, fallbackEmail: $fallbackEmail);
|
||||
|
||||
if ($category = self::STAFF_EVENT_MAP[$triggerKey] ?? null) {
|
||||
$this->notifyStaffForCategory($ticket, $category, $setting->email_template_id, skip: $notifiable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies every operator/admin whose personal notification preferences
|
||||
* (see NotificationPreference) put this ticket into one of their chosen
|
||||
* scopes for $category — "Wszystkie zgłoszenia" deliberately reuses the
|
||||
* existing Ticket::isVisibleToOperator() ACL rather than meaning
|
||||
* literally every ticket, so it naturally stays within a non-admin
|
||||
* operator's own team(s) + unrouted tickets. $skip excludes whoever
|
||||
* notify() already notified directly via the fixed recipient (so an
|
||||
* assignee with scope_mine enabled doesn't get the same event twice),
|
||||
* and the acting user is always excluded so nobody gets notified about
|
||||
* their own action.
|
||||
*/
|
||||
protected function notifyStaffForCategory(Ticket $ticket, string $category, int $templateId, ?User $skip = null): void
|
||||
{
|
||||
$staff = User::query()->whereHas('roleAssignments', fn ($q) => $q->whereIn('key', ['operator', 'admin']))->get();
|
||||
|
||||
foreach ($staff as $user) {
|
||||
if ($user->id === Auth::id() || ($skip && $user->id === $skip->id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pref = NotificationPreference::rowFor($user, $category);
|
||||
|
||||
$inScope = ($pref['scope_mine'] && $ticket->assignee_id === $user->id)
|
||||
|| ($pref['scope_unassigned'] && $ticket->assignee_id === null)
|
||||
|| ($pref['scope_watched'] && $ticket->isWatchedBy($user))
|
||||
|| ($pref['scope_all'] && $ticket->isVisibleToOperator($user));
|
||||
|
||||
if (! $inScope) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->deliverTicketNotification($ticket, 'operator', $templateId, $pref['email'] ? ['mail', 'database'] : ['database'], notifiable: $user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the Trigger engine's send_notification action (see
|
||||
* TriggerEngine) — an admin-authored, explicit business action, not one
|
||||
* of the fixed system lifecycle events, so unlike notify() it doesn't
|
||||
* consult NotificationSetting or any per-user preference; it always
|
||||
* sends both mail and bell, same as the original unconditional
|
||||
* TicketNotification behaviour.
|
||||
*/
|
||||
public function sendCustomNotification(Ticket $ticket, string $recipient, int $templateId): void
|
||||
{
|
||||
$notifiable = $recipient === 'operator' ? $ticket->assignee : $ticket->customer;
|
||||
$fallbackEmail = $recipient === 'operator' ? $ticket->assignee?->email : $ticket->email;
|
||||
|
||||
$this->deliverTicketNotification($ticket, $recipient, $templateId, notifiable: $notifiable, fallbackEmail: $fallbackEmail, templateSource: 'trigger_email_template');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by notify()'s fixed-recipient leg, notifyStaffForCategory()'s
|
||||
* per-user fan-out, and sendCustomNotification(). $notifiable, when
|
||||
* given a real User, always wins over $fallbackEmail — the fallback
|
||||
* only exists for a guest customer with no account, where the
|
||||
* "database" (bell) channel has nothing to attach to, so
|
||||
* TicketNotification::via() drops it to mail-only anyway.
|
||||
*/
|
||||
private function deliverTicketNotification(
|
||||
Ticket $ticket,
|
||||
string $recipientRole,
|
||||
int $templateId,
|
||||
array $channels = ['mail', 'database'],
|
||||
?User $notifiable = null,
|
||||
?string $fallbackEmail = null,
|
||||
string $templateSource = 'email_template',
|
||||
): void {
|
||||
if ($notifiable) {
|
||||
$notifiable->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
|
||||
|
||||
if (! $email) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::route('mail', $email)
|
||||
->notify(new TicketNotification($ticket, $setting->email_template_id));
|
||||
if ($fallbackEmail) {
|
||||
Notification::route('mail', $fallbackEmail)
|
||||
->notify(new TicketNotification($ticket, $templateId, $recipientRole, $channels, $templateSource));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
164
src/app/Services/TriggerEngine.php
Normal file
164
src/app/Services/TriggerEngine.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\Trigger;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Event-based business rules, configured entirely by admins through the
|
||||
* Wyzwalacze tab — additive and independent from AutomationRule (which is
|
||||
* time/silence-based and evaluated by a scheduled command instead). Fires
|
||||
* synchronously on every matching ticket-lifecycle event (see the
|
||||
* TicketService call sites), same as the app's other side effects — there
|
||||
* is no queue worker in this stack to defer work to.
|
||||
*/
|
||||
class TriggerEngine
|
||||
{
|
||||
private static int $depth = 0;
|
||||
|
||||
private const MAX_DEPTH = 5;
|
||||
|
||||
public function __construct(protected TicketService $tickets) {}
|
||||
|
||||
/**
|
||||
* Guarded two ways against runaway loops: an action that would only
|
||||
* reassert the ticket's current value is a no-op before it ever gets
|
||||
* here (see the apply* methods below), which kills the common case of a
|
||||
* trigger re-matching its own result; the depth counter below is the
|
||||
* hard backstop for genuine cycles between two or more different
|
||||
* triggers.
|
||||
*/
|
||||
public function handle(Ticket $ticket, string $event): void
|
||||
{
|
||||
if (self::$depth >= self::MAX_DEPTH) {
|
||||
Log::warning('TriggerEngine: max depth reached, aborting further evaluation', [
|
||||
'ticket_id' => $ticket->id,
|
||||
'event' => $event,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self::$depth++;
|
||||
|
||||
try {
|
||||
$triggers = Trigger::query()->where('enabled', true)->where('event', $event)->orderBy('sort_order')->get();
|
||||
|
||||
foreach ($triggers as $trigger) {
|
||||
$current = $ticket->fresh();
|
||||
|
||||
if ($current && $this->matches($trigger, $current)) {
|
||||
$this->applyActions($trigger, $current);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
self::$depth--;
|
||||
}
|
||||
}
|
||||
|
||||
protected function matches(Trigger $trigger, Ticket $ticket): bool
|
||||
{
|
||||
foreach ($trigger->conditions as $condition) {
|
||||
$field = $condition['field'] ?? null;
|
||||
|
||||
if (! in_array($field, Trigger::CONDITION_FIELDS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->conditionMatches($condition, $ticket->{$field})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function conditionMatches(array $condition, mixed $actual): bool
|
||||
{
|
||||
$value = $condition['value'] ?? null;
|
||||
|
||||
return match ($condition['operator'] ?? null) {
|
||||
'equals' => (string) $actual === (string) $value,
|
||||
'not_equals' => (string) $actual !== (string) $value,
|
||||
'is_empty' => $actual === null || $actual === '',
|
||||
'is_not_empty' => $actual !== null && $actual !== '',
|
||||
'contains' => is_string($actual) && $value !== null && str_contains(mb_strtolower($actual), mb_strtolower((string) $value)),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
protected function applyActions(Trigger $trigger, Ticket $ticket): void
|
||||
{
|
||||
foreach ($trigger->actions as $action) {
|
||||
match ($action['type'] ?? null) {
|
||||
'set_status' => $this->applySetStatus($ticket, $action['value'] ?? null),
|
||||
'set_priority' => $this->applySetPriority($ticket, $action['value'] ?? null),
|
||||
'set_team' => $this->applySetTeam($ticket, $action['value'] ?? null),
|
||||
'set_assignee' => $this->applySetAssignee($ticket, $action['value'] ?? null),
|
||||
'send_notification' => $this->applySendNotification($ticket, $action),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySetStatus(Ticket $ticket, ?string $value): void
|
||||
{
|
||||
if (! $value || ! Status::query()->where('key', $value)->exists() || $ticket->status_key === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->setStatus($ticket, $value);
|
||||
}
|
||||
|
||||
protected function applySetPriority(Ticket $ticket, ?string $value): void
|
||||
{
|
||||
if (! $value || ! Priority::query()->where('key', $value)->exists() || $ticket->priority_key === $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->setPriority($ticket, $value);
|
||||
}
|
||||
|
||||
protected function applySetTeam(Ticket $ticket, null|string|int $value): void
|
||||
{
|
||||
if ($value === null || (int) $ticket->team_id === (int) $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$team = Team::query()->find($value);
|
||||
|
||||
if ($team) {
|
||||
$this->tickets->setTeam($ticket, $team);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySetAssignee(Ticket $ticket, null|string|int $value): void
|
||||
{
|
||||
if ($value === null || (int) $ticket->assignee_id === (int) $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignee = User::query()->find($value);
|
||||
|
||||
if ($assignee) {
|
||||
$this->tickets->setAssignee($ticket, $assignee);
|
||||
}
|
||||
}
|
||||
|
||||
protected function applySendNotification(Ticket $ticket, array $action): void
|
||||
{
|
||||
$templateId = $action['email_template_id'] ?? null;
|
||||
|
||||
if (! $templateId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->tickets->sendCustomNotification($ticket, $action['recipient'] ?? 'client', (int) $templateId);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ class Settings
|
||||
'attachment_allowed_types' => 'jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip,txt',
|
||||
'session_lifetime_minutes' => '120',
|
||||
'timezone' => 'UTC',
|
||||
'ticket_number_prefix' => '#',
|
||||
'ticket_number_obfuscate' => '0',
|
||||
'ticket_number_min_length' => '4',
|
||||
'ldap_enabled' => '1',
|
||||
'ldap_host' => '',
|
||||
'ldap_port' => '389',
|
||||
@@ -39,6 +42,15 @@ class Settings
|
||||
'mail_smtp_encryption' => 'tls',
|
||||
'mail_from_address' => '',
|
||||
'mail_from_name' => '',
|
||||
'bookstack_enabled' => '0',
|
||||
'bookstack_base_url' => '',
|
||||
'bookstack_token_id' => '',
|
||||
'bookstack_token_secret' => '',
|
||||
'bookstack_verify_ssl' => '1',
|
||||
'bookstack_show_to_guests' => '0',
|
||||
'bookstack_search_types' => 'both',
|
||||
'bookstack_allowed_shelf_ids_creation' => '',
|
||||
'bookstack_allowed_shelf_ids_ticket_view' => '',
|
||||
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
|
||||
'accent_color' => '#7c6fd6',
|
||||
'login_notice_type' => 'info',
|
||||
@@ -51,7 +63,7 @@ class Settings
|
||||
.'</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
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
channels: __DIR__.'/../routes/channels.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"darkaonline/l5-swagger": "*",
|
||||
"directorytree/ldaprecord-laravel": "*",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/reverb": "*",
|
||||
"laravel/sanctum": "*",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "*"
|
||||
|
||||
907
src/composer.lock
generated
907
src/composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "73d594569fe3d69fd8d63789e36b323e",
|
||||
"content-hash": "321add40614eb8751e0c8dbda55016eb",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -134,6 +134,136 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "clue/redis-protocol",
|
||||
"version": "v0.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clue/redis-protocol.git",
|
||||
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c",
|
||||
"reference": "6f565332f5531b7722d1e9c445314b91862f6d6c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Clue\\Redis\\Protocol\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@lueck.tv"
|
||||
}
|
||||
],
|
||||
"description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.",
|
||||
"homepage": "https://github.com/clue/redis-protocol",
|
||||
"keywords": [
|
||||
"parser",
|
||||
"protocol",
|
||||
"redis",
|
||||
"resp",
|
||||
"serializer",
|
||||
"streaming"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/clue/redis-protocol/issues",
|
||||
"source": "https://github.com/clue/redis-protocol/tree/v0.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://clue.engineering/support",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/clue",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-08-07T11:06:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "clue/redis-react",
|
||||
"version": "v2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clue/reactphp-redis.git",
|
||||
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca",
|
||||
"reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"clue/redis-protocol": "^0.3.2",
|
||||
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
|
||||
"php": ">=5.3",
|
||||
"react/event-loop": "^1.2",
|
||||
"react/promise": "^3.2 || ^2.0 || ^1.1",
|
||||
"react/promise-timer": "^1.11",
|
||||
"react/socket": "^1.16"
|
||||
},
|
||||
"require-dev": {
|
||||
"clue/block-react": "^1.5",
|
||||
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Clue\\React\\Redis\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering"
|
||||
}
|
||||
],
|
||||
"description": "Async Redis client implementation, built on top of ReactPHP.",
|
||||
"homepage": "https://github.com/clue/reactphp-redis",
|
||||
"keywords": [
|
||||
"async",
|
||||
"client",
|
||||
"database",
|
||||
"reactphp",
|
||||
"redis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/clue/reactphp-redis/issues",
|
||||
"source": "https://github.com/clue/reactphp-redis/tree/v2.8.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://clue.engineering/support",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/clue",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-01-03T16:18:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "darkaonline/l5-swagger",
|
||||
"version": "11.1.0",
|
||||
@@ -730,6 +860,53 @@
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "evenement/evenement",
|
||||
"version": "v3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/igorw/evenement.git",
|
||||
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
|
||||
"reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9 || ^6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Evenement\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Igor Wiedler",
|
||||
"email": "igor@wiedler.ch"
|
||||
}
|
||||
],
|
||||
"description": "Événement is a very simple event dispatching library for PHP",
|
||||
"keywords": [
|
||||
"event-dispatcher",
|
||||
"event-emitter"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/igorw/evenement/issues",
|
||||
"source": "https://github.com/igorw/evenement/tree/v3.0.2"
|
||||
},
|
||||
"time": "2023-08-08T05:53:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "fruitcake/php-cors",
|
||||
"version": "v1.4.0",
|
||||
@@ -1566,6 +1743,85 @@
|
||||
},
|
||||
"time": "2026-06-26T00:11:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/reverb",
|
||||
"version": "v1.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/reverb.git",
|
||||
"reference": "dca414f38e0f7acc237890ca18edfb5f3d535f86"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/reverb/zipball/dca414f38e0f7acc237890ca18edfb5f3d535f86",
|
||||
"reference": "dca414f38e0f7acc237890ca18edfb5f3d535f86",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"clue/redis-react": "^2.6",
|
||||
"guzzlehttp/psr7": "^2.6",
|
||||
"illuminate/console": "^10.47|^11.0|^12.0|^13.0",
|
||||
"illuminate/contracts": "^10.47|^11.0|^12.0|^13.0",
|
||||
"illuminate/http": "^10.47|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^10.47|^11.0|^12.0|^13.0",
|
||||
"laravel/prompts": "^0.1.15|^0.2.0|^0.3.0",
|
||||
"php": "^8.2",
|
||||
"pusher/pusher-php-server": "^7.2",
|
||||
"ratchet/rfc6455": "^0.4",
|
||||
"react/promise-timer": "^1.10",
|
||||
"react/socket": "^1.14",
|
||||
"symfony/console": "^6.0|^7.0|^8.0",
|
||||
"symfony/http-foundation": "^6.3|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
|
||||
"pestphp/pest": "^2.0|^3.0|^4.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"ratchet/pawl": "^0.4.1",
|
||||
"react/async": "^4.2",
|
||||
"react/http": "^1.9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Reverb\\ApplicationManagerServiceProvider",
|
||||
"Laravel\\Reverb\\ReverbServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Reverb\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
},
|
||||
{
|
||||
"name": "Joe Dixon",
|
||||
"email": "joe@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.",
|
||||
"keywords": [
|
||||
"WebSockets",
|
||||
"laravel",
|
||||
"real-time",
|
||||
"websocket"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/reverb/issues",
|
||||
"source": "https://github.com/laravel/reverb/tree/v1.11.0"
|
||||
},
|
||||
"time": "2026-06-25T02:41:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sanctum",
|
||||
"version": "v4.3.2",
|
||||
@@ -3517,6 +3773,66 @@
|
||||
},
|
||||
"time": "2026-06-29T15:41:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pusher/pusher-php-server",
|
||||
"version": "7.2.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pusher/pusher-http-php.git",
|
||||
"reference": "4aa139ed2a2a805cd265449b691198beee1309d2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2",
|
||||
"reference": "4aa139ed2a2a805cd265449b691198beee1309d2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"php": "^7.3|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"overtrue/phplint": "^2.3",
|
||||
"phpunit/phpunit": "^9.3"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Pusher\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Library for interacting with the Pusher REST API",
|
||||
"keywords": [
|
||||
"events",
|
||||
"messaging",
|
||||
"php-pusher-server",
|
||||
"publish",
|
||||
"push",
|
||||
"pusher",
|
||||
"real time",
|
||||
"real-time",
|
||||
"realtime",
|
||||
"rest",
|
||||
"trigger"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/pusher/pusher-http-php/issues",
|
||||
"source": "https://github.com/pusher/pusher-http-php/tree/7.2.8"
|
||||
},
|
||||
"time": "2026-05-18T13:11:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "radebatz/type-info-extras",
|
||||
"version": "1.0.7",
|
||||
@@ -3777,6 +4093,595 @@
|
||||
},
|
||||
"time": "2026-06-18T03:57:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ratchet/rfc6455",
|
||||
"version": "v0.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ratchetphp/RFC6455.git",
|
||||
"reference": "9b05f371219cbaf9748b505f139617dd0715592b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b",
|
||||
"reference": "9b05f371219cbaf9748b505f139617dd0715592b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.4",
|
||||
"psr/http-factory-implementation": "^1.0",
|
||||
"symfony/polyfill-php80": "^1.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/psr7": "^2.7",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"react/socket": "^1.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Ratchet\\RFC6455\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Matt Bonneau",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "RFC6455 WebSocket protocol handler",
|
||||
"homepage": "http://socketo.me",
|
||||
"keywords": [
|
||||
"WebSockets",
|
||||
"rfc6455",
|
||||
"websocket"
|
||||
],
|
||||
"support": {
|
||||
"chat": "https://gitter.im/reactphp/reactphp",
|
||||
"issues": "https://github.com/ratchetphp/RFC6455/issues",
|
||||
"source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1"
|
||||
},
|
||||
"time": "2026-06-06T14:34:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/cache",
|
||||
"version": "v1.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/cache.git",
|
||||
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
|
||||
"reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.0",
|
||||
"react/promise": "^3.0 || ^2.0 || ^1.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"React\\Cache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering",
|
||||
"homepage": "https://clue.engineering/"
|
||||
},
|
||||
{
|
||||
"name": "Cees-Jan Kiewiet",
|
||||
"email": "reactphp@ceesjankiewiet.nl",
|
||||
"homepage": "https://wyrihaximus.net/"
|
||||
},
|
||||
{
|
||||
"name": "Jan Sorgalla",
|
||||
"email": "jsorgalla@gmail.com",
|
||||
"homepage": "https://sorgalla.com/"
|
||||
},
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"homepage": "https://cboden.dev/"
|
||||
}
|
||||
],
|
||||
"description": "Async, Promise-based cache interface for ReactPHP",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"caching",
|
||||
"promise",
|
||||
"reactphp"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/cache/issues",
|
||||
"source": "https://github.com/reactphp/cache/tree/v1.2.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/reactphp",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2022-11-30T15:59:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/dns",
|
||||
"version": "v1.14.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/dns.git",
|
||||
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
|
||||
"reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.0",
|
||||
"react/cache": "^1.0 || ^0.6 || ^0.5",
|
||||
"react/event-loop": "^1.2",
|
||||
"react/promise": "^3.2 || ^2.7 || ^1.2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
|
||||
"react/async": "^4.3 || ^3 || ^2",
|
||||
"react/promise-timer": "^1.11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"React\\Dns\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering",
|
||||
"homepage": "https://clue.engineering/"
|
||||
},
|
||||
{
|
||||
"name": "Cees-Jan Kiewiet",
|
||||
"email": "reactphp@ceesjankiewiet.nl",
|
||||
"homepage": "https://wyrihaximus.net/"
|
||||
},
|
||||
{
|
||||
"name": "Jan Sorgalla",
|
||||
"email": "jsorgalla@gmail.com",
|
||||
"homepage": "https://sorgalla.com/"
|
||||
},
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"homepage": "https://cboden.dev/"
|
||||
}
|
||||
],
|
||||
"description": "Async DNS resolver for ReactPHP",
|
||||
"keywords": [
|
||||
"async",
|
||||
"dns",
|
||||
"dns-resolver",
|
||||
"reactphp"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/dns/issues",
|
||||
"source": "https://github.com/reactphp/dns/tree/v1.14.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/reactphp",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-18T19:34:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/event-loop",
|
||||
"version": "v1.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/event-loop.git",
|
||||
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
|
||||
"reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcntl": "For signal handling support when using the StreamSelectLoop"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"React\\EventLoop\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering",
|
||||
"homepage": "https://clue.engineering/"
|
||||
},
|
||||
{
|
||||
"name": "Cees-Jan Kiewiet",
|
||||
"email": "reactphp@ceesjankiewiet.nl",
|
||||
"homepage": "https://wyrihaximus.net/"
|
||||
},
|
||||
{
|
||||
"name": "Jan Sorgalla",
|
||||
"email": "jsorgalla@gmail.com",
|
||||
"homepage": "https://sorgalla.com/"
|
||||
},
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"homepage": "https://cboden.dev/"
|
||||
}
|
||||
],
|
||||
"description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
|
||||
"keywords": [
|
||||
"asynchronous",
|
||||
"event-loop"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/event-loop/issues",
|
||||
"source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/reactphp",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-17T20:46:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/promise",
|
||||
"version": "v3.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/promise.git",
|
||||
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
|
||||
"reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "1.12.28 || 1.4.10",
|
||||
"phpunit/phpunit": "^9.6 || ^7.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions_include.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"React\\Promise\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jan Sorgalla",
|
||||
"email": "jsorgalla@gmail.com",
|
||||
"homepage": "https://sorgalla.com/"
|
||||
},
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering",
|
||||
"homepage": "https://clue.engineering/"
|
||||
},
|
||||
{
|
||||
"name": "Cees-Jan Kiewiet",
|
||||
"email": "reactphp@ceesjankiewiet.nl",
|
||||
"homepage": "https://wyrihaximus.net/"
|
||||
},
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"homepage": "https://cboden.dev/"
|
||||
}
|
||||
],
|
||||
"description": "A lightweight implementation of CommonJS Promises/A for PHP",
|
||||
"keywords": [
|
||||
"promise",
|
||||
"promises"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/promise/issues",
|
||||
"source": "https://github.com/reactphp/promise/tree/v3.3.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/reactphp",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-19T18:57:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/promise-timer",
|
||||
"version": "v1.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/promise-timer.git",
|
||||
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1",
|
||||
"reference": "4f70306ed66b8b44768941ca7f142092600fafc1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3",
|
||||
"react/event-loop": "^1.2",
|
||||
"react/promise": "^3.2 || ^2.7.0 || ^1.2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions_include.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"React\\Promise\\Timer\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering",
|
||||
"homepage": "https://clue.engineering/"
|
||||
},
|
||||
{
|
||||
"name": "Cees-Jan Kiewiet",
|
||||
"email": "reactphp@ceesjankiewiet.nl",
|
||||
"homepage": "https://wyrihaximus.net/"
|
||||
},
|
||||
{
|
||||
"name": "Jan Sorgalla",
|
||||
"email": "jsorgalla@gmail.com",
|
||||
"homepage": "https://sorgalla.com/"
|
||||
},
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"homepage": "https://cboden.dev/"
|
||||
}
|
||||
],
|
||||
"description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.",
|
||||
"homepage": "https://github.com/reactphp/promise-timer",
|
||||
"keywords": [
|
||||
"async",
|
||||
"event-loop",
|
||||
"promise",
|
||||
"reactphp",
|
||||
"timeout",
|
||||
"timer"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/promise-timer/issues",
|
||||
"source": "https://github.com/reactphp/promise-timer/tree/v1.11.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/reactphp",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2024-06-04T14:27:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/socket",
|
||||
"version": "v1.17.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/socket.git",
|
||||
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
|
||||
"reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
|
||||
"php": ">=5.3.0",
|
||||
"react/dns": "^1.13",
|
||||
"react/event-loop": "^1.2",
|
||||
"react/promise": "^3.2 || ^2.6 || ^1.2.1",
|
||||
"react/stream": "^1.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
|
||||
"react/async": "^4.3 || ^3.3 || ^2",
|
||||
"react/promise-stream": "^1.4",
|
||||
"react/promise-timer": "^1.11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"React\\Socket\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering",
|
||||
"homepage": "https://clue.engineering/"
|
||||
},
|
||||
{
|
||||
"name": "Cees-Jan Kiewiet",
|
||||
"email": "reactphp@ceesjankiewiet.nl",
|
||||
"homepage": "https://wyrihaximus.net/"
|
||||
},
|
||||
{
|
||||
"name": "Jan Sorgalla",
|
||||
"email": "jsorgalla@gmail.com",
|
||||
"homepage": "https://sorgalla.com/"
|
||||
},
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"homepage": "https://cboden.dev/"
|
||||
}
|
||||
],
|
||||
"description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
|
||||
"keywords": [
|
||||
"Connection",
|
||||
"Socket",
|
||||
"async",
|
||||
"reactphp",
|
||||
"stream"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/socket/issues",
|
||||
"source": "https://github.com/reactphp/socket/tree/v1.17.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/reactphp",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-19T20:47:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/stream",
|
||||
"version": "v1.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/reactphp/stream.git",
|
||||
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
|
||||
"reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"evenement/evenement": "^3.0 || ^2.0 || ^1.0",
|
||||
"php": ">=5.3.8",
|
||||
"react/event-loop": "^1.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"clue/stream-filter": "~1.2",
|
||||
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"React\\Stream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christian Lück",
|
||||
"email": "christian@clue.engineering",
|
||||
"homepage": "https://clue.engineering/"
|
||||
},
|
||||
{
|
||||
"name": "Cees-Jan Kiewiet",
|
||||
"email": "reactphp@ceesjankiewiet.nl",
|
||||
"homepage": "https://wyrihaximus.net/"
|
||||
},
|
||||
{
|
||||
"name": "Jan Sorgalla",
|
||||
"email": "jsorgalla@gmail.com",
|
||||
"homepage": "https://sorgalla.com/"
|
||||
},
|
||||
{
|
||||
"name": "Chris Boden",
|
||||
"email": "cboden@gmail.com",
|
||||
"homepage": "https://cboden.dev/"
|
||||
}
|
||||
],
|
||||
"description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
|
||||
"keywords": [
|
||||
"event-driven",
|
||||
"io",
|
||||
"non-blocking",
|
||||
"pipe",
|
||||
"reactphp",
|
||||
"readable",
|
||||
"stream",
|
||||
"writable"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/reactphp/stream/issues",
|
||||
"source": "https://github.com/reactphp/stream/tree/v1.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/reactphp",
|
||||
"type": "open_collective"
|
||||
}
|
||||
],
|
||||
"time": "2024-06-11T12:45:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "swagger-api/swagger-ui",
|
||||
"version": "v5.32.10",
|
||||
|
||||
@@ -27,6 +27,18 @@ return [
|
||||
|
||||
'author_contact' => env('AUTHOR_CONTACT'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Version
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Shown on the Admin > About tab. Not a framework setting — set VERSION in
|
||||
| .env, bump it alongside the CHANGELOG.md entry/git tag on each release.
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('VERSION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
||||
82
src/config/broadcasting.php
Normal file
82
src/config/broadcasting.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Broadcaster
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default broadcaster that will be used by the
|
||||
| framework when an event needs to be broadcast. You may set this to
|
||||
| any of the connections defined in the "connections" array below.
|
||||
|
|
||||
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Broadcast Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the broadcast connections that will be used
|
||||
| to broadcast events to other systems or over WebSockets. Samples of
|
||||
| each available type of connection are provided inside this array.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'reverb' => [
|
||||
'driver' => 'reverb',
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||
'encrypted' => true,
|
||||
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'client_options' => [
|
||||
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||
],
|
||||
],
|
||||
|
||||
'ably' => [
|
||||
'driver' => 'ably',
|
||||
'key' => env('ABLY_KEY'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'null',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
102
src/config/reverb.php
Normal file
102
src/config/reverb.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Reverb Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default server used by Reverb to handle
|
||||
| incoming messages as well as broadcasting message to all your
|
||||
| connected clients. At this time only "reverb" is supported.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('REVERB_SERVER', 'reverb'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Servers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define details for each of the supported Reverb servers.
|
||||
| Each server has its own configuration options that are defined in
|
||||
| the array below. You should ensure all the options are present.
|
||||
|
|
||||
*/
|
||||
|
||||
'servers' => [
|
||||
|
||||
'reverb' => [
|
||||
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||
'path' => env('REVERB_SERVER_PATH', ''),
|
||||
'hostname' => env('REVERB_HOST'),
|
||||
'options' => [
|
||||
'tls' => [],
|
||||
],
|
||||
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||
'scaling' => [
|
||||
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||
'server' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||
],
|
||||
],
|
||||
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Reverb Applications
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define how Reverb applications are managed. If you choose
|
||||
| to use the "config" provider, you may define an array of apps which
|
||||
| your server will support, including their connection credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'apps' => [
|
||||
|
||||
'provider' => 'config',
|
||||
|
||||
'apps' => [
|
||||
[
|
||||
'key' => env('REVERB_APP_KEY'),
|
||||
'secret' => env('REVERB_APP_SECRET'),
|
||||
'app_id' => env('REVERB_APP_ID'),
|
||||
'options' => [
|
||||
'host' => env('REVERB_HOST'),
|
||||
'port' => env('REVERB_PORT', 443),
|
||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||
],
|
||||
'allowed_origins' => ['*'],
|
||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||
'rate_limiting' => [
|
||||
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
|
||||
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
|
||||
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
|
||||
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('csat_rating')->nullable()->after('time_spent_seconds');
|
||||
$table->text('csat_comment')->nullable()->after('csat_rating');
|
||||
$table->timestamp('csat_rated_at')->nullable()->after('csat_comment');
|
||||
});
|
||||
|
||||
// FULLTEXT indexes power full-text ticket search — MariaDB/MySQL only,
|
||||
// the sqlite driver used by tests has no equivalent (search falls back
|
||||
// to LIKE there, see Ticket::scopeSearch()).
|
||||
if (Schema::getConnection()->getDriverName() === 'mysql') {
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->fullText(['subject', 'body']);
|
||||
});
|
||||
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||
$table->fullText('body');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::getConnection()->getDriverName() === 'mysql') {
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropFullText(['subject', 'body']);
|
||||
});
|
||||
Schema::table('ticket_messages', function (Blueprint $table) {
|
||||
$table->dropFullText(['body']);
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn(['csat_rating', 'csat_comment', 'csat_rated_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('saved_queue_views', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->json('filters');
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('saved_queue_views');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Appends a "rate our support" CTA to the existing seeded ticket_closed
|
||||
* e-mail template, matching what a fresh install's seeder now produces
|
||||
* (see DatabaseSeeder::seedEmailTemplatesAndNotifications()). Guarded by
|
||||
* a "does it already contain {ocena}" check so re-running (or a fresh
|
||||
* seed that already has it) is a no-op, and skipped entirely if the
|
||||
* admin has since customized the template away from the seeded wording.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$template = DB::table('email_templates')->where('key', 'tpl-closed')->first();
|
||||
|
||||
if (! $template || str_contains($template->body, '{ocena}')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seededBody = '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p><p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p>';
|
||||
|
||||
if (! str_starts_with($template->body, $seededBody)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$csatLink = '<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>';
|
||||
$rest = substr($template->body, strlen($seededBody));
|
||||
|
||||
DB::table('email_templates')->where('id', $template->id)->update([
|
||||
'body' => $seededBody.$csatLink.$rest,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$template = DB::table('email_templates')->where('key', 'tpl-closed')->first();
|
||||
|
||||
if (! $template) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('email_templates')->where('id', $template->id)->update([
|
||||
'body' => str_replace('<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>', '', $template->body),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* scope_* columns are soft references (like tickets.status_key) — no FK,
|
||||
* so deleting a priority/subcategory/team in Admin never blocks or
|
||||
* cascades into a rule; a null scope column means "any" for that filter.
|
||||
* action_value likewise soft-holds whichever kind of key/id action_type
|
||||
* needs (priority_key/status_key/team_id/user_id).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('automation_rules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('label');
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->unsignedInteger('condition_minutes');
|
||||
$table->string('scope_priority_key')->nullable();
|
||||
$table->unsignedBigInteger('scope_subcategory_id')->nullable();
|
||||
$table->unsignedBigInteger('scope_team_id')->nullable();
|
||||
$table->string('action_type');
|
||||
$table->string('action_value');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('automation_rules');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per (rule, ticket) firing — the idempotency latch that stops
|
||||
* RunAutomationRules from re-applying the same rule to the same ticket
|
||||
* every scheduler tick. Rows are deleted (not flagged) by TicketService
|
||||
* whenever the underlying silence is broken, so the rule can fire again.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('automation_rule_ticket_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('automation_rule_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamp('triggered_at');
|
||||
|
||||
$table->unique(['automation_rule_id', 'ticket_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('automation_rule_ticket_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Nullable, no backfill of existing rows — RunAutomationRules falls back
|
||||
* to created_at when this is null, mirroring how resolutionDeadline()
|
||||
* treats a missing SlaRule as "no SLA" rather than backfilling one.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->timestamp('last_customer_activity_at')->nullable()->after('sla_notified_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn('last_customer_activity_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Backfills the new 'ticket_created_team' trigger + its email template
|
||||
* for an already-seeded database (mirrors DatabaseSeeder::
|
||||
* seedEmailTemplatesAndNotifications(), which only runs on a fresh
|
||||
* install) — guarded so re-running, or a fresh seed that already has
|
||||
* both rows, is a no-op.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$templateId = DB::table('email_templates')->where('key', 'tpl-team-new-ticket')->value('id');
|
||||
|
||||
if (! $templateId) {
|
||||
$templateId = DB::table('email_templates')->insertGetId([
|
||||
'key' => 'tpl-team-new-ticket',
|
||||
'name' => 'Nowe zgłoszenie w zespole',
|
||||
'trigger_label' => 'Nowe zgłoszenie w zespole — operator',
|
||||
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
|
||||
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p><p>Podgląd zgłoszenia: <a href="{link}" rel="noopener noreferrer" target="_blank">Kliknij tu</a></p><p>Pozdrawiamy,<br>Zespół Wsparcia</p>',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (DB::table('notification_settings')->where('trigger_key', 'ticket_created_team')->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('notification_settings')->insert([
|
||||
'trigger_key' => 'ticket_created_team',
|
||||
'trigger_label' => 'Nowe zgłoszenie w zespole (powiadom operatorów)',
|
||||
'enabled' => true,
|
||||
'recipient' => 'operator',
|
||||
'email_template_id' => $templateId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('notification_settings')->where('trigger_key', 'ticket_created_team')->delete();
|
||||
DB::table('email_templates')->where('key', 'tpl-team-new-ticket')->delete();
|
||||
}
|
||||
};
|
||||
@@ -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('ticket_watchers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['ticket_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_watchers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per (user, event_category) — only written the first time a
|
||||
* user actually toggles a checkbox on their notification-preferences
|
||||
* page. A missing row is not "notifications off"; callers must fall
|
||||
* back to NotificationPreference::DEFAULTS, never treat absence as
|
||||
* all-false (see NotificationPreference::rowFor()).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_preferences', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('event_category');
|
||||
$table->boolean('scope_mine')->default(false);
|
||||
$table->boolean('scope_unassigned')->default(false);
|
||||
$table->boolean('scope_watched')->default(false);
|
||||
$table->boolean('scope_all')->default(false);
|
||||
$table->boolean('email')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'event_category']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_preferences');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Event-driven business rules (see App\Services\TriggerEngine) —
|
||||
* conditions/actions are JSON so an admin can add/edit rules entirely
|
||||
* through the UI, with no migration needed per rule. Deliberately no
|
||||
* dedup/log table here (unlike automation_rule_ticket_logs): a trigger
|
||||
* is meant to re-fire on every matching event, not latch until reset.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('triggers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->string('event');
|
||||
$table->json('conditions');
|
||||
$table->json('actions');
|
||||
$table->unsignedInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('triggers');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
* Separate from email_templates on purpose: those are fixed 1:1 to a
|
||||
* built-in notification trigger (no add/delete/reassign — see
|
||||
* Admin\Panel::editingTemplate()), while these are freely add/edit/
|
||||
* delete-able by admins for use in the "Wyślij powiadomienie e-mail"
|
||||
* trigger action (App\Services\TriggerEngine::applySendNotification).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('trigger_email_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('subject');
|
||||
$table->text('body');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('trigger_email_templates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->string('checksum', 20)->nullable()->unique()->after('number');
|
||||
});
|
||||
|
||||
// Backfill: every existing ticket gets a stable, HMAC-derived
|
||||
// checksum (mirrors Ticket::generateUniqueChecksum()) so the
|
||||
// "hide ticket order" numbering mode has a real, unique, indexed
|
||||
// column to resolve ticket URLs against instead of only being a
|
||||
// display-time computation.
|
||||
$assigned = [];
|
||||
|
||||
DB::table('tickets')->orderBy('id')->select('id')->chunkById(500, function ($tickets) use (&$assigned) {
|
||||
foreach ($tickets as $ticket) {
|
||||
$nonce = 0;
|
||||
|
||||
do {
|
||||
$hash = hash_hmac('sha256', $ticket->id.'|'.$nonce, (string) config('app.key'));
|
||||
$candidate = (string) (hexdec(substr($hash, 0, 8)) % 900000 + 100000);
|
||||
$nonce++;
|
||||
} while (isset($assigned[$candidate]));
|
||||
|
||||
$assigned[$candidate] = true;
|
||||
|
||||
DB::table('tickets')->where('id', $ticket->id)->update(['checksum' => $candidate]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table) {
|
||||
$table->dropColumn('checksum');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -55,7 +55,7 @@ class DatabaseSeeder extends Seeder
|
||||
['key' => 'operator', 'label' => 'Operator'],
|
||||
['key' => 'admin', 'label' => 'Administrator'],
|
||||
] as $role) {
|
||||
Role::query()->create($role);
|
||||
Role::query()->firstOrCreate(['key' => $role['key']], $role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,11 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
foreach ([
|
||||
['label' => 'Wyślij i „Oczekuje na klienta”', 'status_key' => 'waiting_customer', 'sort_order' => 1],
|
||||
// Used to point at the now-removed "resolved" status, folded into
|
||||
// "closed" by the status restructure — same target as "Wyślij i
|
||||
// zamknij" today, kept as a separate quick action for continuity
|
||||
// with the old hardcoded menu (see ReplyQuickActionsTest).
|
||||
['label' => 'Wyślij i oznacz jako rozwiązane', 'status_key' => 'closed', 'sort_order' => 2],
|
||||
['label' => 'Wyślij i zamknij', 'status_key' => 'closed', 'sort_order' => 3],
|
||||
] as $action) {
|
||||
ReplyQuickAction::query()->create($action);
|
||||
@@ -287,6 +292,7 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$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>';
|
||||
$csatLink = '<p><a href="{ocena}" rel="noopener noreferrer" target="_blank">Oceń naszą obsługę</a></p>';
|
||||
|
||||
$templates = [
|
||||
'tpl-new' => [
|
||||
@@ -322,7 +328,7 @@ class DatabaseSeeder extends Seeder
|
||||
'tpl-closed' => [
|
||||
'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte',
|
||||
'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
|
||||
'body' => '<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' => [
|
||||
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział',
|
||||
@@ -334,13 +340,17 @@ class DatabaseSeeder extends Seeder
|
||||
'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
|
||||
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
|
||||
],
|
||||
'tpl-team-new-ticket' => [
|
||||
'name' => 'Nowe zgłoszenie w zespole', 'trigger_label' => 'Nowe zgłoszenie w zespole — operator',
|
||||
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})',
|
||||
'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer,
|
||||
],
|
||||
];
|
||||
|
||||
$ids = [];
|
||||
|
||||
foreach ($templates as $key => $tpl) {
|
||||
$ids[$key] = EmailTemplate::query()->create([
|
||||
'key' => $key,
|
||||
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
|
||||
'name' => $tpl['name'],
|
||||
'trigger_label' => $tpl['trigger_label'],
|
||||
'subject' => $tpl['subject'],
|
||||
@@ -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' => 'operator_replied', 'trigger_label' => 'Nowa odpowiedź operatora', 'enabled' => true, 'recipient' => 'client', 'template' => 'tpl-reply'],
|
||||
['trigger_key' => 'sla_breached', 'trigger_label' => 'Przekroczono SLA (powiadom operatora)', 'enabled' => false, 'recipient' => 'operator', 'template' => 'tpl-sla-breach'],
|
||||
['trigger_key' => 'ticket_created_team', 'trigger_label' => 'Nowe zgłoszenie w zespole (powiadom operatorów)', 'enabled' => true, 'recipient' => 'operator', 'template' => 'tpl-team-new-ticket'],
|
||||
] as $setting) {
|
||||
NotificationSetting::query()->create([
|
||||
'trigger_key' => $setting['trigger_key'],
|
||||
NotificationSetting::query()->firstOrCreate(['trigger_key' => $setting['trigger_key']], [
|
||||
'trigger_label' => $setting['trigger_label'],
|
||||
'enabled' => $setting['enabled'],
|
||||
'recipient' => $setting['recipient'],
|
||||
|
||||
40
src/package-lock.json
generated
40
src/package-lock.json
generated
@@ -4,6 +4,10 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"laravel-echo": "^2.1.0",
|
||||
"pusher-js": "^8.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
@@ -909,6 +913,27 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-echo": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.4.0.tgz",
|
||||
"integrity": "sha512-8w0fAGSNt6THfbNyqdKc29bhfeNpJg13CGx2fcLgoX0/f0mTJm/AIkYTTakmcr9pc42ZB68cSoE00j4/xNaFGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pusher-js": "*",
|
||||
"socket.io-client": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pusher-js": {
|
||||
"optional": true
|
||||
},
|
||||
"socket.io-client": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-vite-plugin": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.3.tgz",
|
||||
@@ -1275,6 +1300,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/pusher-js": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -1451,6 +1485,12 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"laravel-echo": "^2.1.0",
|
||||
"pusher-js": "^8.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
|
||||
@@ -313,9 +313,16 @@ body {
|
||||
.queue-filters { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
|
||||
.queue-filters-search { width: 220px; }
|
||||
.queue-filters-columns { position: relative; margin-left: auto; }
|
||||
.queue-filters-saved { position: relative; }
|
||||
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spin { display: inline-block; animation: spin 0.8s linear infinite; }
|
||||
|
||||
.main-col { flex: 1; min-width: 320px; }
|
||||
.aside-col { width: 300px; flex: none; }
|
||||
/* Opt-in wider sidebar (operator ticket view, which also shows BookStack
|
||||
suggestions) — at least 50% wider than the default .aside-col. */
|
||||
.aside-col-wide { width: 460px; }
|
||||
|
||||
.wizard-step { width: 80px; flex: none; }
|
||||
.wizard-connector { width: 56px; flex: none; }
|
||||
@@ -336,9 +343,26 @@ body {
|
||||
.page-pad { padding: 16px !important; }
|
||||
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; }
|
||||
.nav-panel-label { display: none; }
|
||||
|
||||
/* Theme/notifications/profile dropdowns are anchored (position:absolute)
|
||||
to their own small trigger button by default, which overflows off the
|
||||
edge of narrow screens once their fixed width no longer fits between
|
||||
the button and the viewport edge. Dropping the wrapper's own
|
||||
positioning context makes .nav itself (already position:relative) the
|
||||
containing block instead, so left/right:0 spans the whole navbar
|
||||
width rather than the button's. */
|
||||
.nav-dropdown-wrap { position: static !important; }
|
||||
.nav-dropdown {
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
.profile-menu-name { display: none; }
|
||||
.main-col { min-width: 0; }
|
||||
.aside-col { width: 100%; }
|
||||
.aside-col-wide { width: 100%; }
|
||||
.wizard-step { width: 58px; }
|
||||
.wizard-connector { width: 22px; }
|
||||
.dialog { padding: 18px; }
|
||||
|
||||
@@ -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';
|
||||
|
||||
112
src/resources/js/echo.js
Normal file
112
src/resources/js/echo.js
Normal file
@@ -0,0 +1,112 @@
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every logged-in user's own private notification stream — refreshes the
|
||||
* bell instantly (see NotificationBell::onBellNotification()) and, when the
|
||||
* viewer has opted in via the toggle on the notification-preferences page,
|
||||
* also raises an in-tab browser Notification. Deliberately lightweight: no
|
||||
* service worker, no push subscription — this only fires while the tab
|
||||
* calling it is open, same limitation as the operator.queue block above.
|
||||
*/
|
||||
if (window.currentUserId) {
|
||||
window.Echo.private('App.Models.User.' + window.currentUserId)
|
||||
.listen('.NotificationCreated', (e) => {
|
||||
Livewire.dispatch('bell-notification-received', { notificationId: e.notificationId });
|
||||
|
||||
if (
|
||||
localStorage.getItem('browserNotificationsEnabled') === '1'
|
||||
&& typeof Notification !== 'undefined'
|
||||
&& Notification.permission === 'granted'
|
||||
) {
|
||||
const popup = new Notification(e.message, { tag: e.notificationId });
|
||||
popup.onclick = () => {
|
||||
window.focus();
|
||||
window.location.href = e.url;
|
||||
};
|
||||
}
|
||||
})
|
||||
.error((error) => console.error('user notification channel 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;
|
||||
@@ -0,0 +1,55 @@
|
||||
@props(['articles', 'variant' => 'banner', 'title' => 'Może pomogą te artykuły z bazy wiedzy', 'showCopy' => false])
|
||||
|
||||
@php
|
||||
$icons = ['book' => 'menu_book', 'chapter' => 'bookmark', 'bookshelf' => 'library_books', 'page' => 'article'];
|
||||
$isSidebar = $variant === 'sidebar';
|
||||
@endphp
|
||||
|
||||
@if (count($articles))
|
||||
<div class="card" style="{{ $isSidebar ? 'padding:16px;gap:8px' : 'padding:14px;gap:10px;background:color-mix(in srgb, var(--color-accent) 6%, transparent);border-color:color-mix(in srgb, var(--color-accent) 25%, var(--color-divider))' }}">
|
||||
@if ($isSidebar)
|
||||
<div class="card-kicker">{{ $title }}</div>
|
||||
@else
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">auto_awesome</span>
|
||||
{{ $title }}
|
||||
</div>
|
||||
@endif
|
||||
<div style="display:flex;flex-direction:column;gap:2px">
|
||||
@foreach ($articles as $article)
|
||||
<div
|
||||
@if ($showCopy) x-data="{ copied: false }" @endif
|
||||
style="display:flex;gap:6px;align-items:flex-start;padding:8px;border-radius:6px"
|
||||
onmouseover="this.style.background='color-mix(in srgb, var(--color-accent) 8%, transparent)'"
|
||||
onmouseout="this.style.background='transparent'"
|
||||
>
|
||||
<a
|
||||
href="{{ $article['url'] }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display:flex;gap:10px;align-items:flex-start;flex:1;min-width:0;text-decoration:none;color:inherit"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px;flex:none;margin-top:1px;color:var(--color-accent)">{{ $icons[$article['type']] ?? 'article' }}</span>
|
||||
<span style="min-width:0;flex:1">
|
||||
<span style="display:block;font-size:13px;font-weight:500;color:var(--color-accent)">{{ $article['name'] }}</span>
|
||||
@if (! empty($article['book']) && $article['book'] !== $article['name'])
|
||||
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:1px">
|
||||
{{ ! empty($article['shelf']) ? $article['shelf'].' > '.$article['book'] : $article['book'] }}
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
</a>
|
||||
@if ($showCopy)
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
style="flex:none;font-size:11px;padding:4px 8px;white-space:nowrap"
|
||||
x-on:click="navigator.clipboard.writeText(@js($article['url'])); copied = true; setTimeout(() => copied = false, 1500)"
|
||||
x-text="copied ? 'Skopiowano!' : 'Kopiuj link'"
|
||||
></button>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -1,7 +1,11 @@
|
||||
@props(['attachment'])
|
||||
|
||||
@php
|
||||
$url = \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path);
|
||||
@endphp
|
||||
|
||||
<a
|
||||
href="{{ \Illuminate\Support\Facades\Storage::disk('public')->url($attachment->path) }}"
|
||||
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)"
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@endphp
|
||||
|
||||
@if ($user)
|
||||
<div x-data="{ open: false }" @click.outside="open = false" style="position:relative;display:inline-block">
|
||||
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block">
|
||||
<button type="button" class="btn btn-secondary" @click="open = !open" style="display:flex;align-items:center;gap:6px">
|
||||
<span class="material-symbols-outlined" style="font-size:18px">account_circle</span>
|
||||
<span class="profile-menu-name">{{ $user->name }}</span>
|
||||
@@ -25,6 +25,7 @@
|
||||
<div
|
||||
x-show="open"
|
||||
x-cloak
|
||||
class="nav-dropdown"
|
||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30"
|
||||
>
|
||||
@foreach ($areas as $area)
|
||||
@@ -41,6 +42,18 @@
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
@endif
|
||||
|
||||
@if ($user && ($user->isOperator() || $user->isAdmin()))
|
||||
<a
|
||||
href="{{ route('settings.notifications') }}"
|
||||
wire:navigate
|
||||
@click="open = false"
|
||||
class="theme-toggle-option"
|
||||
style="text-decoration:none;color:{{ request()->routeIs('settings.*') ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px"
|
||||
>Powiadomienia</a>
|
||||
|
||||
<div style="border-top:1px solid var(--color-divider)"></div>
|
||||
@endif
|
||||
|
||||
<a
|
||||
href="{{ route('logout') }}"
|
||||
onclick="event.preventDefault(); document.getElementById('profile-menu-logout-form').submit();"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
}
|
||||
}"
|
||||
@click.outside="open = false"
|
||||
class="nav-dropdown-wrap"
|
||||
style="position:relative;display:inline-block"
|
||||
>
|
||||
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
|
||||
@@ -20,6 +21,7 @@
|
||||
<div
|
||||
x-show="open"
|
||||
x-cloak
|
||||
class="nav-dropdown"
|
||||
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"
|
||||
>
|
||||
<button type="button" class="theme-toggle-option" @click="apply('light')">
|
||||
|
||||
@@ -18,5 +18,9 @@
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
@auth
|
||||
<livewire:notification-bell />
|
||||
@endauth
|
||||
|
||||
<x-profile-menu />
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? \App\Support\Settings::get('company_name') }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ \App\Support\Settings::faviconUrl() }}">
|
||||
|
||||
@@ -70,6 +71,12 @@
|
||||
.ql-editor hr { border: none; border-top: 1px solid var(--color-divider); margin: 10px 0; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Lets the Echo listeners in resources/js/echo.js tell "my own action
|
||||
// echoed back" apart from "someone else changed this" without a
|
||||
// roundtrip — broadcast event payloads carry the same actorId shape.
|
||||
window.currentUserId = @json(auth()->id());
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
<style>:root{--color-accent: {{ \App\Support\Settings::accentColor() }};}</style>
|
||||
@livewireStyles
|
||||
|
||||
@@ -7,6 +7,8 @@ $tabGroups = [
|
||||
['key' => 'priorities', 'label' => 'Priorytety i SLA', 'icon' => 'priority_high'],
|
||||
['key' => 'reply-quick-actions', 'label' => 'Szybkie akcje odpowiedzi', 'icon' => 'bolt'],
|
||||
['key' => 'response-templates', 'label' => 'Szablony odpowiedzi', 'icon' => 'chat'],
|
||||
['key' => 'automation-rules', 'label' => 'Automatyzacja SLA', 'icon' => 'bolt'],
|
||||
['key' => 'triggers', 'label' => 'Wyzwalacze', 'icon' => 'rule'],
|
||||
],
|
||||
'Zespół' => [
|
||||
['key' => 'users', 'label' => 'Użytkownicy', 'icon' => 'group'],
|
||||
@@ -15,8 +17,10 @@ $tabGroups = [
|
||||
],
|
||||
'Ustawienia' => [
|
||||
['key' => 'templates', 'label' => 'Szablony e-mail', 'icon' => 'mail'],
|
||||
['key' => 'email', 'label' => 'E-MAIL', 'icon' => 'forward_to_inbox'],
|
||||
['key' => 'branding', 'label' => 'Wygląd i branding', 'icon' => 'palette'],
|
||||
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
|
||||
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
|
||||
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
|
||||
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
|
||||
],
|
||||
@@ -322,6 +326,44 @@ $tabGroups = [
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- ================= AUTOMATION RULES ================= --}}
|
||||
@if ($tab === 'automation-rules')
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="margin:0">Automatyzacja SLA</h3>
|
||||
<button class="btn btn-primary" wire:click="openAutomationRuleForm">+ Dodaj regułę</button>
|
||||
</div>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Co 15 minut sprawdzane jest, czy zgłoszenie milczy (brak odpowiedzi klienta) dłużej niż próg reguły — jeśli tak (i pasuje do opcjonalnego zawężenia), wykonywana jest wybrana akcja. Reguła nie powtarza się dla tego samego zgłoszenia, dopóki klient znów nie napisze albo zgłoszenie nie zostanie zamknięte i otwarte ponownie.</p>
|
||||
@if ($this->automationRules->isNotEmpty())
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
@foreach ($this->automationRules as $rule)
|
||||
<div class="card" style="padding:14px 16px;gap:6px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div>
|
||||
<span class="card-title">{{ $rule->label }}</span>
|
||||
<span class="text-muted" style="font-size:12px;display:block">Brak odpowiedzi klienta ≥ {{ $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
|
||||
|
||||
{{-- ================= TRIGGERS ================= --}}
|
||||
@if ($tab === 'triggers')
|
||||
<livewire:admin.triggers />
|
||||
@endif
|
||||
|
||||
{{-- ================= STATUSES ================= --}}
|
||||
@if ($tab === 'statuses')
|
||||
<h3 style="margin:0 0 6px">Statusy</h3>
|
||||
@@ -401,6 +443,27 @@ $tabGroups = [
|
||||
|
||||
{{-- ================= EMAIL TEMPLATES ================= --}}
|
||||
@if ($tab === 'templates')
|
||||
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon — możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($this->notificationSettings as $ns)
|
||||
<tr>
|
||||
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
|
||||
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
|
||||
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
|
||||
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ================= E-MAIL ================= --}}
|
||||
@if ($tab === 'email')
|
||||
<h3 style="margin:0 0 6px">Wygląd wiadomości e-mail</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde powiadomienie wysyłane jest w stałym „pudełku” (nazwa firmy, ramka, treść zdarzenia) — tu edytujesz tylko jego stopkę. Po prawej — podgląd na żywo na przykładowym zgłoszeniu.</p>
|
||||
|
||||
@@ -420,23 +483,45 @@ $tabGroups = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:0 0 6px">Powiadomienia e-mail</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">Każde zdarzenie ma stały, przypisany na stałe szablon — możesz go dowolnie edytować, ale nie zmienić na inny. Wyłącz przełącznik, żeby dana wiadomość nigdy nie była wysyłana.</p>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Zdarzenie</th><th>Odbiorca</th><th>Wysyłane</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($this->notificationSettings as $ns)
|
||||
<tr>
|
||||
<td style="white-space:nowrap">{{ $ns->trigger_label }}</td>
|
||||
<td><span class="tag tag-outline">{{ $ns->recipient === 'operator' ? 'Operator' : 'Zgłaszający' }}</span></td>
|
||||
<td><input type="checkbox" @checked($ns->enabled) wire:click="toggleNotificationEnabled({{ $ns->id }})"></td>
|
||||
<td><button type="button" class="btn btn-ghost" wire:click="editEmailTemplate({{ $ns->email_template_id }})">Edytuj szablon</button></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<h3 style="margin:0 0 14px">E-mail (SMTP)</h3>
|
||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px;max-width:480px">
|
||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === '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>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === '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 wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- ================= BRANDING ================= --}}
|
||||
@@ -532,6 +617,21 @@ $tabGroups = [
|
||||
</select>
|
||||
</div>
|
||||
<label class="radio"><input type="checkbox" wire:model="systemConfig.autoAssignByCategory" style="position:static;opacity:1;width:auto;height:auto">Automatyczne przypisywanie do zespołu wg kategorii</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>Prefiks numeru zgłoszenia</label>
|
||||
<input class="input" maxlength="20" placeholder="#" wire:model.live="systemConfig.ticketNumberPrefix">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Minimalna długość numeru (uzupełniana zerami z przodu)</label>
|
||||
<input class="input" type="number" min="1" max="10" wire:model.live="systemConfig.ticketNumberMinLength">
|
||||
</div>
|
||||
<label class="radio"><input type="checkbox" wire:model.live="systemConfig.ticketNumberObfuscate" style="position:static;opacity:1;width:auto;height:auto">Ukryj kolejność zgłoszeń (numer wyświetlany jako suma kontrolna zamiast kolejnego numeru)</label>
|
||||
<div class="text-muted" style="font-size:12px">
|
||||
ID z bazy: {{ $this->ticketNumberPreview['id'] }} → podgląd numeru: {{ $this->ticketNumberPreview['formatted'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:20px;gap:14px">
|
||||
@@ -581,46 +681,14 @@ $tabGroups = [
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveMailConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">E-mail (SMTP)</h4>
|
||||
<div class="field"><label>Adres nadawcy</label><input class="input" type="email" placeholder="wsparcie@firma.pl" wire:model="mailConfig.fromAddress"></div>
|
||||
<div class="field"><label>Nazwa nadawcy</label><input class="input" placeholder="Zespół Wsparcia" wire:model="mailConfig.fromName"></div>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Stopka wiadomości e-mail edytowana jest w zakładce „Szablony e-mail”.</p>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="mailConfig.smtpEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz wysyłkę przez własny serwer SMTP</strong></label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez włączenia aplikacja wysyła pocztę zgodnie z konfiguracją środowiska (.env).</span>
|
||||
|
||||
@if ($mailConfig['smtpEnabled'])
|
||||
<div class="field"><label>Host SMTP</label><input class="input" placeholder="smtp.example.com" wire:model="mailConfig.smtpHost"></div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="587" wire:model="mailConfig.smtpPort"></div>
|
||||
<div class="field" style="flex:1">
|
||||
<label>Szyfrowanie</label>
|
||||
<select class="input" wire:model="mailConfig.smtpEncryption">
|
||||
<option value="none">Brak</option>
|
||||
<option value="tls">STARTTLS</option>
|
||||
<option value="ssl">SSL/TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Użytkownik</label><input class="input" wire:model="mailConfig.smtpUsername"></div>
|
||||
<div class="field"><label>Hasło</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="mailConfig.smtpPassword"></div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testMailConnection">Wyślij testową wiadomość</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($mailTestResult === '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>Wysłano na Twój adres</div>
|
||||
@elseif ($mailTestResult === '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 wysyłki</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
{{-- ================= INTEGRATIONS ================= --}}
|
||||
@if ($tab === 'integrations')
|
||||
<h3 style="margin:0 0 16px">Integracje</h3>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:20px;align-items:start">
|
||||
|
||||
<form wire:submit="saveLdapConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">LDAP / Active Directory</h4>
|
||||
@@ -654,6 +722,86 @@ $tabGroups = [
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<form wire:submit="saveBookstackConfig" class="card" style="padding:20px;gap:14px">
|
||||
<h4 style="margin:0">Baza wiedzy BookStack</h4>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Po włączeniu, podczas tworzenia zgłoszenia klientom i operatorom podpowiadane będą pasujące artykuły z BookStack na podstawie wybranej kategorii/podkategorii.</span>
|
||||
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację z BookStack</strong></label>
|
||||
|
||||
@if ($bookstackConfig['enabled'])
|
||||
<div class="field"><label>Adres instancji BookStack</label><input class="input" placeholder="https://wiki.firma.pl" wire:model="bookstackConfig.baseUrl"></div>
|
||||
<div class="field"><label>Token ID</label><input class="input" wire:model="bookstackConfig.tokenId"></div>
|
||||
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
|
||||
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil → Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
|
||||
|
||||
<div class="field"><label>Przeszukuj</label>
|
||||
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes">
|
||||
<option value="both">Strony i książki</option>
|
||||
<option value="page">Tylko strony</option>
|
||||
<option value="book">Tylko książki</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end">
|
||||
<button type="button" class="btn btn-secondary" style="display:flex;align-items:center;gap:6px;font-size:12.5px" wire:click="refreshBookstackShelves" wire:loading.attr="disabled" wire:target="refreshBookstackShelves">
|
||||
<span class="material-symbols-outlined" style="font-size:16px" wire:loading.class="spin" wire:target="refreshBookstackShelves">refresh</span>
|
||||
Odśwież listę półek
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dozwolone półki — podpowiedzi przy tworzeniu zgłoszenia</label>
|
||||
@if (count($this->bookstackShelves))
|
||||
<div style="display:flex;flex-direction:column;gap:2px;border:1px solid var(--color-divider);border-radius:8px;padding:8px">
|
||||
@foreach ($this->bookstackShelves as $shelf)
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:4px 6px;border-radius:5px;cursor:pointer">
|
||||
<input type="checkbox" @checked(in_array($shelf['id'], $bookstackConfig['allowedShelfIdsCreation'])) wire:click="toggleBookstackAllowedShelf('allowedShelfIdsCreation', {{ $shelf['id'] }})">
|
||||
{{ $shelf['name'] }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Brak półek do wyświetlenia — sprawdź, czy połączenie działa (przycisk „Testuj połączenie” niżej), albo zapisz konfigurację, żeby odświeżyć listę.</p>
|
||||
@endif
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Tylko książki/strony z zaznaczonych półek mogą pojawić się jako podpowiedzi podczas tworzenia zgłoszenia (klient, operator, formularz gościa). Jeśli żadna półka nie jest zaznaczona, podpowiedzi się nie pojawią.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Dozwolone półki — panel operatora przy zgłoszeniu</label>
|
||||
@if (count($this->bookstackShelves))
|
||||
<div style="display:flex;flex-direction:column;gap:2px;border:1px solid var(--color-divider);border-radius:8px;padding:8px">
|
||||
@foreach ($this->bookstackShelves as $shelf)
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:4px 6px;border-radius:5px;cursor:pointer">
|
||||
<input type="checkbox" @checked(in_array($shelf['id'], $bookstackConfig['allowedShelfIdsTicketView'])) wire:click="toggleBookstackAllowedShelf('allowedShelfIdsTicketView', {{ $shelf['id'] }})">
|
||||
{{ $shelf['name'] }}
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Brak półek do wyświetlenia — sprawdź, czy połączenie działa (przycisk „Testuj połączenie” niżej), albo zapisz konfigurację, żeby odświeżyć listę.</p>
|
||||
@endif
|
||||
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Niezależna lista — kontroluje, co widzi operator w bocznym panelu po otwarciu istniejącego zgłoszenia (link do artykułu lub przycisk kopiowania linku). Jeśli żadna półka nie jest zaznaczona, panel się nie pokaże.</p>
|
||||
</div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.showToGuests" style="position:static;opacity:1;width:auto;height:auto">Pokazuj podpowiedzi także niezalogowanym (formularz zgłoszenia na stronie głównej)</label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Bez zaznaczenia podpowiedzi widoczne są tylko przy tworzeniu zgłoszenia przez zalogowanego klienta lub operatora.</span>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="bookstackConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL instancji BookStack</label>
|
||||
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja BookStack korzysta z certyfikatu self-signed / z prywatnego CA.</span>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
|
||||
<button type="submit" class="btn btn-primary">Zapisz</button>
|
||||
@if ($bookstackTestResult === 'ok')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
|
||||
@elseif ($bookstackTestResult === 'error')
|
||||
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $bookstackTestMessage ? ': '.$bookstackTestMessage : '' }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -667,7 +815,7 @@ $tabGroups = [
|
||||
<h3 style="margin:0 0 14px">O aplikacji</h3>
|
||||
<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">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>
|
||||
@endif
|
||||
@@ -723,6 +871,96 @@ $tabGroups = [
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($automationRuleFormOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submitAutomationRule" class="dialog" style="max-width:520px">
|
||||
<div class="dialog-title">{{ $automationRuleForm['id'] ? 'Edytuj regułę automatyzacji' : 'Nowa reguła automatyzacji' }}</div>
|
||||
<div class="field"><label>Nazwa reguły</label><input class="input" wire:model="automationRuleForm.label" placeholder="np. Eskalacja przy braku odpowiedzi"></div>
|
||||
@error('automationRuleForm.label') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
<div class="field">
|
||||
<label>Brak odpowiedzi klienta przez (minuty)</label>
|
||||
<input class="input" type="number" min="1" wire:model="automationRuleForm.condition_minutes">
|
||||
</div>
|
||||
@error('automationRuleForm.condition_minutes') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
<div class="field">
|
||||
<label>Zawężenie (opcjonalne — puste = dowolne)</label>
|
||||
<select class="input" wire:model="automationRuleForm.scope_priority_key" style="margin-bottom:6px">
|
||||
<option value="">Dowolny priorytet</option>
|
||||
@foreach ($this->priorities as $p)
|
||||
<option value="{{ $p->key }}">{{ $p->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select class="input" wire:model="automationRuleForm.scope_subcategory_id" style="margin-bottom:6px">
|
||||
<option value="">Dowolna kategoria</option>
|
||||
@foreach ($this->subcategoriesForTeamForm as $s)
|
||||
<option value="{{ $s['id'] }}">{{ $s['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select class="input" wire:model="automationRuleForm.scope_team_id">
|
||||
<option value="">Dowolny zespół</option>
|
||||
@foreach ($this->teams as $t)
|
||||
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Akcja</label>
|
||||
<select class="input" wire:model.live="automationRuleForm.action_type">
|
||||
<option value="change_priority">Zmień priorytet</option>
|
||||
<option value="change_status">Zmień status</option>
|
||||
<option value="change_team">Zmień zespół</option>
|
||||
<option value="change_assignee">Zmień przypisanie</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nowa wartość</label>
|
||||
@switch ($automationRuleForm['action_type'])
|
||||
@case ('change_priority')
|
||||
<select class="input" wire:model="automationRuleForm.action_value">
|
||||
<option value="">— wybierz —</option>
|
||||
@foreach ($this->priorities as $p)
|
||||
<option value="{{ $p->key }}">{{ $p->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@break
|
||||
@case ('change_status')
|
||||
<select class="input" wire:model="automationRuleForm.action_value">
|
||||
<option value="">— wybierz —</option>
|
||||
@foreach ($this->statuses as $s)
|
||||
<option value="{{ $s->key }}">{{ $s->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@break
|
||||
@case ('change_team')
|
||||
<select class="input" wire:model="automationRuleForm.action_value">
|
||||
<option value="">— wybierz —</option>
|
||||
@foreach ($this->teams as $t)
|
||||
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@break
|
||||
@case ('change_assignee')
|
||||
<select class="input" wire:model="automationRuleForm.action_value">
|
||||
<option value="">— wybierz —</option>
|
||||
@foreach ($this->operatorsForTeamForm as $o)
|
||||
<option value="{{ $o['id'] }}">{{ $o['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@break
|
||||
@endswitch
|
||||
</div>
|
||||
@error('automationRuleForm.action_value') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button class="btn btn-secondary" type="button" wire:click="closeAutomationRuleForm">Anuluj</button>
|
||||
<button class="btn btn-primary" type="submit">{{ $automationRuleForm['id'] ? 'Zapisz' : 'Dodaj regułę' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->editingTemplate)
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog" style="max-width:560px">
|
||||
|
||||
303
src/resources/views/livewire/admin/triggers.blade.php
Normal file
303
src/resources/views/livewire/admin/triggers.blade.php
Normal file
@@ -0,0 +1,303 @@
|
||||
@php
|
||||
$eventLabels = \App\Livewire\Admin\Triggers::eventLabels();
|
||||
$fieldLabels = \App\Livewire\Admin\Triggers::fieldLabels();
|
||||
$operatorLabels = \App\Livewire\Admin\Triggers::operatorLabels();
|
||||
$actionTypeLabels = \App\Livewire\Admin\Triggers::actionTypeLabels();
|
||||
@endphp
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="margin:0">Wyzwalacze</h3>
|
||||
<button class="btn btn-primary" type="button" wire:click="openForm">+ Nowy wyzwalacz</button>
|
||||
</div>
|
||||
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
|
||||
Wyzwalacze reagują natychmiast na zdarzenie w zgłoszeniu (utworzenie, zmiana pola, nowy komentarz) — w odróżnieniu od Automatyzacji SLA (zakładka obok), która działa na podstawie czasu milczenia klienta. Warunki wyzwalacza muszą być spełnione wszystkie naraz (ORAZ); akcje wykonują się w podanej kolejności.
|
||||
</p>
|
||||
|
||||
@if ($this->triggers->isNotEmpty())
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nazwa</th>
|
||||
<th>Zdarzenie</th>
|
||||
<th>Warunki</th>
|
||||
<th>Akcje</th>
|
||||
<th>Aktywny</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($this->triggers as $trigger)
|
||||
<tr wire:key="trigger-{{ $trigger->id }}">
|
||||
<td style="white-space:nowrap">
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveUp({{ $trigger->id }})" title="Przenieś wyżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveDown({{ $trigger->id }})" title="Przenieś niżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
|
||||
</button>
|
||||
</td>
|
||||
<td style="white-space:nowrap">{{ $trigger->name }}</td>
|
||||
<td><span class="tag tag-outline">{{ $eventLabels[$trigger->event] ?? $trigger->event }}</span></td>
|
||||
<td class="text-muted" style="font-size:12px">
|
||||
{{ count($trigger->conditions) }} {{ count($trigger->conditions) === 1 ? 'warunek' : 'warunków' }}
|
||||
</td>
|
||||
<td class="text-muted" style="font-size:12px">
|
||||
{{ count($trigger->actions) }} {{ count($trigger->actions) === 1 ? 'akcja' : 'akcji' }}
|
||||
</td>
|
||||
<td><input type="checkbox" @checked($trigger->enabled) wire:click="toggleEnabled({{ $trigger->id }})"></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="editTrigger({{ $trigger->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeTrigger({{ $trigger->id }})" wire:confirm="Usunąć wyzwalacz „{{ $trigger->name }}”?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak wyzwalaczy. Utwórz pierwszy używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
<div class="hr" style="margin:22px 0"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<div>
|
||||
<h3 style="margin:0">Szablony e-mail wyzwalaczy</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:4px 0 0">
|
||||
Osobne od szablonów w zakładce „Szablony e-mail” (te są przypisane na stałe do zdarzeń systemowych) — te
|
||||
tutaj możesz dowolnie dodawać, edytować i usuwać, do wykorzystania w akcji „Wyślij powiadomienie e-mail” wyzwalacza.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" wire:click="openTemplateForm" style="flex:none">+ Nowy szablon</button>
|
||||
</div>
|
||||
|
||||
@if ($this->emailTemplates->isNotEmpty())
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Nazwa</th><th>Temat</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($this->emailTemplates as $template)
|
||||
<tr wire:key="trigger-template-{{ $template->id }}">
|
||||
<td style="white-space:nowrap">{{ $template->name }}</td>
|
||||
<td>{{ $template->subject }}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn btn-ghost" type="button" wire:click="editTemplate({{ $template->id }})">Edytuj</button>
|
||||
<button class="btn btn-ghost" type="button" wire:click="removeTemplate({{ $template->id }})" wire:confirm="Usunąć szablon „{{ $template->name }}”?">Usuń</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted" style="font-size:13px">Brak szablonów. Dodaj pierwszy używając przycisku wyżej.</p>
|
||||
@endif
|
||||
|
||||
@if ($templateFormOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submitTemplate" class="dialog" style="max-width:560px;max-height:88vh;overflow:auto">
|
||||
<div class="dialog-title">{{ $editingTemplateId ? 'Edytuj szablon' : 'Nowy szablon' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa szablonu</label>
|
||||
<input class="input" wire:model="templateForm.name" placeholder="np. Przypomnienie o braku odpowiedzi">
|
||||
@error('templateForm.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Temat wiadomości</label>
|
||||
<input class="input" wire:model="templateForm.subject">
|
||||
@error('templateForm.subject') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Treść</label>
|
||||
<x-quill-editor wire:key="trigger-template-body-{{ $editingTemplateId ?? 'new' }}" :value="$templateForm['body']" on-change="setTemplateBodyDraft" min-height="160px" />
|
||||
@error('templateForm.body') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Dostępne zmienne: {numer}, {imie}, {temat}, {status}, {kategoria}, {priorytet}, {zespol}, {operator}, {link}. Ta treść trafia do wspólnego szablonu-pudełka (zakładka „E-MAIL”) w miejscu {tresc}.</p>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="closeTemplateForm">Anuluj</button>
|
||||
<button type="submit" class="btn btn-primary">{{ $editingTemplateId ? 'Zapisz' : 'Utwórz' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($formOpen)
|
||||
<div class="dialog-backdrop">
|
||||
<form wire:submit="submit" class="dialog" style="max-width:640px;max-height:88vh;overflow:auto">
|
||||
<div class="dialog-title">{{ $editingId ? 'Edytuj wyzwalacz' : 'Nowy wyzwalacz' }}</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa</label>
|
||||
<input class="input" wire:model="form.name" placeholder="np. Priorytet krytyczny → zespół VIP">
|
||||
@error('form.name') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Zdarzenie</label>
|
||||
<select class="input" wire:model="form.event">
|
||||
@foreach ($eventLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="radio"><input type="checkbox" wire:model="form.enabled" style="position:static;opacity:1;width:auto;height:auto">Aktywny</label>
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<label style="font-weight:500">Warunki (wszystkie muszą być spełnione)</label>
|
||||
<button type="button" class="btn btn-ghost" wire:click="addCondition">+ Dodaj warunek</button>
|
||||
</div>
|
||||
|
||||
@foreach ($form['conditions'] as $i => $condition)
|
||||
<div wire:key="condition-{{ $i }}" style="display:flex;gap:6px;align-items:flex-start">
|
||||
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.field">
|
||||
@foreach ($fieldLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select class="input" style="flex:1" wire:model.live="form.conditions.{{ $i }}.operator">
|
||||
@foreach ($operatorLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@if (! in_array($condition['operator'], ['is_empty', 'is_not_empty']))
|
||||
@if (($condition['field'] ?? null) === 'status_key')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->statuses as $status)
|
||||
<option value="{{ $status->key }}">{{ $status->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'priority_key')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->priorities as $priority)
|
||||
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'team_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'assignee_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->operators as $operator)
|
||||
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'subcategory_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->subcategories as $sub)
|
||||
<option value="{{ $sub->id }}">{{ $sub->category->name }} / {{ $sub->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($condition['field'] ?? null) === 'customer_id')
|
||||
<select class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value">
|
||||
@foreach ($this->customers as $customer)
|
||||
<option value="{{ $customer->id }}">{{ $customer->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<input class="input" style="flex:1" wire:model="form.conditions.{{ $i }}.value" placeholder="wartość">
|
||||
@endif
|
||||
@else
|
||||
<div style="flex:1"></div>
|
||||
@endif
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeCondition({{ $i }})" title="Usuń warunek">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
@if (empty($form['conditions']))
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Brak warunków — wyzwalacz zadziała za każdym razem, gdy wybrane zdarzenie wystąpi.</p>
|
||||
@endif
|
||||
|
||||
<div class="hr"></div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<label style="font-weight:500">Akcje (wykonywane po kolei)</label>
|
||||
<button type="button" class="btn btn-ghost" wire:click="addAction">+ Dodaj akcję</button>
|
||||
</div>
|
||||
@error('form.actions') <span style="color:var(--color-danger);font-size:12px">{{ $message }}</span> @enderror
|
||||
|
||||
@foreach ($form['actions'] as $i => $action)
|
||||
<div wire:key="action-{{ $i }}" class="card" style="padding:10px;gap:6px">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<select class="input" style="flex:1" wire:model.live="form.actions.{{ $i }}.type">
|
||||
@foreach ($actionTypeLabels as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionUp({{ $i }})" title="Przenieś wyżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 4px" wire:click="moveActionDown({{ $i }})" title="Przenieś niżej">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" style="padding:0 6px" wire:click="removeAction({{ $i }})" title="Usuń akcję">
|
||||
<span class="material-symbols-outlined" style="font-size:16px">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (($action['type'] ?? null) === 'set_status')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->statuses as $status)
|
||||
<option value="{{ $status->key }}">{{ $status->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_priority')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->priorities as $priority)
|
||||
<option value="{{ $priority->key }}">{{ $priority->label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_team')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->teams as $team)
|
||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'set_assignee')
|
||||
<select class="input" wire:model="form.actions.{{ $i }}.value">
|
||||
@foreach ($this->operators as $operator)
|
||||
<option value="{{ $operator->id }}">{{ $operator->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@elseif (($action['type'] ?? null) === 'send_notification')
|
||||
<div style="display:flex;gap:6px">
|
||||
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.recipient">
|
||||
<option value="client">Zgłaszający</option>
|
||||
<option value="operator">Przypisany operator</option>
|
||||
</select>
|
||||
<select class="input" style="flex:1" wire:model="form.actions.{{ $i }}.email_template_id">
|
||||
<option value="">— wybierz szablon —</option>
|
||||
@foreach ($this->emailTemplates as $template)
|
||||
<option value="{{ $template->id }}">{{ $template->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="closeForm">Anuluj</button>
|
||||
<button type="submit" class="btn btn-primary">{{ $editingId ? 'Zapisz' : 'Utwórz' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
<div class="field">
|
||||
<label>Nazwa użytkownika</label>
|
||||
<input class="input" wire:model="username" autofocus>
|
||||
<input class="input" wire:model="username" autofocus required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Hasło</label>
|
||||
<input class="input" type="password" wire:model="password">
|
||||
<input class="input" type="password" wire:model="password" required>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" type="submit">Zaloguj się</button>
|
||||
</form>
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
<a href="{{ route('client.new') }}" wire:navigate class="btn btn-primary">+ Nowe zgłoszenie</a>
|
||||
</div>
|
||||
|
||||
<div class="seg" style="align-self:flex-start">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<div class="seg">
|
||||
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->count() }})</label>
|
||||
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === '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 style="display:flex;flex-direction:column;gap:10px">
|
||||
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket)
|
||||
<a href="{{ route('client.ticket', $ticket) }}" wire:navigate class="card elev-sm" style="padding:16px;cursor:pointer;flex-direction:row;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;text-decoration:none;color:inherit">
|
||||
<div>
|
||||
<div style="font-weight:500">#{{ $ticket->number }} — {{ $ticket->subject }}</div>
|
||||
<div style="font-weight:500">{{ $ticket->displayNumber() }} — {{ $ticket->subject }}</div>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div wire:init="loadSuggestedArticles">
|
||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Temat</label>
|
||||
<input class="input" wire:model="subject">
|
||||
@@ -76,8 +81,16 @@
|
||||
|
||||
<div class="field">
|
||||
<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>
|
||||
<span class="text-muted" style="font-size:12px">lub przeciągnij pliki tutaj</span>
|
||||
@forelse ($attachments as $i => $file)
|
||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
|
||||
{{ $file->getClientOriginalName() }}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
<div style="flex:1;display:flex;flex-direction:column">
|
||||
<x-topbar />
|
||||
|
||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:1180px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">← Wróć do listy</a>
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently — this is a periodic fallback refresh, with a visible
|
||||
countdown so it's clear the thread is still refreshing on its own. --}}
|
||||
<div
|
||||
class="btn btn-secondary"
|
||||
style="cursor:default;gap:6px"
|
||||
x-data="{ remaining: 30, total: 30 }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||
title="Zgłoszenie odświeża się automatycznie"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||
<span x-text="remaining + 's'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||
@@ -12,7 +28,7 @@
|
||||
@endphp
|
||||
|
||||
<div class="card" style="padding:22px;gap:10px">
|
||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||
<h2 style="margin:2px 0 0">{{ $ticket->subject }}</h2>
|
||||
<div class="card-meta">{{ $ticket->categoryLabel() }} · utworzono {{ \App\Support\Rel::format($ticket->created_at) }}</div>
|
||||
<div style="white-space:pre-wrap;font-size:14px;margin-top:4px">{{ $ticket->body }}</div>
|
||||
@@ -31,7 +47,7 @@
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
@foreach ($threadMessages as $m)
|
||||
@php $mine = $m->role === 'client' && $m->author_id === auth()->id(); @endphp
|
||||
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
||||
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
||||
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \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>
|
||||
@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="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>
|
||||
@forelse ($attachments as $i => $file)
|
||||
<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 class="text-muted" style="font-size:13px">{{ auth()->user()->email }}</div>
|
||||
</div>
|
||||
<div wire:init="loadSuggestedArticles">
|
||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:16px;gap:10px">
|
||||
<div class="card-kicker">Status i priorytet</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
|
||||
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px" class="text-muted">Przypisany operator: <span style="color:var(--color-text)">{{ $ticket->assignee?->name ?? 'Nieprzypisane' }}</span></div>
|
||||
<div style="font-size:13px" class="text-muted">Zespół: <span style="color:var(--color-text)">{{ $ticket->team?->name ?? 'Brak' }}</span></div>
|
||||
@if (! $ticket->isClosed())
|
||||
<button type="button" class="btn btn-secondary btn-block" wire:click="close">Zamknij zgłoszenie</button>
|
||||
@elseif ($ticket->isClosed())
|
||||
<button type="button" class="btn btn-secondary btn-block" wire:click="reopen">Otwórz ponownie</button>
|
||||
@endif
|
||||
</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-kicker">Historia zmian</div>
|
||||
@forelse ($ticket->histories as $h)
|
||||
<div style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
|
||||
<div wire:key="history-{{ $h->id }}" style="font-size:12.5px"><span>{{ $h->text }}</span><div class="text-muted" style="font-size:11px">{{ \App\Support\Rel::format($h->created_at) }}</div></div>
|
||||
@empty
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Brak historii zmian.</p>
|
||||
@endforelse
|
||||
@@ -111,7 +168,7 @@
|
||||
<div class="card-kicker">Inne Twoje zgłoszenia</div>
|
||||
@forelse ($this->otherTickets as $ot)
|
||||
<a href="{{ route('client.ticket', $ot) }}" wire:navigate style="display:flex;justify-content:space-between;align-items:center;gap:8px;cursor:pointer;text-decoration:none;color:inherit">
|
||||
<span style="font-size:13px">#{{ $ot->number }} — {{ $ot->subject }}</span>
|
||||
<span style="font-size:13px">{{ $ot->displayNumber() }} — {{ $ot->subject }}</span>
|
||||
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
|
||||
</a>
|
||||
@empty
|
||||
@@ -134,4 +191,19 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@script
|
||||
<script>
|
||||
// Re-runs on every mount of this component, including after a
|
||||
// wire:navigate to a different ticket, so the socket subscription
|
||||
// always matches whichever ticket is currently on screen.
|
||||
if (window.subscribeToTicketChannel) {
|
||||
window.subscribeToTicketChannel({{ $ticket->id }});
|
||||
} else {
|
||||
// echo.js (a deferred module) hasn't run yet — queue the id so it
|
||||
// subscribes as soon as it does, instead of silently doing nothing.
|
||||
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
|
||||
}
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
@if ($this->submittedTicket)
|
||||
<div class="card elev-md" style="padding:32px;gap:14px;text-align:left">
|
||||
<span class="tag tag-accent" style="align-self:flex-start">Zgłoszenie przyjęte</span>
|
||||
<h2 style="margin:0">Zgłoszenie #{{ $this->submittedTicket->number }} zostało utworzone</h2>
|
||||
<h2 style="margin:0">Zgłoszenie {{ $this->submittedTicket->displayNumber() }} zostało utworzone</h2>
|
||||
<p class="text-muted" style="margin:0">Zapisz numer zgłoszenia i adres e-mail — będziesz mógł/mogła sprawdzić status, kontaktując się z zespołem wsparcia. Aktualizacje będziemy wysyłać na Twój adres e-mail.</p>
|
||||
<div class="hr"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;font-size:14px">
|
||||
<div><strong>Numer zgłoszenia:</strong> #{{ $this->submittedTicket->number }}</div>
|
||||
<div><strong>Numer zgłoszenia:</strong> {{ $this->submittedTicket->displayNumber() }}</div>
|
||||
<div><strong>Temat:</strong> {{ $this->submittedTicket->subject }}</div>
|
||||
<div><strong>Kategoria:</strong> {{ $this->submittedTicket->categoryLabel() }}</div>
|
||||
<div><strong>Zgłaszający:</strong> {{ $this->submittedTicket->email }}</div>
|
||||
@@ -89,6 +89,11 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div wire:init="loadSuggestedArticles">
|
||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Temat</label>
|
||||
<input class="input" placeholder="Krótki opis problemu" wire:model="subject">
|
||||
|
||||
38
src/resources/views/livewire/notification-bell.blade.php
Normal file
38
src/resources/views/livewire/notification-bell.blade.php
Normal 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>
|
||||
@@ -65,6 +65,11 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div wire:init="loadSuggestedArticles">
|
||||
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Temat</label>
|
||||
<input class="input" wire:model="subject">
|
||||
@@ -82,8 +87,16 @@
|
||||
|
||||
<div class="field">
|
||||
<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>
|
||||
<span class="text-muted" style="font-size:12px">lub przeciągnij pliki tutaj</span>
|
||||
@forelse ($attachments as $i => $file)
|
||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px">
|
||||
{{ $file->getClientOriginalName() }}
|
||||
|
||||
@@ -74,6 +74,34 @@
|
||||
@endforeach
|
||||
</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 }">
|
||||
<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>
|
||||
@@ -88,6 +116,21 @@
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently (backgrounded tab, network blip) — this is a periodic
|
||||
fallback refresh, with a visible countdown so it's clear the queue
|
||||
is still refreshing itself rather than just stuck. --}}
|
||||
<div
|
||||
class="btn btn-secondary"
|
||||
style="cursor:default;gap:6px"
|
||||
x-data="{ remaining: 60, total: 60 }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)"
|
||||
title="Kolejka odświeża się automatycznie co minutę"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||
<span x-text="remaining + 's'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
@@ -115,10 +158,10 @@
|
||||
<tbody>
|
||||
@foreach ($this->filteredTickets as $t)
|
||||
@php $sla = $t->slaInfo(); @endphp
|
||||
<tr>
|
||||
<tr wire:key="ticket-{{ $t->id }}">
|
||||
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
|
||||
@if (in_array('number', $visibleColumns))
|
||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->number }}</a></td>
|
||||
<td data-label="Numer" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a></td>
|
||||
@endif
|
||||
@if (in_array('subject', $visibleColumns))
|
||||
<td data-label="Temat" class="td-title"><a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer;white-space:nowrap">{{ $t->subject }}</a></td>
|
||||
@@ -129,6 +172,9 @@
|
||||
@if (in_array('category', $visibleColumns))
|
||||
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
|
||||
@endif
|
||||
@if (in_array('subcategory', $visibleColumns))
|
||||
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
|
||||
@endif
|
||||
@if (in_array('priority', $visibleColumns))
|
||||
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
|
||||
@endif
|
||||
@@ -141,6 +187,12 @@
|
||||
@if (in_array('assignee', $visibleColumns))
|
||||
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
|
||||
@endif
|
||||
@if (in_array('team', $visibleColumns))
|
||||
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
|
||||
@endif
|
||||
@if (in_array('created', $visibleColumns))
|
||||
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
||||
@@ -52,10 +52,17 @@
|
||||
<option value="{{ $u->id }}">{{ $u->name }}</option>
|
||||
@endforeach
|
||||
</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>
|
||||
|
||||
{{-- KPI tiles --}}
|
||||
@php($kpis = $this->kpis)
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<h3 style="margin:0">Podsumowanie</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(160px, 1fr));gap:12px">
|
||||
<div class="stat-tile">
|
||||
<div class="stat-tile-label">Łącznie zgłoszeń</div>
|
||||
@@ -85,10 +92,18 @@
|
||||
</div>
|
||||
<div class="stat-tile-meta">{{ $kpis['sla']['breached'] }} / {{ $kpis['sla']['total'] }} zgłoszeń</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>
|
||||
|
||||
{{-- Rozkład zgłoszeń --}}
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<h3 style="margin:0">Rozkład zgłoszeń</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||
{{-- By status --}}
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg statusu</div>
|
||||
@php($max = max($this->byStatus->max('count'), 1))
|
||||
@@ -103,7 +118,6 @@
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- By priority --}}
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg priorytetu</div>
|
||||
@php($max = max($this->byPriority->max('count'), 1))
|
||||
@@ -118,7 +132,6 @@
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- By category --}}
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg kategorii</div>
|
||||
@php($cats = $this->byCategory)
|
||||
@@ -134,7 +147,27 @@
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- By team --}}
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:12px">Zgłoszenia wg podkategorii</div>
|
||||
@php($subs = $this->bySubcategory)
|
||||
@php($max = max($subs->max('count') ?? 0, 1))
|
||||
@forelse ($subs as $row)
|
||||
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['count'] }}">
|
||||
<div class="bar-row-label">{{ $row['label'] }}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['count'] / $max * 100 }}%;background:var(--color-accent)"></div></div>
|
||||
<div class="bar-row-value">{{ $row['count'] }}</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="card-meta">Brak danych w wybranym okresie.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Obciążenie --}}
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<h3 style="margin:0">Obciążenie</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:12px">Obciążenie zespołów</div>
|
||||
@php($teamRows = $this->byTeam)
|
||||
@@ -150,7 +183,6 @@
|
||||
@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)
|
||||
@@ -166,11 +198,109 @@
|
||||
@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 class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:4px">Klienci wg podkategorii</div>
|
||||
<div class="card-meta" style="margin-bottom:12px">Top 10 klientów × top 5 podkategorii wg wolumenu w wybranym okresie; reszta zbiorczo w kolumnie „Inne”.</div>
|
||||
@php($matrix = $this->customerSubcategoryMatrix)
|
||||
@if (count($matrix['rows']))
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Klient</th>
|
||||
@foreach ($matrix['columns'] as $col)
|
||||
<th>{{ $col }}</th>
|
||||
@endforeach
|
||||
@if ($matrix['hasOther'])
|
||||
<th>Inne</th>
|
||||
@endif
|
||||
<th>Razem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($matrix['rows'] as $row)
|
||||
<tr>
|
||||
<td style="white-space:nowrap">{{ $row['label'] }}</td>
|
||||
@foreach ($row['cells'] as $cell)
|
||||
<td>{{ $cell ?: '—' }}</td>
|
||||
@endforeach
|
||||
@if ($matrix['hasOther'])
|
||||
<td>{{ $row['other'] ?: '—' }}</td>
|
||||
@endif
|
||||
<td style="font-weight:600">{{ $row['total'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@else
|
||||
<div class="card-meta">Brak danych w wybranym okresie.</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ocena obsługi (CSAT) --}}
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<h3 style="margin:0">Ocena obsługi (CSAT)</h3>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(340px, 1fr));gap:16px;align-items:start">
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:12px">CSAT wg zespołu</div>
|
||||
@php($csatTeamRows = $this->csatByTeam)
|
||||
@forelse ($csatTeamRows as $row)
|
||||
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
|
||||
<div class="bar-row-label">{{ $row['label'] }}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
|
||||
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="card-meta">Brak ocen w wybranym okresie.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title" style="margin-bottom:12px">CSAT wg operatora</div>
|
||||
@php($csatOpRows = $this->csatByAssignee)
|
||||
@forelse ($csatOpRows as $row)
|
||||
<div class="bar-row" title="{{ $row['label'] }}: {{ $row['avg'] }} / 5 ({{ $row['count'] }} ocen)">
|
||||
<div class="bar-row-label">{{ $row['label'] }}</div>
|
||||
<div class="bar-track"><div class="bar-fill" style="width:{{ $row['avg'] / 5 * 100 }}%;background:var(--color-accent)"></div></div>
|
||||
<div class="bar-row-value">{{ $row['avg'] }} / 5</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="card-meta">Brak ocen w wybranym okresie.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Trend --}}
|
||||
@php($trend = $this->trend)
|
||||
@php($trendMax = max(collect($trend)->max('created'), collect($trend)->max('closed'), 1))
|
||||
@php($labelStep = max((int) ceil(count($trend) / 12), 1))
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<h3 style="margin:0">Trend w czasie</h3>
|
||||
<div class="card" style="padding:16px">
|
||||
<div class="card-title">Trend zgłoszeń</div>
|
||||
<div class="card-meta" style="margin-bottom:12px">Utworzone i zamknięte w czasie (maks. ostatnie 60 dni okresu).</div>
|
||||
@@ -214,4 +344,5 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,41 @@
|
||||
<x-topbar />
|
||||
|
||||
<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">
|
||||
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="align-self:flex-start;padding:0">← Wróć do listy</a>
|
||||
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
|
||||
<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;margin-right:auto">← Wróć do listy</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
style="gap:6px"
|
||||
wire:click="toggleWatch"
|
||||
title="{{ $this->isWatching ? 'Przestań obserwować to zgłoszenie' : 'Obserwuj to zgłoszenie' }}"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">{{ $this->isWatching ? 'star' : 'star_outline' }}</span>
|
||||
{{ $this->isWatching ? 'Obserwowane' : 'Obserwuj' }}
|
||||
</button>
|
||||
|
||||
{{-- Live updates arrive via broadcasting, but websocket connections can
|
||||
drop silently — this is a periodic fallback refresh, with a visible
|
||||
countdown so it's clear the thread is still refreshing on its own. --}}
|
||||
<div
|
||||
class="btn btn-secondary"
|
||||
style="cursor:default;gap:6px"
|
||||
x-data="{ remaining: 30, total: 30 }"
|
||||
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)"
|
||||
title="Zgłoszenie odświeża się automatycznie"
|
||||
>
|
||||
<span class="material-symbols-outlined" style="font-size:18px">schedule</span>
|
||||
<span x-text="remaining + 's'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div class="main-col" style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="card" style="padding:20px;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div class="card-kicker">Zgłoszenie #{{ $ticket->number }}</div>
|
||||
<div class="card-kicker">Zgłoszenie {{ $ticket->displayNumber() }}</div>
|
||||
@unless ($editingDetails)
|
||||
<button type="button" class="btn btn-ghost" style="padding:0" wire:click="toggleEditDetails">Edytuj</button>
|
||||
@endunless
|
||||
@@ -67,7 +94,7 @@
|
||||
<div class="card" style="padding:16px;gap:10px">
|
||||
<div class="card-kicker">Notatki wewnętrzne</div>
|
||||
@forelse ($this->internalMessages as $m)
|
||||
<div style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
|
||||
<div wire:key="note-{{ $m->id }}" style="padding:10px 12px;border-left:3px solid var(--color-accent);background:var(--color-surface);border-radius:4px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}</div>
|
||||
@if ($m->role === 'operator')
|
||||
@@ -96,7 +123,14 @@
|
||||
@if ($addingNote)
|
||||
<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="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>
|
||||
@forelse ($noteAttachments as $i => $file)
|
||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
||||
@@ -123,7 +157,7 @@
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
@foreach ($threadMessages as $m)
|
||||
@php $mine = $m->role === 'operator'; @endphp
|
||||
<div style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
||||
<div wire:key="msg-{{ $m->id }}" style="display:flex;justify-content:{{ $mine ? 'flex-end' : 'flex-start' }}">
|
||||
<div style="max-width:75%;padding:10px 14px;border-radius:12px;font-size:14px;background:{{ $mine ? 'var(--color-accent-800)' : 'var(--color-surface)' }};color:{{ $mine ? 'var(--color-accent-100)' : 'var(--color-text)' }};border:1px solid var(--color-divider)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px">
|
||||
<div style="font-size:11px;opacity:0.65;margin-bottom:4px">{{ $m->author_name }} · {{ \App\Support\Rel::format($m->created_at) }}{{ $m->edited ? ' · edytowano' : '' }}</div>
|
||||
@@ -160,7 +194,14 @@
|
||||
</select>
|
||||
<textarea class="input" placeholder="Napisz odpowiedź do klienta…" wire:model="reply"></textarea>
|
||||
<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>
|
||||
@forelse ($replyAttachments as $i => $file)
|
||||
<span class="text-muted" style="font-size:13px;display:flex;align-items:center;gap:6px;min-width:0">
|
||||
@@ -190,7 +231,7 @@
|
||||
</form>
|
||||
</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 style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
|
||||
<div class="card-kicker">Zgłaszający</div>
|
||||
@@ -272,11 +313,29 @@
|
||||
</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-kicker">SLA</div>
|
||||
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</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-kicker">MONITOR CZASU PRACY</div>
|
||||
<div
|
||||
@@ -344,6 +403,12 @@
|
||||
<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>
|
||||
</div>
|
||||
@if ($ticket->isClosed())
|
||||
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-start">
|
||||
<span style="font-size:12px;opacity:0.7">Zgłoszenie zamknięte — zliczanie wstrzymane</span>
|
||||
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
|
||||
</div>
|
||||
@else
|
||||
<div style="display:flex;gap:6px">
|
||||
@if ($ticket->timer_started_at)
|
||||
<button type="button" class="btn btn-secondary" wire:click="stopTimer" @click="running = false; clearInterval(tick)">Zatrzymaj</button>
|
||||
@@ -353,6 +418,7 @@
|
||||
<button type="button" class="btn btn-secondary" wire:click="resetTimer" @click="seconds = 0; running = false; clearInterval(tick)">Resetuj</button>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,7 +456,7 @@
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog" style="max-width:400px">
|
||||
<div class="dialog-title">Potwierdź usunięcie</div>
|
||||
<div class="dialog-body">Czy na pewno usunąć zgłoszenie #{{ $ticket->number }}?</div>
|
||||
<div class="dialog-body">Czy na pewno usunąć zgłoszenie {{ $ticket->displayNumber() }}?</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" wire:click="cancelDeleteTicket">Anuluj</button>
|
||||
<button type="button" class="btn btn-primary" wire:click="confirmDeleteTicket">Usuń</button>
|
||||
@@ -398,4 +464,19 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@script
|
||||
<script>
|
||||
// Re-runs on every mount of this component, including after a
|
||||
// wire:navigate to a different ticket, so the socket subscription
|
||||
// always matches whichever ticket is currently on screen.
|
||||
if (window.subscribeToTicketChannel) {
|
||||
window.subscribeToTicketChannel({{ $ticket->id }});
|
||||
} else {
|
||||
// echo.js (a deferred module) hasn't run yet — queue the id so it
|
||||
// subscribes as soon as it does, instead of silently doing nothing.
|
||||
(window.__pendingTicketChannelIds = window.__pendingTicketChannelIds || []).push({{ $ticket->id }});
|
||||
}
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
@php
|
||||
$categoryLabels = [
|
||||
'new_ticket' => 'Nowe zgłoszenie',
|
||||
'ticket_update' => 'Aktualizacja zgłoszenia',
|
||||
'escalation' => 'Zgłoszenie eskalowane',
|
||||
];
|
||||
$scopeColumns = [
|
||||
'scope_mine' => 'Moje zgłoszenia',
|
||||
'scope_unassigned' => 'Nie przypisany',
|
||||
'scope_watched' => 'Obserwowane zgłoszenia',
|
||||
'scope_all' => 'Wszystkie zgłoszenia',
|
||||
];
|
||||
@endphp
|
||||
<div style="flex:1;display:flex;flex-direction:column">
|
||||
<x-topbar />
|
||||
|
||||
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:24px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
|
||||
<div>
|
||||
<h3 style="margin:0 0 6px">Powiadomienia</h3>
|
||||
<p class="text-muted" style="font-size:12.5px;margin:0">Wybierz, o których zgłoszeniach chcesz być informowany dzwoneczkiem w aplikacji, i przy których zdarzeniach dodatkowo wysłać Ci e-mail.</p>
|
||||
</div>
|
||||
<a href="{{ auth()->user()->isAdmin() ? route('admin.panel') : route('operator.queue') }}" wire:navigate class="btn btn-ghost" style="padding:0;flex:none">← Wróć</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div class="table-wrap">
|
||||
<table class="table" style="margin:0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
@foreach ($scopeColumns as $label)
|
||||
<th style="text-align:center">{{ $label }}</th>
|
||||
@endforeach
|
||||
<th style="text-align:center">Informuj również przez e-mail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($categoryLabels as $category => $label)
|
||||
<tr wire:key="pref-row-{{ $category }}">
|
||||
<td style="white-space:nowrap">{{ $label }}</td>
|
||||
@foreach ($scopeColumns as $field => $ignored)
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" @checked($rows[$category][$field]) wire:click="toggle('{{ $category }}', '{{ $field }}')">
|
||||
</td>
|
||||
@endforeach
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" @checked($rows[$category]['email']) wire:click="toggle('{{ $category }}', 'email')">
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:18px;gap:10px" x-data="{
|
||||
supported: typeof Notification !== 'undefined',
|
||||
permission: typeof Notification !== 'undefined' ? Notification.permission : 'unsupported',
|
||||
enabled: localStorage.getItem('browserNotificationsEnabled') === '1',
|
||||
async enable() {
|
||||
if (!this.supported) return;
|
||||
this.permission = await Notification.requestPermission();
|
||||
this.enabled = this.permission === 'granted';
|
||||
localStorage.setItem('browserNotificationsEnabled', this.enabled ? '1' : '0');
|
||||
},
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
localStorage.setItem('browserNotificationsEnabled', '0');
|
||||
},
|
||||
}">
|
||||
<h4 style="margin:0">Powiadomienia push w przeglądarce</h4>
|
||||
<p class="text-muted" style="font-size:12px;margin:0">Gdy ta karta jest otwarta, nowe zdarzenia z dzwoneczka mogą dodatkowo pojawić się jako natywne powiadomienie przeglądarki.</p>
|
||||
|
||||
<template x-if="!supported">
|
||||
<span class="text-muted" style="font-size:12px">Ta przeglądarka nie obsługuje powiadomień push.</span>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && permission === 'denied'">
|
||||
<span style="font-size:12px;color:var(--color-danger)">Powiadomienia zostały zablokowane w ustawieniach przeglądarki.</span>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && permission !== 'denied' && !enabled">
|
||||
<button type="button" class="btn btn-secondary" style="align-self:flex-start" x-on:click="enable">Włącz powiadomienia push</button>
|
||||
</template>
|
||||
|
||||
<template x-if="supported && enabled">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<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>Włączone
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost" x-on:click="disable">Wyłącz</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,14 +12,14 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::prefix('v1')->middleware('throttle:api')->group(function () {
|
||||
Route::middleware(['auth:sanctum', 'abilities:tickets:read'])->group(function () {
|
||||
Route::get('/tickets', [TicketController::class, 'index']);
|
||||
Route::get('/tickets/{ticket}', [TicketController::class, 'show']);
|
||||
Route::get('/tickets/{ticket}/messages', [TicketMessageController::class, 'index']);
|
||||
Route::get('/tickets/{ticket:id}', [TicketController::class, 'show']);
|
||||
Route::get('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'index']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'abilities:tickets:write'])->group(function () {
|
||||
Route::post('/tickets', [TicketController::class, 'store']);
|
||||
Route::patch('/tickets/{ticket}', [TicketController::class, 'update']);
|
||||
Route::post('/tickets/{ticket}/messages', [TicketMessageController::class, 'store']);
|
||||
Route::patch('/tickets/{ticket:id}', [TicketController::class, 'update']);
|
||||
Route::post('/tickets/{ticket:id}/messages', [TicketMessageController::class, 'store']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', 'abilities:dictionaries:read'])->group(function () {
|
||||
|
||||
47
src/routes/channels.php
Normal file
47
src/routes/channels.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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;
|
||||
});
|
||||
|
||||
/**
|
||||
* Every logged-in user's own private notification stream (bell realtime
|
||||
* updates + in-tab browser push, see NotificationCreated). Laravel's
|
||||
* default `App.Models.User.{id}` naming convention is kept verbatim so it
|
||||
* matches what `$notifiable->notify()` already implies, rather than
|
||||
* inventing a shorter alias.
|
||||
*/
|
||||
Broadcast::channel('App.Models.User.{id}', function ($user, int $id) {
|
||||
return $user->id === $id;
|
||||
});
|
||||
@@ -9,3 +9,4 @@ Artisan::command('inspire', function () {
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes();
|
||||
Schedule::command('automation:run-rules')->everyFifteenMinutes();
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Livewire\Operator\NewTicket as OperatorNewTicket;
|
||||
use App\Livewire\Operator\Queue as OperatorQueue;
|
||||
use App\Livewire\Operator\Stats as OperatorStats;
|
||||
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use App\Models\Ticket;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -73,3 +74,7 @@ Route::middleware(['auth', 'role:operator'])->prefix('operator')->name('operator
|
||||
Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () {
|
||||
Route::get('/', AdminPanel::class)->name('panel');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'role:operator,admin'])->prefix('settings')->name('settings.')->group(function () {
|
||||
Route::get('/notifications', NotificationPreferences::class)->name('notifications');
|
||||
});
|
||||
|
||||
172
src/tests/Feature/AutomationRulesTest.php
Normal file
172
src/tests/Feature/AutomationRulesTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
test('a rule fires once a ticket has been silent past its threshold and applies its action', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
$rule = AutomationRule::query()->create([
|
||||
'label' => 'Podnieś priorytet po 60 min',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('low');
|
||||
expect($rule->fresh()->hasFiredFor($ticket->fresh()))->toBeTrue();
|
||||
// TicketService::setPriority() writes its own "Priorytet zmieniony na: ..."
|
||||
// history line; RunAutomationRules adds a second, clearly-attributed one.
|
||||
expect($ticket->fresh()->histories()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('a rule does not fire again on the same ticket once already latched', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
AutomationRule::query()->create([
|
||||
'label' => 'Podnieś priorytet po 60 min',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
$ticket->refresh()->update(['priority_key' => 'high']); // simulate an operator manually reverting it
|
||||
Artisan::call('automation:run-rules');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a fresh client reply resets the latch so the rule can fire again after new silence', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
$client = User::query()->create(['name' => 'Klient', 'email' => 'client-auto@example.com', 'roles' => ['client']]);
|
||||
AutomationRule::query()->create([
|
||||
'label' => 'Podnieś priorytet po 60 min',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
expect($ticket->fresh()->priority_key)->toBe('low');
|
||||
|
||||
app(TicketService::class)->clientReply($ticket->fresh(), $client, 'Nadal potrzebuję pomocy');
|
||||
$ticket->refresh()->update(['last_customer_activity_at' => now()->subMinutes(90), 'priority_key' => 'high']);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('low');
|
||||
});
|
||||
|
||||
test('a rule scoped to a priority ignores tickets with a different priority', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['priority_key' => 'low', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
AutomationRule::query()->create([
|
||||
'label' => 'Tylko dla wysokiego priorytetu',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'scope_priority_key' => 'high',
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('low');
|
||||
expect($ticket->fresh()->histories()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a disabled rule never fires', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
AutomationRule::query()->create([
|
||||
'label' => 'Wyłączona reguła',
|
||||
'enabled' => false,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('a closed ticket is never matched even if it has been silent long enough', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['status_key' => 'closed', 'created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
AutomationRule::query()->create([
|
||||
'label' => 'Podnieś priorytet po 60 min',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
|
||||
expect($ticket->fresh()->priority_key)->toBe('high');
|
||||
});
|
||||
|
||||
test('two independent rules can both fire on the same ticket in one run', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$team = Team::query()->create(['name' => 'Wsparcie L2']);
|
||||
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
AutomationRule::query()->create([
|
||||
'label' => 'Podnieś priorytet',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
AutomationRule::query()->create([
|
||||
'label' => 'Przekaż do L2',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_team',
|
||||
'action_value' => (string) $team->id,
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
|
||||
$ticket->refresh();
|
||||
expect($ticket->priority_key)->toBe('low');
|
||||
expect($ticket->team_id)->toBe($team->id);
|
||||
expect($ticket->histories()->count())->toBe(4);
|
||||
});
|
||||
|
||||
test('reopening a closed ticket clears its automation logs so rules can fire again', function () {
|
||||
seedStatusesAndPriorities();
|
||||
Priority::query()->create(['key' => 'low', 'label' => 'Niski', 'color' => '#75798c', 'sort_order' => 2]);
|
||||
$ticket = makeTicket(['created_at' => now()->subMinutes(90), 'last_customer_activity_at' => now()->subMinutes(90)]);
|
||||
$rule = AutomationRule::query()->create([
|
||||
'label' => 'Podnieś priorytet',
|
||||
'enabled' => true,
|
||||
'condition_minutes' => 60,
|
||||
'action_type' => 'change_priority',
|
||||
'action_value' => 'low',
|
||||
]);
|
||||
|
||||
Artisan::call('automation:run-rules');
|
||||
expect($rule->logs()->count())->toBe(1);
|
||||
|
||||
app(TicketService::class)->setStatus($ticket->fresh(), 'closed');
|
||||
expect($rule->logs()->count())->toBe(0);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ test('admin can reset the email footer back to its default, remounting the edito
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', 'Coś innego')
|
||||
->assertSet('emailFooterVersion', 0);
|
||||
|
||||
@@ -50,11 +50,11 @@ test('admin can reset the email footer back to its default, remounting the edito
|
||||
expect(Settings::get('email_footer'))->toBe(Settings::default('email_footer'));
|
||||
});
|
||||
|
||||
test('admin can save the email footer from the Szablony e-mail tab (moved out of Konfiguracja)', function () {
|
||||
test('admin can save the email footer from the E-MAIL tab', function () {
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', '<p>Pozdrawiamy, Zespół Wsparcia</p>')
|
||||
->assertOk()
|
||||
->assertSet('emailFooterHtml', '<p>Pozdrawiamy, Zespół Wsparcia</p>');
|
||||
@@ -66,7 +66,7 @@ test('the live example preview reflects the currently saved footer', function ()
|
||||
$admin = adminUser();
|
||||
|
||||
$component = Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'templates')
|
||||
->call('setTab', 'email')
|
||||
->call('saveEmailFooter', 'Stopka na żywo');
|
||||
|
||||
expect($component->instance()->emailPreviewHtml)->toContain('Stopka na żywo')
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
use App\Livewire\Admin\Panel;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin can toggle a notification on/off but cannot add, delete, or reassign templates', function () {
|
||||
$admin = adminUser();
|
||||
$this->seed();
|
||||
$admin = User::query()->withRole('admin')->firstOrFail();
|
||||
$statusChanged = NotificationSetting::query()->where('trigger_key', 'status_changed')->firstOrFail();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
@@ -26,10 +28,11 @@ test('admin can toggle a notification on/off but cannot add, delete, or reassign
|
||||
});
|
||||
|
||||
test('admin can edit the subject and body of a trigger\'s fixed template', function () {
|
||||
$admin = adminUser();
|
||||
$this->seed();
|
||||
$admin = User::query()->withRole('admin')->firstOrFail();
|
||||
|
||||
// A bare migrated (unseeded) database has no email_templates rows yet, so
|
||||
// ticket_created's binding is still null at this point — give it one.
|
||||
// Repoint ticket_created's NotificationSetting at a template we control,
|
||||
// instead of the real seeded one, so this test's assertions are its own.
|
||||
$template = EmailTemplate::query()->create(['key' => 'tpl-created-test', 'name' => 'Nowe', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B']);
|
||||
$ticketCreated = NotificationSetting::query()->where('trigger_key', 'ticket_created')->firstOrFail();
|
||||
$ticketCreated->update(['email_template_id' => $template->id]);
|
||||
@@ -50,7 +53,7 @@ test('admin can edit the subject and body of a trigger\'s fixed template', funct
|
||||
|
||||
test('a disabled trigger sends no notification, an enabled one sends the assigned template with a working ticket link', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-new-test', 'name' => 'Nowe', 'trigger_label' => 'x',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -9,19 +10,21 @@ use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('the 6 extended triggers exist and are disabled by default, alongside the 2 enabled originals', function () {
|
||||
test('the 10 notification triggers exist, with the customer-facing lifecycle ones (and the new-ticket-for-team one) enabled by default and the rest opt-in', function () {
|
||||
$this->seed();
|
||||
$settings = NotificationSetting::query()->get()->keyBy('trigger_key');
|
||||
|
||||
expect($settings->keys()->sort()->values()->all())->toBe([
|
||||
'assignee_changed', 'category_changed', 'operator_replied', 'priority_changed',
|
||||
'sla_breached', 'status_changed', 'team_changed', 'ticket_closed', 'ticket_created',
|
||||
'ticket_created_team',
|
||||
]);
|
||||
|
||||
foreach (['ticket_created', 'status_changed'] as $key) {
|
||||
foreach (['ticket_created', 'status_changed', 'ticket_closed', 'operator_replied', 'ticket_created_team'] as $key) {
|
||||
expect($settings[$key]->enabled)->toBeTrue();
|
||||
}
|
||||
|
||||
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'ticket_closed', 'operator_replied', 'sla_breached'] as $key) {
|
||||
foreach (['category_changed', 'assignee_changed', 'priority_changed', 'team_changed', 'sla_breached'] as $key) {
|
||||
expect($settings[$key]->enabled)->toBeFalse()
|
||||
->and($settings[$key]->email_template_id)->not->toBeNull();
|
||||
}
|
||||
@@ -29,7 +32,7 @@ test('the 6 extended triggers exist and are disabled by default, alongside the 2
|
||||
|
||||
test('a disabled-by-default trigger sends nothing until an admin turns it on', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
$ticket = makeTicket();
|
||||
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
@@ -42,7 +45,7 @@ test('a disabled-by-default trigger sends nothing until an admin turns it on', f
|
||||
|
||||
test('changing the assignee fires assignee_changed with the {operator} placeholder once enabled', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'assignee_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
@@ -57,7 +60,7 @@ test('changing the assignee fires assignee_changed with the {operator} placehold
|
||||
|
||||
test('changing the team fires team_changed with the {zespol} placeholder once enabled', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'team_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
@@ -72,7 +75,7 @@ test('changing the team fires team_changed with the {zespol} placeholder once en
|
||||
|
||||
test('changing the subcategory fires category_changed, but re-saving details without changing it does not', function () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'category_changed')->update(['enabled' => true]);
|
||||
|
||||
$category = Category::query()->create(['name' => 'IT-Pomoc']);
|
||||
@@ -90,14 +93,14 @@ test('changing the subcategory fires category_changed, but re-saving details wit
|
||||
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();
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'ticket_closed')->update(['enabled' => true]);
|
||||
|
||||
// status_changed ships enabled by default, but in a bare migrated (unseeded)
|
||||
// database it has no template assigned yet — give it one so both triggers
|
||||
// actually have something to send, isolating this test from seeding order.
|
||||
// status_changed ships enabled by default — repoint it at a template we
|
||||
// control so this test's "did it wrongly fire" assertion isn't tied to
|
||||
// whatever content the real seeded template happens to have.
|
||||
$statusTemplate = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-status-test', 'name' => 'Status', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
@@ -107,12 +110,28 @@ test('closing a ticket fires both status_changed and ticket_closed', function ()
|
||||
|
||||
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 () {
|
||||
Notification::fake();
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'operator_replied')->update(['enabled' => true]);
|
||||
$ticket = makeTicket();
|
||||
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-reply@example.com', 'roles' => ['operator']]);
|
||||
@@ -121,3 +140,86 @@ test('an operator reply fires operator_replied once enabled, independent of any
|
||||
|
||||
Notification::assertSentOnDemandTimes(TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('every operator/admin whose new_ticket preference puts a routed ticket in scope 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]);
|
||||
|
||||
// auto_assign_by_category is on by default (seeded), so the ticket's
|
||||
// team_id actually becomes the VPN team's id — that's what now drives
|
||||
// who's "in scope" for the default scope_all preference, replacing the
|
||||
// old separate team-subcategory-routing fan-out.
|
||||
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);
|
||||
// The seeded admin also qualifies: Ticket::isVisibleToOperator() returns
|
||||
// true unconditionally for admins, and scope_all is the default — this
|
||||
// is intentional, it's what keeps the one real admin account notified
|
||||
// about every new ticket without any setup.
|
||||
Notification::assertSentTo(User::query()->where('email', 'admin@example.com')->firstOrFail(), 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, 4);
|
||||
});
|
||||
|
||||
test('an operator outside the ticket\'s team is not notified, even with scope_all left at its default', 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);
|
||||
User::query()->create(['name' => 'Ola', 'email' => 'team-notif-a@example.com', 'roles' => ['operator']])
|
||||
->teams()->attach($team->id);
|
||||
$outsider = User::query()->create(['name' => 'Niepowiązany', 'email' => 'unrelated-op@example.com', 'roles' => ['operator']]);
|
||||
|
||||
app(TicketService::class)->create([
|
||||
'email' => 'client-team-notif-2@example.com',
|
||||
'subject' => 'Problem z VPN',
|
||||
'body' => 'Nie mogę się połączyć.',
|
||||
'subcategory_id' => $sub->id,
|
||||
], null);
|
||||
|
||||
// The ticket routed to "Zespół VPN"; $outsider belongs to no team, so
|
||||
// Ticket::isVisibleToOperator() (which scope_all delegates to) is false
|
||||
// for them even though their preference defaults to scope_all=true.
|
||||
Notification::assertNotSentTo($outsider, TicketNotification::class);
|
||||
});
|
||||
|
||||
test('an operator who turns off scope_all for new tickets stops receiving them, even for a ticket they could otherwise see', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$category = Category::query()->create(['name' => 'Bez zespołu']);
|
||||
$sub = $category->subcategories()->create(['name' => 'Inne']);
|
||||
$operator = User::query()->create(['name' => 'Cichy', 'email' => 'opted-out-op@example.com', 'roles' => ['operator']]);
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
|
||||
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
|
||||
));
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Ldap\LldapUser;
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use LdapRecord\Laravel\Testing\DirectoryEmulator;
|
||||
use Livewire\Livewire;
|
||||
|
||||
afterEach(function () {
|
||||
DirectoryEmulator::tearDown();
|
||||
@@ -63,3 +65,22 @@ test('an unknown username does not authenticate', function () {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ test('admin can save the SMTP/from settings, and the password is only overwritte
|
||||
$admin = adminUser();
|
||||
|
||||
Livewire::actingAs($admin)->test(Panel::class)
|
||||
->call('setTab', 'config')
|
||||
->call('setTab', 'email')
|
||||
->set('mailConfig.fromAddress', 'wsparcie@firma.pl')
|
||||
->set('mailConfig.fromName', 'Zespół Wsparcia')
|
||||
->set('mailConfig.smtpEnabled', true)
|
||||
|
||||
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal file
95
src/tests/Feature/NotificationDeliveryRewiringTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationSetting;
|
||||
use App\Notifications\TicketNotification;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('an assignee with scope_mine enabled for escalations is not double-notified on top of the direct sla_breached send', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'sla_breached')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
$assignee = operatorUser('assignee-sla@example.com');
|
||||
$ticket->update(['assignee_id' => $assignee->id]);
|
||||
|
||||
app(TicketService::class)->notify($ticket->fresh(), 'sla_breached');
|
||||
|
||||
// NotificationPreference::DEFAULTS['escalation'] has scope_mine=true, so
|
||||
// without the notify()->notifyStaffForCategory() dedup, the assignee
|
||||
// would receive this twice: once as the fixed NotificationSetting
|
||||
// recipient, once again from the scope_mine fan-out.
|
||||
Notification::assertSentToTimes($assignee, TicketNotification::class, 1);
|
||||
});
|
||||
|
||||
test('a staff member with the e-mail column off for an event still gets the bell but not a mail', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('bell-only@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => false])
|
||||
));
|
||||
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
|
||||
return $channels === ['database'];
|
||||
});
|
||||
});
|
||||
|
||||
test('a staff member with the e-mail column on for an event gets both the bell and a mail', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('bell-and-mail@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
|
||||
));
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertSentTo($operator, TicketNotification::class, function ($notification, $channels) {
|
||||
return $channels === ['mail', 'database'];
|
||||
});
|
||||
});
|
||||
|
||||
test('the operator performing the action is never notified about their own change', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => true]);
|
||||
|
||||
$actor = operatorUser('actor@example.com');
|
||||
$this->actingAs($actor);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertNotSentTo($actor, TicketNotification::class);
|
||||
});
|
||||
|
||||
test('disabling a trigger instance-wide silences the staff fan-out too, regardless of any individual preference', function () {
|
||||
Notification::fake();
|
||||
$this->seed();
|
||||
|
||||
$operator = operatorUser('kill-switch@example.com');
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'ticket_update'],
|
||||
array_merge(NotificationPreference::DEFAULTS['ticket_update'], ['scope_all' => true, 'email' => true])
|
||||
));
|
||||
NotificationSetting::query()->where('trigger_key', 'priority_changed')->update(['enabled' => false]);
|
||||
|
||||
$ticket = makeTicket();
|
||||
app(TicketService::class)->setPriority($ticket, 'high');
|
||||
|
||||
Notification::assertNotSentTo($operator, TicketNotification::class);
|
||||
});
|
||||
70
src/tests/Feature/NotificationPreferencesTest.php
Normal file
70
src/tests/Feature/NotificationPreferencesTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\NotificationPreferences;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('a client cannot open the notification preferences page', function () {
|
||||
$client = User::query()->create(['name' => 'Client', 'email' => 'client-np@example.com', 'roles' => ['client']]);
|
||||
|
||||
Livewire::actingAs($client)->test(NotificationPreferences::class)->assertStatus(403);
|
||||
});
|
||||
|
||||
test('an operator with no saved preferences sees the built-in defaults', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->assertViewHas('rows', [
|
||||
'new_ticket' => NotificationPreference::DEFAULTS['new_ticket'],
|
||||
'ticket_update' => NotificationPreference::DEFAULTS['ticket_update'],
|
||||
'escalation' => NotificationPreference::DEFAULTS['escalation'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('toggling a checkbox persists just that one field and leaves the rest at their defaults', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'ticket_update', 'scope_all')
|
||||
->assertOk();
|
||||
|
||||
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'ticket_update')->firstOrFail();
|
||||
|
||||
expect($row->scope_all)->toBeTrue()
|
||||
->and($row->scope_mine)->toBe(NotificationPreference::DEFAULTS['ticket_update']['scope_mine'])
|
||||
->and($row->email)->toBe(NotificationPreference::DEFAULTS['ticket_update']['email']);
|
||||
});
|
||||
|
||||
test('toggling twice flips the field back off', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'escalation', 'email')
|
||||
->call('toggle', 'escalation', 'email');
|
||||
|
||||
$row = NotificationPreference::query()->where('user_id', $operator->id)->where('event_category', 'escalation')->firstOrFail();
|
||||
|
||||
expect($row->email)->toBe(NotificationPreference::DEFAULTS['escalation']['email']);
|
||||
});
|
||||
|
||||
test('an unknown category or field is rejected', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
Livewire::actingAs($operator)->test(NotificationPreferences::class)
|
||||
->call('toggle', 'not_a_category', 'scope_all')
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
test('NotificationPreference::rowFor falls back to defaults when nothing is saved, and to the saved row once toggled', function () {
|
||||
$operator = operatorUser();
|
||||
|
||||
expect(NotificationPreference::rowFor($operator, 'new_ticket'))->toBe(NotificationPreference::DEFAULTS['new_ticket']);
|
||||
|
||||
NotificationPreference::query()->create(array_merge(
|
||||
['user_id' => $operator->id, 'event_category' => 'new_ticket'],
|
||||
array_merge(NotificationPreference::DEFAULTS['new_ticket'], ['scope_all' => false])
|
||||
));
|
||||
|
||||
expect(NotificationPreference::rowFor($operator, 'new_ticket')['scope_all'])->toBeFalse();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Operator\Queue;
|
||||
use App\Models\Team;
|
||||
use Livewire\Livewire;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('the "Otwarte" tab does not offer "Zamknięte" as a status filter option', function () {
|
||||
test('no tab other than "Zamknięte" offers "Zamknięte" as a status filter option', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('all-no-closed-filter@example.com');
|
||||
$operator = operatorUser('no-closed-filter-elsewhere@example.com');
|
||||
|
||||
foreach (['all', 'mine', 'unassigned'] as $queue) {
|
||||
$keys = Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', $queue)
|
||||
->instance()->filterableStatuses->pluck('key')->all();
|
||||
|
||||
expect($keys)->not->toContain('closed');
|
||||
}
|
||||
});
|
||||
|
||||
test('other tabs still offer "Zamknięte" as a status filter option', function () {
|
||||
test('the "Zamknięte" tab offers "Zamknięte" as a status filter option', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('mine-has-closed-filter@example.com');
|
||||
$operator = operatorUser('closed-tab-has-closed-filter@example.com');
|
||||
|
||||
$keys = Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', 'mine')
|
||||
->call('setQueue', 'closed')
|
||||
->instance()->filterableStatuses->pluck('key')->all();
|
||||
|
||||
expect($keys)->toContain('closed');
|
||||
});
|
||||
|
||||
test('switching to "Otwarte" resets an active "Zamknięte" status filter, since it would always be empty there', function () {
|
||||
test('"Moje zgłoszenia", "Nieprzypisane" and team tabs never show closed tickets, only "Zamknięte" does', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$team = Team::query()->create(['name' => 'Support']);
|
||||
$operator = operatorUser('closed-excluded-everywhere@example.com');
|
||||
$operator->teams()->attach($team->id);
|
||||
|
||||
$mine = makeTicket(['number' => '3001', 'status_key' => 'closed', 'assignee_id' => $operator->id]);
|
||||
$unassigned = makeTicket(['number' => '3002', 'status_key' => 'closed', 'assignee_id' => null]);
|
||||
$teamTicket = makeTicket(['number' => '3003', 'status_key' => 'closed', 'team_id' => $team->id]);
|
||||
|
||||
foreach (['mine', 'unassigned', 'team:'.$team->id] as $queue) {
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', $queue)
|
||||
->assertDontSee($mine->number)
|
||||
->assertDontSee($unassigned->number)
|
||||
->assertDontSee($teamTicket->number);
|
||||
}
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', 'closed')
|
||||
->assertSee($mine->number)
|
||||
->assertSee($unassigned->number)
|
||||
->assertSee($teamTicket->number);
|
||||
});
|
||||
|
||||
test('switching away from "Zamknięte" resets an active "Zamknięte" status filter, since it would always be empty elsewhere', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$operator = operatorUser('reset-filter@example.com');
|
||||
|
||||
Livewire::actingAs($operator)->test(Queue::class)
|
||||
->call('setQueue', 'closed')
|
||||
->set('filterStatus', 'closed')
|
||||
->call('setQueue', 'all')
|
||||
->call('setQueue', 'mine')
|
||||
->assertSet('filterStatus', 'all');
|
||||
});
|
||||
|
||||
@@ -58,8 +58,8 @@ test('columns can be hidden and shown again, but at least one must stay visible'
|
||||
$component->call('toggleColumn', 'sla')
|
||||
->assertSet('visibleColumns', fn ($cols) => in_array('sla', $cols, true));
|
||||
|
||||
// Hide every column except one, then try to hide the last one too.
|
||||
foreach (array_keys((new \App\Livewire\Operator\Queue)->columnDefs()) as $key) {
|
||||
// Hide every visible-by-default column except one, then try to hide the last one too.
|
||||
foreach ((new Queue)->visibleColumns as $key) {
|
||||
if ($key !== 'number') {
|
||||
$component->call('toggleColumn', $key);
|
||||
}
|
||||
|
||||
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal file
60
src/tests/Feature/RealtimeBellNotificationTest.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Models\EmailTemplate;
|
||||
use App\Notifications\TicketNotification;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
test('sending a bell notification to a real user dispatches NotificationCreated on their private channel', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('realtime-bell@example.com');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-test', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$operator->notify(new TicketNotification($ticket, $template->id, 'operator'));
|
||||
|
||||
Event::assertDispatched(NotificationCreated::class, function (NotificationCreated $event) use ($operator, $ticket) {
|
||||
return $event->userId === $operator->id
|
||||
&& str_contains($event->message, $ticket->number)
|
||||
&& $event->url === route('operator.ticket', $ticket);
|
||||
});
|
||||
});
|
||||
|
||||
test('a bell-only notification (no mail channel) still dispatches NotificationCreated', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$operator = operatorUser('bell-only-realtime@example.com');
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-bell-only', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
$operator->notify(new TicketNotification($ticket, $template->id, 'operator', ['database']));
|
||||
|
||||
Event::assertDispatched(NotificationCreated::class, fn (NotificationCreated $event) => $event->userId === $operator->id);
|
||||
});
|
||||
|
||||
test('a guest customer notified by mail only never broadcasts a bell event', function () {
|
||||
Mail::fake();
|
||||
Event::fake([NotificationCreated::class]);
|
||||
seedStatusesAndPriorities();
|
||||
|
||||
$template = EmailTemplate::query()->create([
|
||||
'key' => 'tpl-realtime-guest', 'name' => 'x', 'trigger_label' => 'x', 'subject' => 'S', 'body' => 'B',
|
||||
]);
|
||||
$ticket = makeTicket();
|
||||
|
||||
Notification::route('mail', $ticket->email)
|
||||
->notify(new TicketNotification($ticket, $template->id));
|
||||
|
||||
Event::assertNotDispatched(NotificationCreated::class);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ use App\Models\ReplyQuickAction;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('the 3 default reply quick actions exist out of the box, matching the old hardcoded menu', function () {
|
||||
$this->seed();
|
||||
|
||||
// "Wyślij i oznacz jako rozwiązane" used to point at the now-removed
|
||||
// "resolved" status (folded into "closed" — see the status restructure
|
||||
// migration), so it points at "closed" today, same as "Wyślij i zamknij".
|
||||
@@ -72,7 +74,7 @@ test('admin can edit and delete a reply quick action', function () {
|
||||
});
|
||||
|
||||
test('the operator ticket view lists the configured reply quick actions in the send menu', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
$operator = operatorUser('quickaction-view@example.com');
|
||||
$ticket = makeTicket(['number' => '1001']);
|
||||
|
||||
@@ -83,7 +85,7 @@ test('the operator ticket view lists the configured reply quick actions in the s
|
||||
});
|
||||
|
||||
test('sending via a status-changing quick action updates the ticket status', function () {
|
||||
seedStatusesAndPriorities();
|
||||
$this->seed();
|
||||
$operator = operatorUser('quickaction-send@example.com');
|
||||
$ticket = makeTicket(['number' => '1001', 'status_key' => 'new']);
|
||||
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user