6 Commits

Author SHA1 Message Date
0943829331 v1.5.1
Co nowego:
- Kolumny kolejki operatora: dwie nowe (ID, e-mail) obok istniejących, oraz
  możliwość zmiany kolejności widocznych kolumn strzałkami ↑/↓ w picker
  „Kolumny” — nie tylko włączanie/wyłączanie. Kolejność zapamiętywana jest
  per operator tak samo jak dotąd widoczność.
- Wybór sprzętu klienta (Snipe-IT): druga lista dozwolonych kategorii obok
  istniejącej listy podkategorii — pozwala objąć od razu wszystkie
  podkategorie danej kategorii jednym zaznaczeniem.
- Pulpit klienta i widok zgłoszenia operatora pamiętają teraz aktywną
  zakładkę, więc „Wróć do listy” wraca do tej samej, a nie zawsze do
  domyślnej.

Poprawki:
- Paginacja (kolejka operatora, pulpit klienta) używała domyślnego,
  szarego stylu Laravela reagującego na motyw systemu/przeglądarki, a nie
  przełącznik jasny/ciemny w aplikacji — stąd ciemne przyciski nawet w
  trybie jasnym. Podmieniony na własny widok zgodny z kolorami aplikacji
  (w tym własne tło/border każdego przycisku i wyśrodkowanie na telefonie).
- Kolorystyka boksu z informacją o logowaniu nie zmienia już odcienia
  między trybem jasnym i ciemnym (wcześniej pochodziła z --color-accent) —
  teraz stałe, ciemne tło w obu trybach, więc kolory tekstu ustawione przez
  admina (np. biały) zostają czytelne niezależnie od motywu. Poszerzona
  karta logowania (380px → 480px).
- Lista zgłoszeń klienta: etykiety priorytetu/statusu nie zawijają się już
  do osobnej linii przy długim temacie na wąskich ekranach — zostają
  przypięte do prawej, a temat zawija się we własnej kolumnie.
- Liczniki czasu pracy (resumeTimer/stopTimer/...) nie dotykają już
  updated_at — samo otwarcie zgłoszenia nie liczy się jako aktualizacja.
  Kolejka operatora i pulpit klienta domyślnie sortują po dacie utworzenia
  z tego samego powodu.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/client, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:01:34 +02:00
4b70b910a9 v1.5.0
Co nowego:
- Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log
  bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów
  i auto-odświeżaniem.
- Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez
  przypisanej kategorii/podkategorii.
- Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla
  nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto),
  łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po
  e-mailu, nowe flagi --assign-operators i --fix-closed-dates do
  donaprawiania wcześniejszych importów, dedykowany log
  storage/logs/hesk-import.log.
- Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i
  filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez
  podkategorii (np. z poczty IMAP) — teraz liczone poprawnie.
- Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z
  aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy
  operatora).
- Porządki w bazie: usunięte niewykorzystywane kolumny
  (users.remember_token, users.email_verified_at,
  email_templates.trigger_label); wartości pól dodatkowych, stan
  triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli
  tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries,
  ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są
  teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach
  pivot; tickets.source/ticket_messages.source walidowane względem znanego
  zestawu wartości.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:28:03 +02:00
03c6ec7cae v1.4.0
Co nowego:
- Wsparcie Active Directory dla LDAP (obok LLDAP/OpenLDAP), przełącznik typu
  katalogu w Admin > Integracje.
- Wyszukiwarka klientów dla operatora (Operator > Klienci).
- Stronicowanie kolejki operatora (50/stronę) i dashboardu klienta (20/stronę).
- Globalna wyszukiwarka zgłoszeń (Ctrl+K/Cmd+K) z operatorami w stylu Gmaila
  (od:, temat:, treść:, numer:), plus przycisk "Szukaj" w panelu bocznym.
- Ostatnio przeglądane zgłoszenia w panelu bocznym operatora.
- Przeprojektowany pasek nawigacji: suwak Klient/Operator/Administrator
  zamiast rozwijanego menu, bogatsze menu profilu (nazwa/e-mail/role),
  dynamiczne tytuły kart przeglądarki na każdej podstronie.
- Narzędzie do jednorazowego importu historii zgłoszeń z Heska 3.x
  (scripts/hesk-import/).
- Poprawka: paginacja pokazywała surowe klucze tłumaczeń zamiast tekstu
  (brakujący lang/pl/pagination.php).

Zaktualizowana dokumentacja: README, CLAUDE.md, install.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 00:58:35 +02:00
7a8cf2037c v1.3.0
- Snipe-IT asset inventory integration (Admin > Integracje), optional and off
  by default: connect by API address + personal token (+ SSL-verification
  bypass). Three independent toggles: client can pick which of their own
  Snipe-IT assets a ticket concerns (scoped to admin-selected subcategories,
  empty = never shows), operator sees the requester's assets in a ticket-view
  sidebar, operator can search the whole inventory from that same sidebar (not
  a separate page) for shared equipment. Assets shown as "numer środka - numer
  seryjny - producent model" + category; a linked asset's live status is
  fetched fresh on the ticket page, and unlinking stays available to an
  operator even with both view/search toggles off.
- AI summary: a "Wygeneruj teraz" button for an immediate on-demand refresh,
  plus a new admin toggle to regenerate right after every new reply/note
  instead of only on the next scheduled sweep. The transcript sent to the
  model now also includes the ticket's own opening body, fixing summaries
  missing the original request on long threads.
- Fixed: the status dropdown in the operator ticket view could keep showing
  the pre-change status after a status-changing quick action until the next
  page load (Livewire/Alpine-morph quirk for wire:change-bound selects).
- Docs: README/ARCHITECTURE/CHANGELOG/install/wiki updated for all of the
  above, including correcting the AI-summary refresh description left over
  from the 1.2.1 release notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:11:21 +02:00
1df697afce v1.2.2
- Subcategories can now be reordered within their category — up/down arrow
  buttons next to "Edytuj" in Admin > Kategorie. The order set there is used
  everywhere a subcategory list is shown (pickers, admin listings, etc.).
2026-07-24 13:58:18 +02:00
313e01ad24 v1.2.1
- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and
  off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI,
  or a self-hosted Ollama instance) configured by base URL, optional API key,
  model, and an SSL-verification toggle. Foundation for the two AI features
  below and anything else that wants an LLM call in the future.
- BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj
  wszystko ponownie" buttons plus `php artisan bookstack:tag-content`
  (--dry-run/--force/--limit=N) tag every book/chapter/page with matching
  helpdesk subcategory names, idempotent by default.
- BookStack search refinement: "Przeszukuj" is now three independent
  checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a
  new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare
  subcategory name, matching what auto-tagging writes.
- AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI
  dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles
  auto-assign/correct category+subcategory, rewrite an unclear subject, and
  set priority from content, once per ticket in the background; every change
  is logged in the ticket's history. Separately, an AI summary + suggested
  action for every ticket, shown to operators only, with an admin-editable
  prompt.
- Operators can now reassign a ticket to any team, not just one they belong
  to.
- The auto-refresh countdown badges (ticket view, operator queue) are now
  clickable — fetch immediately and reset the countdown.
- All 7 "cyclical" intervals (3 browser refresh countdowns, the notification
  bell poll, and the 4 background scheduled commands) are now configurable
  from Admin > Konfiguracja instead of fixed in code.
- Fixed: an operator viewing a ticket that's deleted or moved outside their
  team scope mid-session is now redirected to the operator queue instead of
  hitting an error.
- Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 13:38:39 +02:00
125 changed files with 9703 additions and 454 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
/mysql/ /mysql/
.env .env
compose.yaml compose.yaml
scripts/hesk-import/.env

View File

@@ -36,13 +36,17 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
├──< TicketMessage (public replies + internal notes) ├──< TicketMessage (public replies + internal notes)
├──< TicketAttachment ├──< TicketAttachment
├──< TicketHistory ├──< TicketHistory
├──< TicketFieldValue (queryable custom_fields values, kept in sync)
├── aiSummary → TicketAiSummary (1:1, triage+summary state)
├── snipeitAsset → TicketSnipeitAsset (1:1, linked asset)
├── customer/assignee → User ├── customer/assignee → User
├── status → Status (fixed stages: new/open/closed) ├── status → Status (fixed stages: new/open/closed)
├── priority → Priority → SlaRule (response/resolution minutes) ├── priority → Priority → SlaRule (response/resolution minutes)
└── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close) └── csat_rating/csat_comment/csat_rated_at (nullable — set once, on close)
User ─< UserFieldValue >─ UserField User ─< UserFieldValue >─ UserField
User ─< SavedQueueView (operator's own saved queue filter/sort/column presets) User ─< SavedQueueView (operator's own named/default saved queue filter/sort/column presets)
User.operator_queue_columns (JSON, auto-remembers shown/hidden queue columns + their order, independent of SavedQueueView)
User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user') User ─< notifications (Laravel's database channel — polymorphic, morph-mapped as 'user')
ApiClient (Sanctum token owner, ability-scoped) ApiClient (Sanctum token owner, ability-scoped)
Setting (single-row-per-key config store, see below) Setting (single-row-per-key config store, see below)
@@ -63,6 +67,51 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
rather than spreading it across Livewire components. rather than spreading it across Livewire components.
**Virtual `ai_*`/`snipeit_*` attributes.** The AI triage/summary fields
(`ai_triaged_at`, `ai_summary`, `ai_suggested_action`, `ai_summary_generated_at`)
and the Snipe-IT link (`snipeit_asset_id`, `snipeit_asset_name`) are **not**
real columns on `tickets` — they live on the related `TicketAiSummary`/
`TicketSnipeitAsset` rows shown in the diagram above (each table's own columns
drop the prefix, e.g. `ticket_ai_summaries.summary`). `Ticket` overrides
`getAttribute()`/`setAttribute()` (see `AI_SUMMARY_FIELD_MAP`/
`SNIPEIT_FIELD_MAP`) so every existing `$ticket->ai_summary`/
`$ticket->update(['snipeit_asset_id' => ...])` call site keeps working
unchanged against the new tables — the same pattern `TicketMessage` already
uses for its own virtual `role`/`author_id`. A write is queued
(`$pendingVirtualAttributes`) and flushed into the related row's
`updateOrCreate()` on the model's `saved` event, since a brand-new ticket has
no id yet to key the related row on until that point. If you add a new
`ai_*`/`snipeit_*` field, add it to the relevant `FIELD_MAP` rather than to
`tickets` directly.
**Custom field values.** `tickets.custom_fields` (a JSON blob, `field.id =>
value`) stays the source of truth for reads/writes — `TicketFieldValue`
(`ticket_field_values`, one row per non-blank entry) is a queryable mirror
kept in sync automatically by `Ticket::syncFieldValues()` (called from the
same `saved` hook whenever `custom_fields` changes), so reporting can
filter/join on "tickets where custom field X = Y" without scanning JSON.
Nothing else needs to write to `ticket_field_values` directly.
**`source` validation.** `Ticket::SOURCES`/`TicketMessage::SOURCES` are the
only values ever allowed in `tickets.source`/`ticket_messages.source`
(`'web'`/`'email'`/`'hesk_import'`; `null` still means "web" for messages) —
enforced by a `saving` listener that throws `InvalidArgumentException` on
anything else, so a typo'd literal fails loudly instead of sticking silently.
Add new values to the constant before writing them anywhere.
**Timer bookkeeping never touches `updated_at`.** `flushTimer()`/`stopTimer()`/
`resumeTimer()`/`resetTimer()`/`setTimeSpent()` all route their writes through
the private `updateTimerFields()`, which toggles `$this->timestamps = false`
around the `update()` call. `resumeTimer()` runs on every single ticket open
(`TicketShow::mount()`) and `stopTimer()` on every navigate-away/tab-close —
without this, merely viewing a ticket (no reply, no status change) would bump
`updated_at`, which used to drown out genuinely stale tickets in any list
sorted by that column (operator queue, client dashboard — both now default to
sorting by `created_at` instead, for the same reason). Real content changes
still touch `updated_at` normally, via their own separate `update()`/`save()`
calls elsewhere. If you add another timer-only field, write it through
`updateTimerFields()` too rather than a plain `update()`.
## Ticket numbering & URLs ## Ticket numbering & URLs
A ticket carries three distinct identifiers, each with a different job: A ticket carries three distinct identifiers, each with a different job:
@@ -113,6 +162,29 @@ typed against `ModelNotFoundException` itself would never match) that
redirects to `operator.queue`/`client.dashboard` instead, for any redirects to `operator.queue`/`client.dashboard` instead, for any
authenticated request under `operator/*`/`client/*`. authenticated request under `operator/*`/`client/*`.
That global handler only ever sees a full HTTP request (a page load/reload),
not Livewire's own AJAX update endpoint (`/livewire/update`, which doesn't
match the `operator/*`/`client/*` path check) — so it doesn't cover an
operator who already has a ticket open when it's deleted, or whose team gets
reassigned (by anyone, including via their own action — see "Teams" in
[README.md](README.md)) to one outside their visible scope
(`Ticket::isVisibleToOperator()`) mid-session. `Operator\TicketShow` handles
that case itself: a Livewire component's typed public model property
(`public Ticket $ticket`) is re-fetched by id on every subsequent request via
`firstOrFail()` (`Livewire\Features\SupportModels\ModelSynth::hydrate()`),
which throws `ModelNotFoundException` *before* any of the component's own
method code runs if the row is gone — too early for an ordinary try/catch
inside an action method to ever catch. The component instead defines
Livewire's `exception($e, $stopPropagation)` lifecycle hook (called for any
exception raised anywhere in the component's request lifecycle, hydration
included) to catch that case and redirect. The narrower case — ticket still
exists but is no longer visible, e.g. after a team reassignment — doesn't
throw at all, so it's caught separately: `refreshOrRedirectAway()` re-checks
`isVisibleToOperator()` after every live-update refresh
(`onQueueChanged()`/`refreshTicketData()`) and after the operator's own
`setTeam()` call, redirecting immediately rather than leaving them on a
ticket they can no longer legitimately keep viewing.
## Roles & permissions ## Roles & permissions
`$user->roles` reads/writes as a plain array (`['client', 'operator']`), but `$user->roles` reads/writes as a plain array (`['client', 'operator']`), but
@@ -144,6 +216,16 @@ the account used for first login after a fresh install (see
`SyncUserFieldsFromLdap` keeps `UserFieldValue` rows in sync with directory `SyncUserFieldsFromLdap` keeps `UserFieldValue` rows in sync with directory
attributes. attributes.
`app/Ldap/` has two directory-schema models — `LldapUser` (LLDAP/OpenLDAP,
the default) and `AdUser` (Active Directory, `LdapRecord\Models\ActiveDirectory\User`
under the hood). `Settings::ldapUserModelClass()` picks between them based on
the `ldap_directory_type` setting, and `AppServiceProvider::applyLdapSettingsOverride()`
wires the chosen class into `config('auth.providers.users.model')` on every
request — same live-override mechanism as the connection host/base DN below.
`LdapUserProvisioner` (used for sync + guest auto-provisioning) resolves the
same setting at call time rather than caching the class, so switching
directory type takes effect without a redeploy.
## Settings override ("live config") ## Settings override ("live config")
`App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader `App\Support\Settings` (`app/Support/Settings.php`) is a cached key/value reader
@@ -252,9 +334,19 @@ if "nothing updates live" ever comes back:
As a defense against a dropped websocket connection (backgrounded tab, As a defense against a dropped websocket connection (backgrounded tab,
network blip), the operator queue and both ticket-detail views also poll network blip), the operator queue and both ticket-detail views also poll
themselves every 3060 seconds via a small Alpine countdown calling themselves via a small Alpine countdown calling `$wire.refreshQueue()` /
`$wire.refreshQueue()` / `$wire.refreshTicketData()` — broadcasting is `$wire.refreshTicketData()` — broadcasting is best-effort, not the only way
best-effort, not the only way these views ever update. these views ever update. The countdown badge is also clickable
(`x-on:click="remaining = total; $wire.refresh...()"` on the same element
the `x-init="setInterval(...)"` already lives on) to fetch immediately and
reset the countdown, rather than only ever firing on its own schedule. Its
interval — like the notification bell's `wire:poll` and the 4 scheduled
commands below — reads from `Settings` (`refresh_queue_seconds`/
`refresh_ticket_view_seconds`/`refresh_notifications_seconds`, admin-editable
in Konfiguracja) rather than a hardcoded number: `wire:poll.{{ $seconds }}s`
and Alpine's `x-data="{ remaining: {{ $seconds }}, ... }"` both just
interpolate to plain text in the rendered HTML, so a `Settings`-sourced value
works exactly like a literal one would.
A third private channel, **`App.Models.User.{id}`** (Laravel's default A third private channel, **`App.Models.User.{id}`** (Laravel's default
per-notifiable convention, kept verbatim rather than a shorter alias), per-notifiable convention, kept verbatim rather than a shorter alias),
@@ -274,8 +366,9 @@ fires while the tab is open, same limitation as the other Echo listeners here.
`SlaRule` holds per-priority response/resolution targets in minutes. The `SlaRule` holds per-priority response/resolution targets in minutes. The
scheduled command `tickets:check-sla-breaches` (registered in scheduled command `tickets:check-sla-breaches` (registered in
`routes/console.php`, run every 15 minutes via `schedule:run`) flags overdue `routes/console.php`, default every 15 minutes, interval admin-configurable —
tickets and can notify the assigned operator — see [install.md](install.md) for see "Configurable scheduled-command intervals" below) flags overdue tickets
and can notify the assigned operator — see [install.md](install.md) for
why this requires an external cron entry (the Docker image ships no why this requires an external cron entry (the Docker image ships no
cron/supervisor of its own). cron/supervisor of its own).
@@ -285,8 +378,9 @@ cron/supervisor of its own).
`scope_subcategory_id`/`scope_team_id`, `action_type` + `action_value`) lets an `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 admin configure "if a ticket has been silent for N minutes, change its
priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The priority/status/team/assignee" without code — Admin > Automatyzacja SLA. The
scheduled command `automation:run-rules` (also every 15 minutes) evaluates scheduled command `automation:run-rules` (default also every 15 minutes,
every enabled rule against `Ticket.last_customer_activity_at` (falling back to independently configurable) evaluates every enabled rule against
`Ticket.last_customer_activity_at` (falling back to
`created_at` if never set — mirrors how `resolutionDeadline()` treats a `created_at` if never set — mirrors how `resolutionDeadline()` treats a
missing `SlaRule` as "no SLA" rather than backfilling one), and applies a missing `SlaRule` as "no SLA" rather than backfilling one), and applies a
match through the same `TicketService` setters a manual operator action would match through the same `TicketService` setters a manual operator action would
@@ -301,6 +395,29 @@ 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 this run, but a later rule's own query naturally excludes an already-closed
ticket. ticket.
## Configurable scheduled-command intervals
All 4 scheduled commands (`tickets:check-sla-breaches`, `automation:run-rules`,
`emails:fetch-imap`, `ai:run-ticket-automation`) have an admin-configurable
interval (Admin > Konfiguracja — `schedule_sla_check_minutes`/
`schedule_automation_rules_minutes`/`schedule_imap_fetch_minutes`/
`schedule_ai_automation_minutes`), defaulting to their previous hardcoded
values (15/15/5/5 minutes). `routes/console.php` registers all 4 as
`->everyMinute()->when(fn () => Settings::dueEveryMinutes($key, $default))`
rather than an eagerly-built `->cron('*/N * * * *')` string — this is a
deliberate choice, not just a style preference: `routes/console.php` is
`require`'d on **every** artisan boot (`migrate`, `tinker`, `php artisan
test`, not just `schedule:run`, since it's wired in via `bootstrap/app.php`'s
`commands:` key), so anything at its *top level* that queries the database
would run before a fresh/test database necessarily has the `settings` table
yet — an early version of this feature that built the cron string eagerly at
the top level broke exactly this way. A closure passed to `->when()` is only
ever evaluated later, when `schedule:run` actually processes due events, so
`Settings::dueEveryMinutes()` never runs at boot. One visible side effect:
`php artisan schedule:list` shows `* * * * *` for all four regardless of
their actual configured interval, since the real interval only exists inside
the closure — expected, not a bug.
## IMAP e-mail intake ## IMAP e-mail intake
Optional, off by default (`ImapMailbox.enabled` per row — there is no single Optional, off by default (`ImapMailbox.enabled` per row — there is no single
@@ -366,10 +483,10 @@ singleton). Split across three layers, mirroring the plan that shipped it:
the ticket number; ticket view: per-message in the thread, plus a tag next the ticket number; ticket view: per-message in the thread, plus a tag next
to the ticket number in the header). to the ticket number in the header).
- **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`), - **`emails:fetch-imap`** (`app/Console/Commands/FetchImapEmails.php`),
registered in `routes/console.php` as registered in `routes/console.php` with `->withoutOverlapping()` (like
`Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping()` `ai:run-ticket-automation`, unlike the SLA-check/automation-rules
— the one scheduled command in this app that opts into commands — both make real outbound HTTP/IMAP calls per record, so a slow
`withoutOverlapping()` (SLA/automation don't), given IMAP I/O latency. run risks overlapping the next tick in a way a pure-DB command doesn't).
Early-returns if no `ImapMailbox` is enabled. Also callable directly per Early-returns if no `ImapMailbox` is enabled. Also callable directly per
mailbox from Admin > Poczta's "Pobierz teraz" button mailbox from Admin > Poczta's "Pobierz teraz" button
(`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only (`ImapMailboxFetcher::fetchMailbox()`, bypassing the enabled-only
@@ -381,6 +498,26 @@ Requires the same external `schedule:run` cron entry as SLA/automation (see
[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button [CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button
does anything. does anything.
## Log channels & the admin log viewer
`config/logging.php` defines three dedicated channels alongside the app's
default one, each daily/14-day-retention and always `debug` level regardless
of `.env`'s `LOG_LEVEL` (so they stay useful even when the app itself runs at
`error`): `imap` (`storage/logs/imap-*.log` — see "IMAP e-mail intake"
above), `ai` (`storage/logs/ai.log` — every `ai:run-ticket-automation` run,
used by both `TicketAiTriageService` and `TicketAiSummaryService`, plus
`AiClient`'s own request/response/failure logging), and `hesk_import`
(`storage/logs/hesk-import.log` — every `hesk:import` run). `Admin\Logs`
(`app/Livewire/Admin/Logs.php`, Admin > Logi) is a read-only viewer over
`storage/logs/*.log` (any file, not just these three) — it reads only the
last 4 MB of a file to bound memory on large ones, splits raw log text back
into individual entries by the `[YYYY-MM-DD HH:MM:SS]` line prefix (so a
multi-line stack trace stays grouped with the line that started it), and
offers level/free-text/entry-count filters plus an optional `wire:poll.5s`
auto-refresh. `selectedFile` is validated against the real glob'd file list
on every read, not trusted as a path — a crafted value (e.g. `../../.env`)
is silently ignored rather than read.
## API ## API
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST `routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
@@ -392,26 +529,233 @@ tighter per-IP limit for unauthenticated requests
generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API generated by L5-Swagger at `/admin/api-docs`; there is no static Markdown API
reference in-repo. reference in-repo.
## Generic AI integration
`App\Services\AiClient` is a small, feature-agnostic wrapper around an
OpenAI-compatible `/chat/completions` endpoint (`chat(array $messages, array
$options = []): ?string`) — works against Groq, OpenAI itself, or a
self-hosted Ollama instance, whichever `ai_base_url` points at.
`Settings`-driven like everything else here: `ai_enabled`, `ai_base_url`,
`ai_api_key` (encrypted, optional — deliberately not required by `enabled()`,
since a self-hosted Ollama instance typically has no auth at all),
`ai_model`, `ai_verify_ssl`. Every call is wrapped in `try/catch(\Throwable)`
and returns `null` on any failure (network, non-2xx, unexpected shape),
matching `BookStackClient`'s safe-default convention — callers are expected
to treat `null` as "AI unavailable" and degrade gracefully rather than throw.
Not tied to any single feature: `BookStackContentTagger`,
`TicketAiTriageService` and `TicketAiSummaryService` (below) are just its
first three consumers, each with their own prompt-building/parsing logic
layered on top rather than baked into the client itself.
## BookStack integration ## BookStack integration
`App\Services\BookStackClient` is the only outbound HTTP client in the `App\Services\BookStackClient` is one of three outbound HTTP clients in the
codebase (Laravel's `Http` facade) — everything else here only ever receives codebase (Laravel's `Http` facade), alongside `AiClient` above and
requests. It's entirely `Settings`-driven, no `.env`/`config()` involved: `SnipeItClient` below — everything else here only ever receives requests.
`bookstack_enabled`, `bookstack_base_url`, `bookstack_token_id`/ It's entirely `Settings`-driven, no
`bookstack_token_secret` (encrypted, same as the LDAP/SMTP passwords), `.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`,
`bookstack_verify_ssl`, `bookstack_search_types` ('both'|'page'|'book'), and `bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the
**two independent** allow-lists of BookStack shelf IDs — LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent**
`bookstack_allowed_shelf_ids_creation` (ticket-wizard suggestions) and allow-lists of BookStack shelf IDs — `bookstack_allowed_shelf_ids_creation`
`bookstack_allowed_shelf_ids_ticket_view` (the operator's sidebar on an (ticket-wizard suggestions) and `bookstack_allowed_shelf_ids_ticket_view`
existing ticket) — `search()` takes a `$context` (`CONTEXT_CREATION` / (the operator's sidebar on an existing ticket) — `search()` takes a
`CONTEXT_TICKET_VIEW`) that selects which one applies. **An empty allow-list `$context` (`CONTEXT_CREATION` / `CONTEXT_TICKET_VIEW`) that selects which
means "search nothing"**, not "search everything" — nothing is ever one applies. **An empty allow-list means "search nothing"**, not "search
suggested until an admin explicitly opts shelves in, independently per everything" — nothing is ever suggested until an admin explicitly opts
context. BookStack has no "which shelf is this book on" field in its own shelves in, independently per context. BookStack has no "which shelf is this
search response, so `BookStackClient` fetches `/api/shelves` + book on" field in its own search response, so `BookStackClient` fetches
`/api/shelves/{id}` once (cached 30 min) into a shelf→book-ids map, used both `/api/shelves` + `/api/shelves/{id}` once (cached 30 min) into a
to resolve the allow-list to book IDs and to build the "Shelf > Book" shelf→book-ids map, used both to resolve the allow-list to book IDs and to
breadcrumb shown next to each suggestion. Per-query search results are cached build the "Shelf > Book" breadcrumb shown next to each suggestion. Per-query
10 minutes, keyed on the query text **and** the active allow-list, so toggling search results are cached 10 minutes, keyed on the query text **and** the
which shelves are allowed is reflected immediately instead of serving a active allow-list, so toggling which shelves are allowed is reflected
pre-change result for up to 10 minutes. immediately instead of serving a pre-change result for up to 10 minutes.
**Content-type filter and "search by" mode**: `bookstack_search_types` is a
comma-separated subset of `BookStackClient::SEARCH_TYPES` (`book`, `page`,
`chapter` — checkboxes in the admin UI, no more single-select "both/page/book"
dropdown), combined into BookStack's own `{type:a|b}` query syntax.
`bookstack_search_by` (`'name'`/`'tags'`/`'both'`) picks between matching the
title (`{in_name:...}`) and matching a tag whose name equals the query
(`[...]` — see BookStack content auto-tagging below for what actually writes
those tags); `'both'` runs one request per mode and merges/dedupes the
results, since BookStack's own query syntax ANDs filters together rather than
OR-ing them, so there's no single-request way to ask for "name OR tag".
`search()` takes both a `$query` (full "Category Subcategory" text, used for
the name-match variant) and an optional `$tagQuery` (bare subcategory name,
used for the tag-match variant) — the two differ because a tag is expected to
hold just the subcategory name, not the combined category+subcategory text.
## BookStack content auto-tagging
`App\Services\BookStackContentTagger` (used by the "Otaguj nową
treść"/"Otaguj wszystko ponownie" buttons on the BookStack admin card and by
`php artisan bookstack:tag-content`) is the reason the tag-based search mode
above has anything to match: it walks every book/chapter/page via
`BookStackClient::listAll()`/`detail()`, builds a Polish prompt naming the
current, live `Subcategory` list as the only allowed vocabulary, and asks
`AiClient` (above) to return which subcategory name(s) fit each item — a
single response per batch of 20 items, to keep prompt size/cost down.
Defensive JSON parsing (`parseAssignments()`) regex-extracts the first
`{...}` block before decoding, so a chatty or malformed response fails just
that one batch (`failed_batches` in the run summary) instead of crashing the
whole pass; every returned label is matched case-insensitively against the
real subcategory list before being trusted, so a hallucinated name is
silently dropped rather than written as a tag. Idempotent by default — an
item already carrying a tag matching a current subcategory name is skipped
unless `--force`/the "wszystko ponownie" button is used — and new tags are
merged into an item's existing tags (`updateTags()` PUTs the whole array;
BookStack has no "append a tag" endpoint), never overwriting unrelated ones.
## Snipe-IT asset inventory integration
`App\Services\SnipeItClient` talks to a Snipe-IT instance's REST API
(`/api/v1/...`, bearer token auth), entirely `Settings`-driven like
`BookStackClient`: `snipeit_enabled`, `snipeit_base_url`,
`snipeit_api_token` (encrypted), `snipeit_verify_ssl`. Every call is wrapped
in `try/catch(\Throwable)` returning `[]`/`null` on failure, same
safe-default convention as `AiClient`/`BookStackClient`. Three independently
toggleable settings gate what a client/operator can actually do with it —
none of them affect `SnipeItClient` itself, only which Livewire methods are
willing to call it:
- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`
and `snipeit_client_asset_category_ids`, two independent comma-separated
allow-lists) — gates `Client\NewTicket`'s asset picker. Mirrors BookStack's
shelf allow-lists: **empty** lists mean the picker never shows for any
subcategory, not "every subcategory" — `NewTicket::snipeitAssets()` checks
the toggle and that *either* the currently selected `subcategoryId` is in
the subcategory list *or* `categoryId` is in the (coarser) category list
before calling `assetsForEmail()`. The category list exists so an admin can
cover every subcategory of a category in one click instead of ticking each
one individually; the two lists are additive, not exclusive.
`selectCategory()`/`selectSubcategory()` reset any already-picked asset, so
switching to an out-of-scope subcategory can't silently carry a stale
selection through to `submit()`.
- `snipeit_operator_view_requester_assets` — gates the same
`assetsForEmail()` lookup (by the ticket's own `email`, not the viewing
operator's) in `Operator\TicketShow`'s sidebar.
- `snipeit_operator_search_inventory` — gates `searchAssets()`, a free-text
`/hardware?search=` lookup across the *whole* inventory, for linking
equipment the requester doesn't personally own (e.g. a shared printer).
Rendered inline in the same sidebar card as the requester-assets list, not
a separate route/page.
`Operator\TicketShow::linkSnipeitAsset(int $id)` deliberately does **not**
fall back to a direct `SnipeItClient::asset($id)` lookup by id — it only
accepts an id present in `snipeitRequesterAssets`/`snipeitSearchResults`,
and each of those is itself empty unless its own setting above is on. This
means an operator can't link an arbitrary asset through a source the admin
has switched off for them, even by tampering with the Livewire request
payload. `unlinkSnipeitAsset()` has no such gate — clearing an existing link
is a correction, not a new way to browse Snipe-IT, so it stays available
even with both toggles off.
`SnipeItClient::assetsForEmail()` has to resolve an e-mail to a Snipe-IT user
first (`GET /users?search=`, no "assets by e-mail" endpoint exists), then
lists what's checked out to them (`GET /users/{id}/assets`) — cached 5
minutes per e-mail. `normalizeAsset()` is the single place that turns a raw
Snipe-IT hardware row into the shape every caller/view uses (`id`, `label`,
`serial`, `manufacturer`, `model`, `category`, `status`, `url`); `label`
joins whichever of asset tag / serial / "manufacturer model" are actually
present with `" - "`, falling back to `Zasób #{id}` if all three are blank —
Snipe-IT doesn't guarantee any of them are filled in. The `x-snipeit-assets`
Blade component renders that shape everywhere an asset list shows up
(client picker, requester sidebar, search results), with a `card` prop that
skips its own wrapping `<div class="card">` when embedded inside a
caller-provided one (the inventory-search box + its results share one card).
A linked ticket only stores an `asset_id` + a cached `asset_name` label on
the related `ticket_snipeit_assets` row (`TicketService::setSnipeitAsset()`,
which also writes a ticket-history line — see "Virtual `ai_*`/`snipeit_*`
attributes" above for how this reads/writes as `$ticket->snipeit_asset_id`
despite not being a `tickets` column) — no other Snipe-IT fields are
persisted.
Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card),
it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted
from the cache, so a status/reassignment change made directly in Snipe-IT is
reflected immediately; the cached label is only ever the fallback shown when
that live fetch fails (instance unreachable, or the asset was deleted
there).
## AI ticket triage & summary
Two independent services, both consuming `AiClient` above, both run from a
single scheduled command (`ai:run-ticket-automation`) — **never
synchronously at ticket creation**, so an LLM call never adds latency to a
live customer submitting a ticket:
- **`App\Services\TicketAiTriageService`** — a one-shot classification pass
per ticket, gated by 5 independent toggles
(`ai_triage_category_when_missing`/`subcategory_when_category_only`/
`recheck_categorized`/`fix_subject`/`set_priority`). `buildPrompt()` picks
one of 3 mutually-exclusive category scenarios from the ticket's *current*
state (no category/subcategory at all → assign both; category but no
subcategory → pick one within it; already has a subcategory → recheck and
possibly correct), independently of the subject/priority toggles. Every
scanned ticket gets `ai_triaged_at` stamped exactly once (on the related
`ticket_ai_summaries` row, not `tickets` itself — see "Virtual
`ai_*`/`snipeit_*` attributes" above) — this is
a one-shot pass, not a continuous recheck, and there's deliberately no
manual per-ticket re-trigger. Resolution is fail-closed the same way as the
BookStack tagger: every value the model returns is matched against the
real category/subcategory/priority vocabulary before being trusted: a
hallucinated or out-of-scope value (e.g. a subcategory claimed under the
wrong category) is silently dropped. Applying changes goes through a new
`TicketService::applyAiTriage(Ticket $ticket, array $changes, array
$historyLines)` — a single `$ticket->update()` for whichever
category/subcategory/subject/priority fields actually changed, one
specific history line per changed field plus a final "Automatyzacja:
klasyfikacja AI" attribution line (mirrors how `RunAutomationRules` logs
its own SLA-automation changes), and `notify()`/`TriggerEngine::handle()`
fired only for the fields that actually changed — deliberately not
composed from the existing `setPriority()`/`updateDetails()` setters, since
one AI pass can touch several fields at once and those would each write
their own generic line and fire notifications per-field instead of once
per pass.
- **`App\Services\TicketAiSummaryService`** — a summary + suggested next
action for **every** ticket (gated by a single `ai_summary_enabled`
toggle), cached on the related `ticket_ai_summaries` row's `summary`/
`suggested_action`/`summary_generated_at` and shown only in the operator ticket view (a
"Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the
BookStack suggestions card next to it). `run()` (the scheduled sweep)
regenerates whenever a ticket's latest message postdates its last summary
— deliberately compared against `ticket_messages.created_at`, not
`tickets.updated_at` (which also changes on unrelated actions like a
status/priority edit, which would otherwise trigger spurious
re-summarization on every tick for an active ticket). `buildTranscript()`
includes the ticket's own `body` (the opening description, outside
`ticket_messages`) ahead of the message transcript — needed because that
row would otherwise fall outside `TRANSCRIPT_MESSAGE_LIMIT` (30) on any
thread longer than that, silently dropping the original request from the
prompt. Unlike the triage service, a malformed AI response here leaves the
previous summary untouched rather than stamping "done" — the ticket stays
in the "stale" set and gets retried next run, since this feature is meant
to keep refreshing indefinitely, not run once. The system prompt is
admin-editable (`ai_summary_prompt` setting, plain textarea with a
"Resetuj" button restoring `Settings::default('ai_summary_prompt')`
same pattern as the e-mail footer editor) and asks the model for a small
JSON object (`{"summary": "...", "suggested_action": "..."}`), parsed with
the same defensive regex-extract-then-decode approach used throughout
these AI services.
Besides `run()`'s scheduled sweep, two paths call `generateFor(Ticket
$ticket): bool` directly, bypassing the staleness check entirely:
`Operator\TicketShow::regenerateAiSummary()` (the sidebar's "Wygeneruj
teraz" button, a synchronous Livewire call — its `wire:loading` state covers
the wait, no need to dispatch anything in the background) and a
`TicketMessagePosted` listener registered in
`AppServiceProvider::regenerateAiSummaryOnNewMessage()`, active only when
both `ai_summary_enabled` and `ai_summary_regenerate_on_message` (off by
default) are on. That listener dispatches `App\Jobs\GenerateTicketAiSummaryJob`
via `::dispatchAfterResponse()` rather than the normal queue — deliberately
**not** `ShouldQueue`, since this deployment's queue worker is optional
infrastructure (see install.md) and anything pushed onto the `jobs` table
has no guarantee of ever being picked up; `dispatchAfterResponse()` instead
runs the job in-process right after the triggering HTTP/console response is
sent, needing no worker at all.
Its own interval (`ai:run-ticket-automation`) is admin-configurable the same
way the other 3 scheduled commands are — see "Configurable scheduled-command
intervals" above for the mechanism and a boot-time trap worth knowing about
before touching `routes/console.php` again.

View File

@@ -3,6 +3,289 @@
All notable changes to this project are documented in this file. Format loosely All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.5.1] - 2026-08-06
### Added
- **Operator queue columns**: two new optional columns, ID (raw DB id) and
e-mail, alongside the existing set; visible columns can now also be
**reordered** with ↑/↓ arrows next to each entry in the "Kolumny" picker,
not just shown/hidden — order is remembered per operator the same way
visibility already was (`users.operator_queue_columns`).
- **SnipeIT client asset picker**: a second, category-level allow-list
(`snipeit_client_asset_category_ids`) alongside the existing subcategory
one — lets an admin cover every subcategory of a category in one click
instead of ticking each one individually. The two lists are additive.
- Client dashboard and operator ticket-detail page now remember which tab
(Bieżące/Archiwum, or whichever queue tab) was active, so "Wróć do listy"
returns to it instead of always resetting to the default.
### Fixed
- **Pagination controls** (operator queue, client dashboard) no longer
render as Laravel's stock gray Tailwind styling, which only reacted to the
browser/OS's `prefers-color-scheme` and stayed dark even when the app's own
light/dark toggle was set to light. They're now a themed override
(`resources/views/vendor/livewire/tailwind.blade.php` — pagination here
actually renders through Livewire's own pagination view, not Laravel's
default) styled with the app's own light/dark CSS variables, with visible
per-button background/border and a centered layout on mobile.
- **Login notice box** background/border no longer shift hue between light
and dark mode (previously derived from `--color-accent`, which differs per
theme) — it's now one fixed dark color in both themes, so admin-picked
text colors (e.g. white) stay legible regardless of the viewer's theme.
Login card widened (380px → 480px).
- **Client dashboard ticket list**: priority/status badges no longer wrap
onto their own left-aligned line below a long ticket subject on narrow
screens — they stay pinned to the right while the subject text wraps
within its own column instead.
- Timer bookkeeping (`resumeTimer()`/`stopTimer()`/etc.) no longer touches
`updated_at` — merely opening a ticket (or the background timer
starting/stopping) no longer counted as an update, which used to drown out
genuinely stale tickets in any list sorted by that column. The operator
queue and client dashboard both now default to sorting by creation date
instead for the same reason.
- Admin panel and SnipeIT integration config test coverage extended for the
new category-level allow-list.
## [1.5.0] - 2026-08-05
### Added
- **Admin log viewer** (Admin > Logi) — browse `storage/logs/*.log` from the
admin panel without shell access to the container: a file picker (size +
last-modified, most recent first), level/free-text/entry-count filters, and
an optional 5s auto-refresh. Read-only, admin-only; reads only the tail of
large files to keep it fast.
- **"Bez kategorii" filter** in the operator queue's category dropdown —
isolates tickets with neither a category nor subcategory assigned (e.g. an
IMAP mailbox routed to nothing in particular), previously only reachable by
scanning the unfiltered list.
### Changed
- **Hesk import tool** (`scripts/hesk-import/`) no longer auto-creates client
accounts for unrecognized requester e-mails — the servicedesk user base is
now treated as authoritative, so a Hesk ticket whose requester has no
matching account is skipped instead (reported at the end, with the list of
skipped e-mails). Hesk staff replies/notes and ticket ownership are now
linked to real operator/admin accounts when the staff e-mail matches one.
Two new backfill flags cover tickets imported before these existed:
`--assign-operators` (sets `assignee_id` from Hesk's ticket owner, never
overwriting a manual reassignment) and `--fix-closed-dates` (corrects a
closed ticket's date to Hesk's own `closedat` column instead of the
drifting `lastchange`, and adds the missing "Zamknięte" history entry).
Every newly imported ticket also records its source Hesk id
(`tickets.hesk_ticket_id`, unique) as a second, DB-level guard against
duplicate imports on top of the existing state file. See
`scripts/hesk-import/README.md` for details.
- Internal database cleanup: removed three columns confirmed unused against
live data (`users.remember_token`, `users.email_verified_at`,
`email_templates.trigger_label`); moved custom field values, AI
triage/summary state, and the linked Snipe-IT asset off the `tickets` row
into three dedicated one-to-one tables (`ticket_field_values`,
`ticket_ai_summaries`, `ticket_snipeit_assets`) — no visible behavior
change, but custom field values are now efficiently queryable instead of
living only in a JSON blob, and the `tickets` row itself is narrower; added
missing reverse indexes on 4 pivot tables (`team_subcategory`, `role_user`,
`team_user`, `custom_field_subcategory`); `tickets.source` and
`ticket_messages.source` now validate against a known set of values
instead of silently accepting any string.
### Fixed
- The operator statistics dashboard's category/subcategory breakdown (and
its category filter) only counted tickets routed through a subcategory —
a ticket routed to a whole category with no subcategory (e.g. via an IMAP
mailbox routed to "całą kategorię") was silently excluded from those
charts and from filtering by that category. Now counted correctly, with
bare-category tickets shown as their own "(bez podkategorii)" row in the
subcategory breakdown.
- A JavaScript error (and stray background timers left running) could occur
when navigating away from a page with an active countdown/timer widget —
ticket/queue auto-refresh, the theme switcher, file-attachment
drag-and-drop. Most consequential in the operator ticket time tracker,
where it could throw console errors and momentarily break other UI
elements (e.g. dropdown menus) after leaving a ticket with the timer
running.
## [1.4.0] - 2026-08-05
### Added
- **Active Directory support for LDAP auth** (Admin > Integracje > "LDAP /
Active Directory") — a "Typ katalogu" dropdown switches between LLDAP/
OpenLDAP (the original, still the default) and Active Directory. AD uses a
different schema (no inetOrgPerson/posixAccount, a binary `objectGUID`
instead of `entryUUID`) and login attribute (`sAMAccountName`, not `uid`);
both are now auto-detected from the directory type instead of requiring
LLDAP's schema everywhere.
- **Operator client search** (Operator > Klienci) — a dedicated search page
(name or e-mail, any account, not just role=client) showing each match's
role badges and ticket count, linking straight into the queue pre-filtered
to that customer.
- **Paginated ticket lists** — the operator queue (50/page) and client
dashboard (20/page, current/archive tracked as separate pages so switching
tabs doesn't lose your place) no longer render every matching ticket at
once; sorting still happens over the full filtered result first.
- **Command-palette global search (Ctrl+K / Cmd+K)** — searches tickets from
anywhere in the app, scoped to what the searching user can actually see
(operators/admins search everything visible to them, clients only their
own). Supports Gmail-style operators — `od:` (reporter), `temat:`
(subject only), `treść:`/`tresc:` (message content only), `numer:`/`nr:`
(ticket number), combinable and AND'd together (`od:kacper
temat:drukarka`) — plain text with no operator still searches everything
as before. A "Szukaj" button in the operator/admin sidebars opens the same
dialog for anyone who doesn't know the shortcut.
- **Recently viewed tickets** (operator sidebar) — the last 6 tickets an
operator actually opened, most-recent first, re-bumped (not duplicated) on
a repeat visit.
- **Navbar redesign: panel switcher.** The old dropdown-only role switcher
(in the profile menu) and the plain "Panel Klienta/Operatora/Administratora"
text label are replaced by a single segmented control centered in the top
bar — Klient / Operator / Administrator — showing only the areas the
logged-in user actually holds, highlighting the current one, and sliding a
preview to whichever option is hovered before you click. Adapts to a
full-width row below the icons on narrow screens instead of overlapping
them.
- **Richer profile dropdown** — now shows the account's name, e-mail, and
role badges above the existing Powiadomienia/Wyloguj się links.
- **Per-page browser tab titles** — every page sets its own `<title>`
(e.g. the ticket subject, the selected queue, the active admin tab)
instead of every tab just showing the company name, always anchored with
the company name as a suffix so it's still identifiable once the browser
truncates a long tab title.
- **Hesk 3.x historical import** (`scripts/hesk-import/`) — a one-time,
read-only migration of tickets (with full reply/note history) from an old
Hesk helpdesk database, filtered by e-mail domain. Dry-run by default,
resumable, auto-maps categories (exact name match) and teams (when
unambiguous). See `scripts/hesk-import/README.md`.
### Fixed
- Pagination controls ("« Poprzednia" / "Następna »") were showing the raw
translation keys `pagination.previous`/`pagination.next` instead of
actual text — the app's `APP_LOCALE=pl` had no matching `lang/pl/`
translation file and no English fallback (`APP_FALLBACK_LOCALE` is also
`pl`), so Laravel had nothing to resolve those strings to.
## [1.3.0] - 2026-07-27
### Added
- **Snipe-IT asset inventory integration** (Admin > Integracje), optional and
off by default — connects to a Snipe-IT instance by API address + personal
API token (plus a "Nie sprawdzaj SSL" toggle for self-signed instances) and
surfaces three independently switchable capabilities:
- **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — while
creating a ticket, a client sees the devices checked out to them in
Snipe-IT (matched by e-mail) and can pick the one the ticket is about.
Scoped to admin-selected subcategories via a multi-select picker that
only appears once this is turned on — same "nothing shows until
explicitly opted in" convention as BookStack's shelf allow-lists.
- **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — the
same per-requester asset list, shown in a sidebar card on the ticket
view, with a "Powiąż" button per item.
- **Zezwól operatorowi na przeszukiwanie całego inwentarza** — a search
box + button in the same sidebar (not a separate page) letting an
operator link any asset in Snipe-IT, not just the requester's own — for
shared equipment like printers.
- A linked asset shows live status/category/current assignment (fetched
fresh from Snipe-IT, not just the cached label) with an "Odepnij" button
that stays available to the operator regardless of the two toggles above
— clearing an existing link is a correction, not new Snipe-IT access.
Every asset is displayed as "numer środka - numer seryjny - producent
model" plus its Snipe-IT category, joining whichever of those pieces are
actually present.
- **AI summary: manual regenerate + regenerate on new message.** A
"Wygeneruj teraz" button now sits on the operator's "Podsumowanie AI" card
for an immediate, on-demand refresh. Separately, a new admin toggle
("Regeneruj podsumowanie od razu po każdej nowej wiadomości", off by
default) re-runs the summary right after any reply/note lands on a ticket,
instead of only ever picking it up on the next scheduled
`ai:run-ticket-automation` sweep. The transcript sent to the model now also
includes the ticket's own opening body text (previously only the reply
thread), fixing summaries silently missing the original request on long
tickets whose first message had scrolled out of the transcript window.
### Fixed
- The status dropdown in the operator ticket view could keep showing the
pre-change status after sending a reply via a status-changing quick action
(e.g. "Wyślij i oznacz jako rozwiązane") until the next full page load — a
Livewire/Alpine-morph quirk for `<select>` elements bound via `wire:change`
rather than `wire:model`. Fixed by keying the element to the status value
so the DOM node is force-replaced instead of morphed.
## [1.2.2] - 2026-07-24
### Added
- Subcategories can now be reordered within their category — up/down arrow
buttons next to "Edytuj" in Admin > Kategorie, right beside each
subcategory's edit/delete buttons. The order set there is used everywhere a
subcategory list is shown (subcategory pickers, admin listings, etc.), not
just the admin panel itself.
## [1.2.1] - 2026-07-24
### Added
- **Generic AI integration** (Admin > Integracje > "Integracja AI"), optional
and off by default — an OpenAI-compatible `/chat/completions` client (works
against Groq, OpenAI itself, or a self-hosted Ollama instance) configured by
base URL, optional API key, model name, and an SSL-verification toggle for
self-signed local endpoints. Not tied to any one feature — it's the shared
foundation for the two AI-driven features below, and for anything else that
wants an LLM call in the future.
- **BookStack automatic content tagging (AI)** — a "Otaguj nową treść"/"Otaguj
wszystko ponownie (force)" button pair in the BookStack card, plus
`php artisan bookstack:tag-content` (`--dry-run`/`--force`/`--limit=N`) for
the command line. Uses the AI integration above to classify every
book/chapter/page's title+content against the current list of helpdesk
subcategories and tags matching ones by name — idempotent by default
(skips already-tagged content), so re-running after adding a few pages is
cheap. This is what gives the BookStack "search by tags" option below
something to actually match against.
- **BookStack search refinement** — "Przeszukuj" is now three independent
checkboxes (Książki / Strony / Rozdziały) instead of a single dropdown with
no chapter option, plus a new "Szukaj po" setting: słowa kluczowe w nazwie /
tagi / oba. Tag matching uses the bare subcategory name (e.g. "Drukarki i
skanery"), matching what the auto-tagging feature above writes.
- **AI-driven ticket triage + summary** (Admin > Integracje >
"Automatyzacja AI dla zgłoszeń", runs via a new scheduled
`ai:run-ticket-automation`) — five independent toggles: assign a
category/subcategory when a ticket has neither, pick a subcategory when it
only has a category, recheck and possibly correct an already-categorized
ticket, rewrite an unclear subject, and set a priority based on content.
Runs once per ticket in the background (never synchronously at submission,
so it adds no latency for a client), and every applied change leaves a
specific line plus a "Automatyzacja: klasyfikacja AI" entry in the ticket's
history, same convention as the existing SLA automation rules. Separately,
an AI-generated summary + suggested next action for **every** ticket, shown
only to operators in a new "Podsumowanie AI" sidebar card, refreshed
whenever the thread gets a new message — its system prompt is admin-editable
as a plain-text field with a "Resetuj" button back to the shipped default.
- Operators can now reassign a ticket to **any** team, not just one they
belong to (previously the dropdown only ever offered the operator's own
teams).
- The auto-refresh countdown badges (ticket view, operator queue) are now
**clickable** — fetch immediately and reset the countdown, instead of only
ever refreshing on their own fixed schedule.
- **All 7 "cyclical" intervals** in the app are now configurable from
Admin > Konfiguracja instead of fixed in code: the 3 browser auto-refresh
countdowns (ticket view, operator queue), the notification bell's poll,
and the 4 background scheduled commands (SLA breach check, SLA automation
rules, IMAP fetch, AI ticket automation). Defaults match the previous
hardcoded values, so nothing changes until an admin edits them.
### Fixed
- An operator viewing a ticket that gets deleted by someone else, or whose
team changes to one outside the operator's own scope (including via their
own reassignment above), is now redirected back to the operator queue
instead of hitting an error mid-session.
## [1.2.0] - 2026-07-23 ## [1.2.0] - 2026-07-23
### Added ### Added

View File

@@ -21,7 +21,12 @@ roles). Treat the running database as production, not a sandbox:
## Container operations: use `sudo`, never build the image locally ## Container operations: use `sudo`, never build the image locally
All Docker commands against this stack need `sudo` (e.g. All Docker commands against this stack need `sudo` (e.g.
`sudo docker compose exec servicedesk ...`, `sudo docker exec servicedesk-servicedesk-1 ...`). `sudo docker compose exec app ...`, `sudo docker exec servicedesk-app-1 ...`).
The stack is four services sharing the one `servicedesk` image — `app` (Apache,
what actually serves HTTP), `reverb` (websocket server, `php artisan
reverb:start`), `cron` (scheduler loop, `php artisan schedule:work` — see
below), and `mariadb`. Only `app` and `reverb` are reachable from Traefik.
**Never run `docker build`, `docker compose build`, or `--build`.** The **Never run `docker build`, `docker compose build`, or `--build`.** The
`servicedesk` image is built by CI (`.gitea/workflows/build.yml`, triggered on `servicedesk` image is built by CI (`.gitea/workflows/build.yml`, triggered on
@@ -29,7 +34,7 @@ All Docker commands against this stack need `sudo` (e.g.
`compose.yaml` only ever `pull`s a tag (`sudo docker compose pull && sudo docker `compose.yaml` only ever `pull`s a tag (`sudo docker compose pull && sudo docker
compose up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would compose up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would
just diverge from what CI produces. The app container just diverge from what CI produces. The app container
(`servicedesk-servicedesk-1`) mounts `./src` from the host over NFS (`servicedesk-app-1`) mounts `./src` from the host over NFS
(`/mnt/rabbit-containers` → NFS export), so plain file edits already take effect (`/mnt/rabbit-containers` → NFS export), so plain file edits already take effect
with no rebuild or restart: with no rebuild or restart:
@@ -56,33 +61,53 @@ with no rebuild or restart:
surfaces in production as a 500 with `touch(): Utime failed: Operation not surfaces in production as a 500 with `touch(): Utime failed: Operation not
permitted`. If you ran `php artisan test`/`tinker`/any artisan command via permitted`. If you ran `php artisan test`/`tinker`/any artisan command via
`docker exec` in a session where you also edited Blade files afterward, `docker exec` in a session where you also edited Blade files afterward,
finish with `sudo docker exec servicedesk-servicedesk-1 php artisan finish with `sudo docker exec servicedesk-app-1 php artisan
view:clear` to flush any root-owned compiled views before ending the 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. session — don't wait for a report of a broken page to catch it.
## Scheduled commands need a host crontab entry ## Scheduled commands run in the dedicated `cron` container
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)), The Docker image ships no cron/supervisor of its own (see [install.md](install.md)),
so `tickets:check-sla-breaches`, `automation:run-rules`, and `emails:fetch-imap` so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`,
(all registered in `routes/console.php` via `Schedule::command(...)`) only ever and `ai:run-ticket-automation` (all registered in `routes/console.php` via
run if something outside the container calls `php artisan schedule:run` on a `Schedule::command(...)`) only ever run if something calls `php artisan
timer. **As of 2026-07-23 this is configured** — root's crontab on the host schedule:run` on a timer. **As of 2026-08-04 this is the `cron` service** in
runs, every minute: `compose.yaml` — same `servicedesk` image, running `php artisan schedule:work`
(Laravel's own foreground scheduler loop, ticks every minute internally, no
external trigger needed). Before this it was a root crontab entry on the host
calling `docker compose exec -T servicedesk schedule:run`; that entry has been
removed from `sudo crontab -l -u root` now that the container replaces it —
don't re-add it, the two would double-run every scheduled command.
```cron If the `cron` container isn't running (`sudo docker compose ps cron`), none of
* * * * * cd /mnt/rabbit-containers/servicedesk && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 the four scheduled commands fire — same failure mode as the old missing-crontab
``` case, just check the container instead of the crontab. IMAP-specific activity
(`sudo crontab -l -u root` to inspect/edit — it previously did not exist at all,
which meant none of the three scheduled commands above had ever run
automatically; ask before changing this again, since removing it silently
breaks SLA checks, automation rules and IMAP fetching, and confusingly not the
IMAP feature alone if you're only debugging that one.) IMAP-specific activity
(connect attempts, per-message accept/reject decisions, created/replied ticket (connect attempts, per-message accept/reject decisions, created/replied ticket
ids) is logged separately from the app's normal `LOG_LEVEL` to ids) is logged separately from the app's normal `LOG_LEVEL` to
`storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) — `storage/logs/imap-*.log` (see the `imap` channel in `config/logging.php`) —
check there first when a mailbox isn't behaving as expected, before assuming check there first when a mailbox isn't behaving as expected, before assuming
the scheduler itself isn't firing. the scheduler itself isn't firing. `ai:run-ticket-automation` and
`hesk:import` get the same always-debug treatment via the `ai`/`hesk_import`
channels (`storage/logs/ai.log`/`hesk-import.log`). All of `storage/logs/*.log`
is also browsable from Admin > Logi (`app/Livewire/Admin/Logs.php`) if you'd
rather not `docker exec` in just to tail a file.
All four commands' intervals are admin-configurable (Admin > Konfiguracja —
`schedule_sla_check_minutes`/`schedule_automation_rules_minutes`/
`schedule_imap_fetch_minutes`/`schedule_ai_automation_minutes`), which is why
`routes/console.php` registers them as `->everyMinute()->when(fn () =>
Settings::dueEveryMinutes(...))` instead of a plain `->everyFifteenMinutes()`/
`->cron(...)` call — **never build a cron expression (or otherwise read
`Settings`) at that file's top level**. `routes/console.php` is `require`'d on
every artisan boot (`migrate`, `tinker`, `php artisan test`, not just
`schedule:run` — it's wired in via `bootstrap/app.php`'s `commands:` key), so
a top-level `Settings::get(...)` call runs before a fresh/test database
necessarily has the `settings` table, and crashes every single artisan
invocation, not just the scheduler. A `->when($closure)` guard is the fix —
the closure is only ever evaluated later, when `schedule:run` processes due
events. One visible side effect: `php artisan schedule:list` shows
`* * * * *` for all four regardless of the actual configured interval, since
that only exists inside the closure — expected, not a bug worth chasing.
## Apache `/icons/` alias trap ## Apache `/icons/` alias trap
@@ -106,6 +131,24 @@ reason — never add a `public/icons/` directory.
attributes on `<td>`s. Currently applied to the operator ticket queue; apply attributes on `<td>`s. Currently applied to the operator ticket queue; apply
the same treatment to any other wide table you add or make mobile-relevant the same treatment to any other wide table you add or make mobile-relevant
(admin panel tables don't have it yet). (admin panel tables don't have it yet).
- **Ticket list default sort** is `created_at` (or the raw `id`, which is
equivalent — both are monotonic) descending, everywhere a ticket list is
shown: the operator queue (`App\Livewire\Operator\Queue::$sortBy`, default
`'created'`) and both tabs of the client dashboard
(`App\Livewire\Client\Dashboard::baseQuery()`, `orderByDesc('created_at')`).
Deliberately not `updated_at` — see the timer/`updated_at` note in
[ARCHITECTURE.md](ARCHITECTURE.md#data-model); even with that fixed, sorting
by "last touched" is a worse default for a support queue than a stable
creation order. Keep any new ticket list consistent with this rather than
defaulting to `updated_at`.
- **Per-user table customization** (shown/hidden columns + their order): the
operator queue's pattern (`Queue::$visibleColumns`, persisted to
`users.operator_queue_columns` via `persistVisibleColumns()`, reordered with
`moveColumnUp()`/`moveColumnDown()`) is the template to reuse if another
table gains the same feature — auto-save on every toggle/reorder, no
explicit "save" step required from the user. This is intentionally separate
from `SavedQueueView` (named, manually-saved, multi-field filter presets);
don't conflate the two.
## Testing & code style ## Testing & code style

View File

@@ -16,11 +16,11 @@ build through a throwaway `node:22` container as documented there.
1. **Run the test suite** — see [TESTING.md](TESTING.md) for details: 1. **Run the test suite** — see [TESTING.md](TESTING.md) for details:
```bash ```bash
docker compose exec servicedesk php artisan test docker compose exec app php artisan test
``` ```
2. **Run Pint** (Laravel's code-style fixer, default preset, no project overrides): 2. **Run Pint** (Laravel's code-style fixer, default preset, no project overrides):
```bash ```bash
docker compose exec servicedesk ./vendor/bin/pint docker compose exec app ./vendor/bin/pint
``` ```
3. If you changed anything under `resources/`, rebuild the frontend bundle and 3. If you changed anything under `resources/`, rebuild the frontend bundle and
commit the result if `public/build/` is tracked, or confirm the deploy step commit the result if `public/build/` is tracked, or confirm the deploy step

122
README.md
View File

@@ -23,9 +23,25 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
## Feature overview ## Feature overview
- **Navigation** — a segmented Klient/Operator/Administrator switcher in the
top bar (only the areas a user actually holds, current one highlighted,
hover-previews the destination before you click) replaces the old dropdown
role switcher; the profile menu shows name/e-mail/role badges above
Powiadomienia/Wyloguj się; every page sets its own browser-tab title
(ticket subject, selected queue, active admin tab, ...) anchored with the
company name; and a command-palette global search (Ctrl+K/Cmd+K, or a
"Szukaj" sidebar button) finds tickets from anywhere, scoped to what the
searching user can see, with Gmail-style `od:`/`temat:`/`treść:`/`numer:`
operators. The operator sidebar also lists the last 6 tickets they
actually opened ("Ostatnio przeglądane").
- **Tickets** — number, subject, body, category/subcategory, status, priority, team, - **Tickets** — number, subject, body, category/subcategory, status, priority, team,
assignee, custom fields (per subcategory), attachments, full message thread assignee, custom fields (per subcategory), attachments, full message thread
(public replies + internal notes), history log, merge, delete. (public replies + internal notes), history log, merge, delete. The operator
queue (50/page) and client dashboard (20/page, current/archive tracked
separately) paginate rather than rendering every matching ticket at once,
and both default to newest-created-first. The operator queue's columns
(including the raw DB id, e-mail, source, last-updated, ...) can be
individually shown/hidden and reordered, remembered per operator.
- **SLA** — per-priority response/resolution time targets; a scheduled command - **SLA** — per-priority response/resolution time targets; a scheduled command
(`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify (`tickets:check-sla-breaches`, every 15 min) flags overdue tickets and can notify
the assigned operator. the assigned operator.
@@ -40,13 +56,23 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
(operator and client) update live over WebSockets (Laravel Reverb): new (operator and client) update live over WebSockets (Laravel Reverb): new
tickets, status/priority/team/assignee changes, and new replies ("live chat") tickets, status/priority/team/assignee changes, and new replies ("live chat")
all show up without a manual refresh. A periodic fallback refresh (with a all show up without a manual refresh. A periodic fallback refresh (with a
visible countdown) covers a dropped websocket connection. visible, clickable countdown badge — click it to fetch immediately and reset
the countdown) covers a dropped websocket connection. All of the refresh/poll
intervals in the app, browser-side and the background scheduled commands
alike, are configurable from Admin > Konfiguracja (see below).
- **Categories & custom fields** — admin-defined categories/subcategories, each with - **Categories & custom fields** — admin-defined categories/subcategories, each with
its own set of custom fields (text/textarea/select/checkbox/date/number) and an its own set of custom fields (text/textarea/select/checkbox/date/number) and an
optional default priority. optional default priority. Subcategories within a category can be reordered with
up/down arrows in Admin > Kategorie; the order set there is what clients/operators
see everywhere a subcategory picker is shown.
- **Teams** — subcategories auto-route to a team; operators only see their own - **Teams** — subcategories auto-route to a team; operators only see their own
team's queue (plus unrouted tickets and anything assigned to them) unless they're team's queue (plus unrouted tickets and anything assigned to them) unless they're
an admin. an admin. Reassigning a ticket to a team, though, is unrestricted — an operator
can route a ticket to any team, not just one they belong to.
- **Client search** (Operator > Klienci) — find any account by name or e-mail
(not just role=client — a ticket's customer can be any user), see its role
badges and ticket count, and jump straight into the queue pre-filtered to
that customer.
- **Templates** — canned response snippets for the reply box, admin-configurable - **Templates** — canned response snippets for the reply box, admin-configurable
"quick actions" (send + transition status in one click), and HTML e-mail "quick actions" (send + transition status in one click), and HTML e-mail
templates for every ticket lifecycle event (created, status/priority/category/ templates for every ticket lifecycle event (created, status/priority/category/
@@ -63,9 +89,16 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits, e-mail layout/footer, SMTP connection (Admin > E-MAIL), attachment limits,
session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user session lifetime, timezone (Admin > Konfiguracja), and LDAP connection + user
sync + BookStack (Admin > Integracje). sync + BookStack (Admin > Integracje).
- **LDAP auth** — logins bind against an LDAP/LLDAP directory (`config/auth.php`, - **Log viewer** (Admin > Logi) — browse `storage/logs/*.log` (app, IMAP, AI
automation, Hesk import, ...) from the admin panel, with level/text/entry-count
filters and an optional auto-refresh, so diagnosing a scheduled integration
doesn't need shell access to the container.
- **LDAP auth** — logins bind against a directory (`config/auth.php`,
`config/ldap.php`); local accounts (e.g. the emergency `admin` account) fall back `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. to e-mail + local password when the LDAP bind doesn't match. A "Typ katalogu"
toggle (Admin > Integracje) switches between LLDAP/OpenLDAP (default) and
Active Directory, which auto-selects the right schema/login attribute
(`sAMAccountName` + `objectGUID` for AD, vs. `uid` + `entryUUID`).
- **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire - **Triggers** (Admin > Wyzwalacze) — event-driven business rules that fire
immediately on a ticket lifecycle event (created, any field updated, status/ immediately on a ticket lifecycle event (created, any field updated, status/
priority/assignee/team/category changed, new public reply): AND-combined priority/assignee/team/category changed, new public reply): AND-combined
@@ -133,10 +166,54 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
both operators and clients (with a copy-link button for operators). Loads in 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 after the page's first paint rather than blocking it. Configured entirely
from Admin > Integracje: connection + API token, optional SSL-verification from Admin > Integracje: connection + API token, optional SSL-verification
bypass for self-signed instances, page/book search-type filter, and two bypass for self-signed instances, a content-type filter (books/pages/
independent per-shelf allow-lists (nothing is searched until an admin opts chapters, independently toggleable) and a "search by" mode (name / tags /
specific shelves in, separately for ticket-creation suggestions vs. the both), and two independent per-shelf allow-lists (nothing is searched until
operator/client ticket-view sidebar). an admin opts specific shelves in, separately for ticket-creation
suggestions vs. the operator/client ticket-view sidebar). A pair of
"Otaguj nową treść"/"Otaguj wszystko ponownie" buttons (also available as
`php artisan bookstack:tag-content`) use the AI integration below to
auto-tag every book/chapter/page with matching subcategory names, so the
tag-based search mode has something to find.
- **Generic AI integration** (Admin > Integracje > "Integracja AI") *(optional,
off by default)* — an OpenAI-compatible chat-completions client (Groq,
OpenAI, or a self-hosted Ollama instance) configured by base URL, optional
API key, model, and an SSL-verification toggle. Not tied to a single
feature — it backs the BookStack auto-tagging above and the AI ticket
triage/summary below, and is meant to be reused by anything that needs an
LLM call in the future.
- **AI-driven ticket triage + summary** (Admin > Integracje >
"Automatyzacja AI dla zgłoszeń") *(optional, off by default, requires the AI
integration above)* — five independent toggles run once per new ticket, in
the background (`ai:run-ticket-automation`, never synchronously at
submission): assign a category/subcategory when missing, pick a
subcategory when only a category is set, recheck/correct an
already-categorized ticket, rewrite an unclear subject, and set a priority
from the ticket's content. Every applied change is logged in the ticket's
history. Separately, an AI-generated summary + suggested next action for
every ticket, shown to operators only in a "Podsumowanie AI" sidebar card
with a manual "Wygeneruj teraz" button, an admin-editable prompt
(reset-to-default button included), and a per-transcript excerpt of the
ticket's own opening body alongside the reply thread (so long tickets
don't lose the original request once it scrolls out of the message
window). Refreshed by the same periodic sweep by default; an optional
admin toggle regenerates it immediately after every new reply/note
instead of waiting for the next scheduled run.
- **Snipe-IT asset inventory integration** *(optional, off by default)*
connects to a Snipe-IT instance (API address + personal API token, plus an
SSL-verification bypass for self-signed instances) and adds three
independently toggleable capabilities from Admin > Integracje: a client
can pick which of their own Snipe-IT assets a ticket concerns while
creating it (scoped to admin-selected subcategories, empty selection means
it never shows — same convention as BookStack's shelf allow-lists), an
operator sees the requester's own assets in a ticket-view sidebar card,
and an operator can search the entire Snipe-IT inventory from that same
sidebar (not a separate page) to link shared equipment the requester isn't
the current owner of. Every asset is shown as "numer środka - numer
seryjny - producent model" plus its Snipe-IT category; a linked asset's
live status/assignment is fetched fresh on the ticket page, and unlinking
stays available to an operator even if both view/search toggles are later
turned off.
## Tech stack ## Tech stack
@@ -151,11 +228,13 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
build step beyond the CSS bundle. build step beyond the CSS bundle.
- **Database**: MariaDB. - **Database**: MariaDB.
- **Deployment**: `compose.yaml``servicedesk` (source bind-mounted from `./src`, - **Deployment**: `compose.yaml``app` (source bind-mounted from `./src`,
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb` no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
(same image, `php artisan reverb:start`), fronted by Traefik with a private-CA (same image, `php artisan reverb:start`) + `cron` (same image, `php artisan
TLS cert (the websocket path is routed to `reverb` by a higher-priority schedule:work` — runs the scheduled commands below without needing a host
Traefik rule; everything else goes to `servicedesk`). The `servicedesk` image crontab), 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 `app`). The `servicedesk` image
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml` the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
just pulls a tag, it never builds locally. just pulls a tag, it never builds locally.
@@ -177,7 +256,7 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
``` ```
- Fresh install / reset: - Fresh install / reset:
```bash ```bash
sudo docker exec servicedesk-servicedesk-1 php artisan migrate:fresh --seed sudo docker exec servicedesk-app-1 php artisan migrate:fresh --seed
``` ```
Seeds real reference data (categories, custom fields, statuses/priorities/SLA, Seeds real reference data (categories, custom fields, statuses/priorities/SLA,
teams, quick actions, response/e-mail templates, branding/config with example teams, quick actions, response/e-mail templates, branding/config with example
@@ -192,10 +271,15 @@ src/ Laravel application
app/Livewire/ Client/Operator/Admin Livewire components app/Livewire/ Client/Operator/Admin Livewire components
app/Models/ Eloquent models app/Models/ Eloquent models
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted) app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch) app/Console/Commands/ Scheduled commands (SLA breach check, automation rules, IMAP fetch,
AI ticket triage/summary) + bookstack:tag-content + hesk:import
(one-time historical migration, see scripts/hesk-import/)
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient, app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic) ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic),
app/Ldap/ LDAP user model + sync handlers AiClient (generic LLM client), BookStackContentTagger,
TicketAiTriageService, TicketAiSummaryService, SnipeItClient
app/Ldap/ LDAP user models — LldapUser (LLDAP/OpenLDAP, default) and
AdUser (Active Directory) — plus sync handlers
database/migrations/ Schema (one file per table group, final shape) database/migrations/ Schema (one file per table group, final shape)
database/seeders/ DatabaseSeeder — reference data, no ticket data database/seeders/ DatabaseSeeder — reference data, no ticket data
resources/css/ Tailwind entrypoint (needs `npm run build` after edits) resources/css/ Tailwind entrypoint (needs `npm run build` after edits)
@@ -208,4 +292,6 @@ wiki/
client/ How-to guide for the Client role client/ How-to guide for the Client role
operator/ How-to guide for the Operator role operator/ How-to guide for the Operator role
admin/ How-to guide for the Admin role admin/ How-to guide for the Admin role
scripts/
hesk-import/ One-time Hesk 3.x ticket history import — see its own README.md
``` ```

View File

@@ -13,20 +13,20 @@ fast in-process fakes (`array`/`sync`) for the same reason.
From inside the app container (or on a bare-metal install, from `src/`): From inside the app container (or on a bare-metal install, from `src/`):
```bash ```bash
docker compose exec servicedesk php artisan test docker compose exec app php artisan test
``` ```
or directly with Pest: or directly with Pest:
```bash ```bash
docker compose exec servicedesk ./vendor/bin/pest docker compose exec app ./vendor/bin/pest
``` ```
Run a single file or filter by name: Run a single file or filter by name:
```bash ```bash
docker compose exec servicedesk php artisan test --filter=SlaBreachNotificationTest docker compose exec app php artisan test --filter=SlaBreachNotificationTest
docker compose exec servicedesk ./vendor/bin/pest tests/Feature/TicketApiTest.php docker compose exec app ./vendor/bin/pest tests/Feature/TicketApiTest.php
``` ```
There is no CI pipeline configured for this repository — running the suite There is no CI pipeline configured for this repository — running the suite
@@ -60,5 +60,5 @@ automatically without extra boilerplate.
on Laravel's default preset). Run it before committing: on Laravel's default preset). Run it before committing:
```bash ```bash
docker compose exec servicedesk ./vendor/bin/pint docker compose exec app ./vendor/bin/pint
``` ```

View File

@@ -24,7 +24,7 @@ osobne pliki, w dwóch różnych miejscach.
- Docker + wtyczka `docker compose`. - Docker + wtyczka `docker compose`.
- Zewnętrzna sieć Docker `traefik_public`, jeśli używasz Traefika tak jak w - Zewnętrzna sieć Docker `traefik_public`, jeśli używasz Traefika tak jak w
`compose.yaml` (`docker network create traefik_public`, jeśli jeszcze nie `compose.yaml` (`docker network create traefik_public`, jeśli jeszcze nie
istnieje). Bez Traefika trzeba samodzielnie zmapować porty serwisu `servicedesk` istnieje). Bez Traefika trzeba samodzielnie zmapować porty serwisu `app`
na hosta (`ports: ["8080:80"]`) i obsłużyć TLS inaczej (patrz sekcja 2 niżej, w na hosta (`ports: ["8080:80"]`) i obsłużyć TLS inaczej (patrz sekcja 2 niżej, w
razie potrzeby reverse-proxy przed kontenerem). razie potrzeby reverse-proxy przed kontenerem).
@@ -74,7 +74,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
VERSION=1.1.3 # widoczne w Admin > O aplikacji VERSION=1.3.0 # widoczne w Admin > O aplikacji
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1 DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
@@ -90,8 +90,8 @@ QUEUE_CONNECTION=database
`APP_KEY` wygenerujesz komendą artisan (krok 1.4) — zostaw puste w pliku. `APP_KEY` wygenerujesz komendą artisan (krok 1.4) — zostaw puste w pliku.
`LDAP_*` i `MAIL_*` w `src/.env` są tylko **wartościami startowymi/awaryjnymi**. `LDAP_*` i `MAIL_*` w `src/.env` są tylko **wartościami startowymi/awaryjnymi**.
Docelowo LDAP i SMTP konfiguruje się wygodniej z poziomu **Admin > Konfiguracja** Docelowo LDAP konfiguruje się wygodniej z poziomu **Admin > Integracje**, a SMTP
w samej aplikacji (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć z **Admin > Poczta** (patrz ramka ostrzegawcza w kroku 1.6) — ale jeśli chcesz mieć
sensowny fallback zanim ktokolwiek się zaloguje do panelu admina, warto je od razu sensowny fallback zanim ktokolwiek się zaloguje do panelu admina, warto je od razu
uzupełnić: uzupełnić:
@@ -184,7 +184,7 @@ 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. 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 Traefik musi kierować ścieżkę websocketu (`/app*`) do `reverb`, a resztę do
`servicedesk` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu: `app` — na tej samej domenie, więc bez dodatkowego wpisu DNS/certyfikatu:
```yaml ```yaml
reverb: reverb:
@@ -240,10 +240,10 @@ te wartości są wypiekane w zbudowany bundle JS, nie czytane w runtime.
### 1.4. Instalacja aplikacji wewnątrz kontenera ### 1.4. Instalacja aplikacji wewnątrz kontenera
```bash ```bash
docker compose exec servicedesk composer install --no-dev --optimize-autoloader docker compose exec app composer install --no-dev --optimize-autoloader
docker compose exec servicedesk php artisan key:generate docker compose exec app php artisan key:generate
docker compose exec servicedesk php artisan migrate --seed docker compose exec app php artisan migrate --seed
docker compose exec servicedesk php artisan storage:link docker compose exec app php artisan storage:link
``` ```
`migrate --seed` (bez `--fresh`) na pustej bazie utworzy wszystkie tabele i `migrate --seed` (bez `--fresh`) na pustej bazie utworzy wszystkie tabele i
@@ -254,7 +254,7 @@ po pierwszym zalogowaniu (Admin > Użytkownicy).
### 1.5. Zbudowanie zasobów front-endowych (CSS/Tailwind) ### 1.5. Zbudowanie zasobów front-endowych (CSS/Tailwind)
Ani host, ani kontener `servicedesk` nie mają zainstalowanego Node.js — buduj Ani host, ani kontener `app` nie mają zainstalowanego Node.js — buduj
przez jednorazowy kontener `node:22` zamiast dorzucać Node do obrazu aplikacji: przez jednorazowy kontener `node:22` zamiast dorzucać Node do obrazu aplikacji:
```bash ```bash
@@ -265,19 +265,43 @@ docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`. Powtarzaj drugi krok po każdej zmianie w `resources/css/` lub `resources/js/`.
### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka ### 1.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka
`routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules` `routes/console.php` planuje `tickets:check-sla-breaches` i `automation:run-rules`
co 15 minut oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem — co 15 minut, oraz `emails:fetch-imap` (odbieranie zgłoszeń/odpowiedzi e-mailem —
patrz Admin > Poczta) co 5 minut, ale **obraz Dockera nie ma wbudowanego patrz Admin > Poczta) i `ai:run-ticket-automation` (opcjonalna automatyczna
cron/supervisora** — bez dodatkowego kroku żadne z tych zadań nigdy się nie kategoryzacja/podsumowania AI zgłoszeń — patrz Admin > Integracje) co 5 minut,
uruchomi (poczta IMAP nadal da się sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale **obraz Dockera nie ma wbudowanego cron/supervisora** — bez dodatkowego
ale bez crona nic nie dzieje się samo). Najprościej dodać wpis crona **na hoście**: kroku żadne z tych zadań nigdy się nie uruchomi (poczta IMAP nadal da się
sprawdzić ręcznie przyciskiem „Pobierz teraz”, ale bez tego nic nie dzieje się
samo). Wszystkie cztery interwały są też konfigurowalne z poziomu **Admin >
Konfiguracja** (bez potrzeby edycji kodu czy restartu — nowa wartość obowiązuje
od najbliższego tyknięcia harmonogramu).
```cron `compose.yaml` rozwiązuje to czwartą usługą, `cron` — tego samego obrazu
* * * * * cd /ścieżka/do/repo && docker compose exec -T servicedesk php artisan schedule:run >> /dev/null 2>&1 `servicedesk`, tylko z innym poleceniem:
```yaml
cron:
image: gitea.kzbikowski.pl/kzbkowski/servicedesk:${IMAGE_TAG:-latest}
command: php artisan schedule:work
volumes:
- ./src:/var/www/html
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
networks:
- internal
``` ```
`schedule:work` to własna, pierwszoplanowa pętla harmonogramu Laravela —
odpowiednik odpalania `schedule:run` co minutę, ale bez potrzeby zewnętrznego
triggera. Ten kontener nie musi być widoczny w Traefiku (nie obsługuje ruchu
HTTP), stąd tylko sieć `internal`. Sprawdź, że działa: `docker compose ps cron`
oraz `docker compose logs -f cron` (loguje każde odpalenie zaplanowanego
zadania).
Powiadomienia e-mail wysyłają się synchronicznie (nie trafiają do kolejki), więc Powiadomienia e-mail wysyłają się synchronicznie (nie trafiają do kolejki), więc
`php artisan queue:work` nie jest obowiązkowy — `QUEUE_CONNECTION=database` w `php artisan queue:work` nie jest obowiązkowy — `QUEUE_CONNECTION=database` w
`.env` wystarcza jako bezpieczny domyślny driver, gdyby coś w przyszłości zaczęło `.env` wystarcza jako bezpieczny domyślny driver, gdyby coś w przyszłości zaczęło
@@ -286,8 +310,9 @@ kolejkować zadania.
### ⚠️ Ważne: LDAP/SMTP z panelu Admina nadpisują `.env` w locie ### ⚠️ Ważne: LDAP/SMTP z panelu Admina nadpisują `.env` w locie
`AppServiceProvider` na starcie żądania sprawdza tabelę `settings` — jeśli w `AppServiceProvider` na starcie żądania sprawdza tabelę `settings` — jeśli w
Admin > Konfiguracja pole **host LDAP** albo **SMTP włączony + host** jest Admin > Integracje pole **host LDAP** albo w Admin > Poczta **SMTP włączony +
ustawione, **te wartości wygrywają z `.env`**, bez potrzeby restartu czy redeployu. host** jest ustawione, **te wartości wygrywają z `.env`**, bez potrzeby
restartu czy redeployu.
Po świeżym `migrate --seed` te pola zawierają **przykładowe placeholdery** Po świeżym `migrate --seed` te pola zawierają **przykładowe placeholdery**
(`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że (`ldap.example.com`, `smtp.example.com`, `changeme-*-password`) — to znaczy, że
@@ -296,20 +321,43 @@ adresami**, nawet jeśli w `.env` wpisałeś prawdziwe dane! Zanim oddasz system
użytku: użytku:
1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`. 1. Zaloguj się lokalnym kontem `admin@example.com` / `admin`.
2. Wejdź w **Admin > Konfiguracja** i wpisz prawdziwe dane LDAP/SMTP (albo wyczyść 2. Wejdź w **Admin > Integracje** i wpisz prawdziwe dane LDAP (wybierz też
pole hosta LDAP, żeby wrócić do wartości z `.env`). właściwy **Typ katalogu** LLDAP/OpenLDAP albo Active Directory — jeśli
3. Użyj przycisków **„Testuj połączenie”** przy obu sekcjach, zanim zaczniesz katalog to nie LLDAP; albo wyczyść pole hosta LDAP, żeby wrócić do
polegać na logowaniu przez katalog. wartości z `.env`), a w **Admin > Poczta** dane SMTP.
3. Użyj przycisku **„Testuj połączenie”** w Integracje i **„Wyślij testową
wiadomość”** w Poczta, zanim zaczniesz polegać na logowaniu przez katalog
albo na powiadomieniach e-mail.
### Integracje opcjonalne (BookStack) ### Integracje opcjonalne (BookStack, AI)
Podpowiedzi artykułów z bazy wiedzy BookStack (przy tworzeniu zgłoszenia i w 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 panelu operatora) są **domyślnie wyłączone** i nie wymagają żadnej zmiennej w
`.env` — całość konfiguruje się w **Admin > Konfiguracja**: adres instancji, `.env` — całość konfiguruje się w **Admin > Integracje**: adres instancji,
Token ID/Secret (rola/użytkownik właściciela tokenu musi mieć w BookStacku 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 uprawnienie „Access System API”), filtr typu treści (książki/strony/rozdziały,
podpowiedzi przy tworzeniu zgłoszenia i dla panelu operatora — dopóki żadna niezależne checkboxy), tryb wyszukiwania (po nazwie / po tagach / oba), oraz
półka nie jest zaznaczona, wyszukiwanie nic nie zwraca. 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.
Integracja AI (Admin > Integracje > „Integracja AI”) jest **domyślnie
wyłączona** i tak samo nie wymaga żadnej zmiennej w `.env` — adres API
(dowolny dostawca kompatybilny z OpenAI: Groq, OpenAI, lokalny Ollama),
opcjonalny klucz API, nazwa modelu i przełącznik weryfikacji SSL. Sama w
sobie nic nie robi — dopiero po jej włączeniu można włączyć automatyczne
tagowanie treści BookStack (przyciski przy integracji BookStack) oraz
automatyczną kategoryzację/podsumowania AI zgłoszeń (Admin > Integracje >
„Automatyzacja AI dla zgłoszeń”, wymaga też wpisu crona z kroku 1.6/2.6
powyżej — to ten sam harmonogram co SLA/automatyzacje/IMAP).
### Import historycznych zgłoszeń z Heska (opcjonalnie)
Jeśli migrujesz z helpdesku Hesk 3.x, `scripts/hesk-import/` zawiera
jednorazowe (nie ciągłe) narzędzie migracyjne — importuje zgłoszenia wraz z
pełną historią odpowiedzi/notatek, ograniczone do jednej domeny e-mail, w
trybie dry-run domyślnie. Nie dotyka bazy Heska poza odczytem. Zobacz
`scripts/hesk-import/README.md` po pełną instrukcję.
--- ---
@@ -352,7 +400,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl AUTHOR_CONTACT=helpdesk@twoja-domena.pl
VERSION=1.1.3 VERSION=1.3.0
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB
@@ -366,7 +414,7 @@ QUEUE_CONNECTION=database
``` ```
Uzupełnij też `LDAP_*`/`MAIL_*` jak w sekcji 1.2 (to samo ostrzeżenie o Uzupełnij też `LDAP_*`/`MAIL_*` jak w sekcji 1.2 (to samo ostrzeżenie o
Admin > Konfiguracja nadpisującym te wartości w locie dotyczy tu identycznie). Admin > Integracje/Poczta nadpisującym te wartości w locie dotyczy tu identycznie).
```bash ```bash
php artisan key:generate php artisan key:generate
@@ -460,10 +508,11 @@ server {
} }
``` ```
### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP) i kolejka ### 2.6. Zadanie cykliczne (SLA, automatyzacje, poczta IMAP, AI) i kolejka
Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje też Crontab użytkownika, pod którym stoi aplikacja (np. `www-data`) — obsługuje też
`automation:run-rules` i `emails:fetch-imap` (patrz 1.6 wyżej): `automation:run-rules`, `emails:fetch-imap` i `ai:run-ticket-automation`
(patrz 1.6 wyżej, w tym konfigurowalne interwały w Admin > Konfiguracja):
```cron ```cron
* * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1 * * * * * cd /var/www/servicedesk/src && php artisan schedule:run >> /dev/null 2>&1
@@ -506,9 +555,9 @@ jednej ścieżki, analogicznie do reguły Traefika w 1.3b).
### 2.7. Pierwsze logowanie i dalsza konfiguracja ### 2.7. Pierwsze logowanie i dalsza konfiguracja
Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień Identycznie jak w kroku 1.6 — zaloguj się `admin@example.com` / `admin`, zmień
hasło, uzupełnij prawdziwe LDAP/SMTP w Admin > Konfiguracja (placeholdery z seeda hasło, uzupełnij prawdziwe LDAP w Admin > Integracje i SMTP w Admin > Poczta
inaczej realnie próbują łączyć się z fałszywymi adresami), przetestuj oba (placeholdery z seeda inaczej realnie próbują łączyć się z fałszywymi
połączenia przyciskiem „Testuj połączenie”. adresami), przetestuj oba połączenia.
### 2.8. Aktualizacje (bez przestoju) ### 2.8. Aktualizacje (bez przestoju)

View File

@@ -0,0 +1,5 @@
HESK_DB_HOST=
HESK_DB_PORT=3306
HESK_DB_DATABASE=
HESK_DB_USERNAME=
HESK_DB_PASSWORD=

View File

@@ -0,0 +1,127 @@
# Hesk 3.x import
One-time historical migration of tickets (with full reply/note history) from a
Hesk 3.x helpdesk database into this app, restricted to a single e-mail
domain. Read-only against the Hesk database — never writes there.
This is **not** an ongoing sync. Run it once to backfill history from an
old Hesk install; it doesn't pick up edits made in Hesk afterward.
## What it does
- Matches Hesk tickets by requester e-mail domain (`--domain=firma.pl`).
- Imports each ticket's subject/body, status, priority, and full reply +
internal-note history, converting Hesk's `<br />`-laden "plain" text into
real line breaks.
- Maps Hesk categories to this app's categories by exact (case/whitespace-
insensitive) name match. A Hesk category with no match is skipped by
default — pass `--include-unmapped-categories` to import those tickets
anyway, uncategorized.
- Auto-assigns a team when every subcategory under the matched category
routes to the same single team (same rule `TicketService::autoAssignTeam()`
uses for normal ticket creation); ambiguous categories are left unrouted.
- Finds the local client account per requester e-mail (matched by e-mail,
adding the `client` role if it doesn't have it yet). **Never creates a
User** — the servicedesk user base is treated as authoritative/complete, so
a Hesk requester e-mail with no matching account means that ticket is
skipped (reported at the end, with the list of skipped e-mails).
- Hesk staff replies/notes are linked to a real operator account when the
Hesk staff member's e-mail matches an existing servicedesk operator/admin
account; otherwise they fall back to showing the correct staff name and
"operator" badge via `author_name` only, without a clickable user behind
it (this script never creates operator accounts either).
- Every imported ticket also stores its source Hesk ticket id
(`tickets.hesk_ticket_id`, unique). This is a second, DB-level guard
against duplicate imports on top of the state file below — if the state
file is ever lost or out of sync, a re-run still can't create a duplicate
ticket for the same Hesk id.
- A ticket's Hesk owner is matched the same way as reply/note authors and
set as `assignee_id`, so imported tickets show up correctly assigned in
the operator queue instead of everything landing in "Nieprzypisane".
## Setup
```bash
cp scripts/hesk-import/.env.example scripts/hesk-import/.env
```
Fill in `scripts/hesk-import/.env` with the Hesk database's
host/port/database/username/password. That file is gitignored — it holds
real credentials for a database this app otherwise has no access to.
## Usage
Always dry-run first — it reports what *would* happen without writing
anything:
```bash
scripts/hesk-import/hesk-import.sh --domain=firma.pl
```
Try a small batch for real before committing to the whole thing:
```bash
scripts/hesk-import/hesk-import.sh --domain=firma.pl --limit=10 --commit
```
Then the full import:
```bash
scripts/hesk-import/hesk-import.sh --domain=firma.pl --commit
```
Safe to re-run (including after an interrupted/crashed run): already-imported
Hesk ticket ids are tracked in `storage/app/hesk-import-state.json` inside the
app container and skipped on subsequent runs.
### Backfilling team assignment
If tickets were already imported before team-by-category mapping existed (or
teams/subcategories changed since), backfill `team_id` on existing imported
tickets without importing anything new:
```bash
scripts/hesk-import/hesk-import.sh --assign-teams --commit
```
### Fixing closed-ticket dates
New imports already use Hesk's dedicated `closedat` column (not `lastchange`,
which moves forward on any later edit — e.g. a note added after closing) for
a closed ticket's date, and record a matching "Status zmieniony na: Zamknięte"
history entry. To apply the same correction to tickets imported before this
existed (including the original import, from before `hesk_ticket_id` was
even tracked — matched back to Hesk via the unique `(email, created_at)` pair
instead):
```bash
scripts/hesk-import/hesk-import.sh --fix-closed-dates --commit
```
Doesn't import anything new; safe to re-run (already-correct tickets are left
alone).
### Backfilling ticket ownership
New imports already set `assignee_id` from Hesk's ticket owner. To apply the
same to tickets imported before this existed:
```bash
scripts/hesk-import/hesk-import.sh --assign-operators --commit
```
Only touches tickets with no `assignee_id` yet (never overwrites a manual
reassignment made since import) and never invents an assignment — a Hesk
owner of 0 or one with no matching servicedesk account is left unassigned,
unless it's one of the two ids in `ImportHeskTickets::DELETED_STAFF_REASSIGNMENT`
(Hesk staff accounts deleted since, with historical tickets explicitly
reassigned to a current operator per the app owner).
## How it's wired up
`hesk-import.sh` is a thin wrapper: it loads `.env` in this folder, then runs
`php artisan hesk:import` inside the `app` container via `docker compose
exec`, passing the Hesk DB credentials as one-off environment variables (they
never touch the app's own `.env` or get persisted anywhere but the state
file). The actual import logic lives in
[`../../src/app/Console/Commands/ImportHeskTickets.php`](../../src/app/Console/Commands/ImportHeskTickets.php).

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Imports tickets from a Hesk 3.x helpdesk database into this app, filtered
# to one e-mail domain. Thin wrapper around `php artisan hesk:import` (see
# ../../src/app/Console/Commands/ImportHeskTickets.php for the actual logic)
# — this script only wires up the Hesk DB credentials and runs it inside the
# app container. See README.md in this folder for full setup/usage docs.
#
# Setup: copy .env.example (this folder) to .env (gitignored) and fill in
# your Hesk database's host/port/database/username/password.
#
# Usage:
# scripts/hesk-import/hesk-import.sh --domain=firma.pl # dry run (default, writes nothing)
# scripts/hesk-import/hesk-import.sh --domain=firma.pl --limit=10 --commit # real run, first 10 tickets only
# scripts/hesk-import/hesk-import.sh --domain=firma.pl --commit # real run, everything
#
# Safe to re-run: already-imported Hesk tickets are tracked in
# storage/app/hesk-import-state.json inside the app container and skipped.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Brak $ENV_FILE — skopiuj scripts/hesk-import/.env.example i uzupełnij dane dostępowe do bazy Heska." >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${HESK_DB_HOST:?ustaw HESK_DB_HOST w $ENV_FILE}"
: "${HESK_DB_DATABASE:?ustaw HESK_DB_DATABASE w $ENV_FILE}"
: "${HESK_DB_USERNAME:?ustaw HESK_DB_USERNAME w $ENV_FILE}"
HESK_DB_PORT="${HESK_DB_PORT:-3306}"
cd "$REPO_ROOT"
exec sudo docker compose exec \
-e HESK_DB_HOST="$HESK_DB_HOST" \
-e HESK_DB_PORT="$HESK_DB_PORT" \
-e HESK_DB_DATABASE="$HESK_DB_DATABASE" \
-e HESK_DB_USERNAME="$HESK_DB_USERNAME" \
-e HESK_DB_PASSWORD="$HESK_DB_PASSWORD" \
app php artisan hesk:import "$@"

View File

@@ -5,7 +5,7 @@ APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
AUTHOR_CONTACT=helpdesk@kzbikowski.pl AUTHOR_CONTACT=helpdesk@kzbikowski.pl
VERSION=1.1.4 VERSION=1.4.0
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\BookStackContentTagger;
use Illuminate\Console\Command;
class BookstackTagContent extends Command
{
protected $signature = 'bookstack:tag-content
{--dry-run : Klasyfikuj i pokaż wynik bez zapisu tagów do BookStack}
{--force : Klasyfikuj ponownie elementy, które już mają tag podkategorii}
{--limit= : Zatrzymaj się po przetworzeniu N elementów}';
protected $description = 'Otaguj każdą książkę/rozdział/stronę w BookStack pasującymi nazwami podkategorii helpdesku, z pomocą skonfigurowanego dostawcy AI';
public function handle(BookStackContentTagger $tagger): int
{
$totals = $tagger->run(
dryRun: (bool) $this->option('dry-run'),
force: (bool) $this->option('force'),
limit: $this->option('limit') !== null ? (int) $this->option('limit') : null,
);
$this->info(sprintf(
'BookStack tagging: przeskanowano %d, otagowano %d, pominięto %d, nieudanych paczek %d.',
$totals['scanned'],
$totals['tagged'],
$totals['skipped'],
$totals['failed_batches'],
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,908 @@
<?php
namespace App\Console\Commands;
use App\Models\Category;
use App\Models\Role;
use App\Models\Status;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketHistory;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use PDO;
/**
* One-off migration from a Hesk 3.x helpdesk database into this app's own
* tickets/users. Never writes to the Hesk database read-only there.
*
* Runs in dry-run mode by default (reports what it would do); pass --commit
* to actually write. Resumable: every successfully imported Hesk ticket id
* is recorded in a local state file (--state, defaults to
* storage/app/hesk-import-state.json). Belt-and-suspenders: tickets.hesk_ticket_id
* is also unique at the DB level, so a state file that's lost/desynced from
* a crash between commit and state-save can't turn into a silent duplicate
* see alreadyImported(). Not idempotent across *edits* on the Hesk side
* this is a one-time historical import, not an ongoing sync.
*
* As of the 2026-08 re-import pass, this never creates new local User
* accounts (the servicedesk user base is considered authoritative/complete)
* a Hesk requester e-mail with no matching account means the ticket is
* skipped rather than auto-provisioning one. See resolveCustomer().
*/
class ImportHeskTickets extends Command
{
protected $signature = 'hesk:import
{--domain= : Only import tickets whose requester e-mail ends in @this-domain}
{--commit : Actually write to the database (default is a dry run)}
{--limit= : Only process this many Hesk tickets (after the domain filter), useful for a test run}
{--state= : Path to the resume-state JSON file (default storage/app/hesk-import-state.json)}
{--include-unmapped-categories : Also import tickets whose Hesk category has no matching servicedesk category (default: skip them)}
{--assign-teams : Backfill team_id (by category) on already-imported tickets that don\'t have one yet, then exit does not import anything}
{--fix-closed-dates : Backfill accurate closedat-based updated_at + a closure history entry on already-imported closed tickets, then exit does not import anything}
{--assign-operators : Backfill assignee_id (from Hesk\'s ticket owner, matched by e-mail to an existing operator account) on already-imported tickets that don\'t have one yet, then exit — does not import anything}';
protected $description = 'Import tickets (with full reply/note history) from a Hesk 3.x database, restricted to one e-mail domain';
/**
* Hesk ticket.status -> our status_key. Hesk's built-in codes are
* 0=New, 1=Waiting reply (for staff), 2=Replied (waiting on customer),
* 3=Resolved. Any other value (seen: a handful of "5" rows, presumably
* a since-deleted custom status) falls back to 'open'.
*/
private const STATUS_MAP = [
0 => 'new',
1 => 'waiting_operator',
2 => 'waiting_customer',
3 => 'closed',
];
/**
* Hesk ticket.priority -> our priority_key. Hesk's enum is 1=Critical,
* 2=High, 3=Medium (the near-universal default every Hesk category
* here defaults new tickets to priority 3); 0 is an unused/rare edge
* value, treated as the closest thing Hesk has to "Low".
*/
private const PRIORITY_MAP = [
0 => 'low',
1 => 'critical',
2 => 'high',
3 => 'medium',
];
private array $categoryMap = [];
/** @var array<int, int> servicedesk category id => servicedesk team id, only when unambiguous */
private array $teamByCategory = [];
/** @var array<int, string> Hesk help_users.id => name, loaded once */
private array $heskStaffNames = [];
/** @var array<int, ?string> Hesk help_users.id => email, loaded once */
private array $heskStaffEmails = [];
/** @var array<int, ?User> Hesk help_users.id => matching local operator/admin account (or null), memoized */
private array $operatorCache = [];
/** @var int[] hesk ticket ids already present in tickets.hesk_ticket_id — see alreadyImported() */
private array $importedHeskIds = [];
/** @var Collection<int, Collection> Hesk ticket id => its help_replies rows, preloaded in bulk for the whole run — see fix for the old per-ticket N+1 query */
private Collection $repliesByTicket;
/** @var Collection<int, Collection> Hesk ticket id => its help_notes rows, preloaded in bulk */
private Collection $notesByTicket;
private array $state = ['imported' => []];
private string $statePath;
public function handle(): int
{
if ($this->option('assign-teams')) {
return $this->runAssignTeams((bool) $this->option('commit'));
}
if ($this->option('fix-closed-dates')) {
return $this->configureHeskConnection()
? $this->runFixClosedDates((bool) $this->option('commit'))
: self::FAILURE;
}
if ($this->option('assign-operators')) {
return $this->configureHeskConnection()
? $this->runAssignOperators((bool) $this->option('commit'))
: self::FAILURE;
}
$domain = trim((string) $this->option('domain'), " \t\n\r\0\x0B@");
if ($domain === '') {
$this->error('Podaj --domain=twoja-domena.pl (bez @).');
return self::FAILURE;
}
$commit = (bool) $this->option('commit');
$limit = $this->option('limit') !== null ? (int) $this->option('limit') : null;
$includeUnmapped = (bool) $this->option('include-unmapped-categories');
$this->statePath = $this->option('state') ?: storage_path('app/hesk-import-state.json');
if (! $this->configureHeskConnection()) {
return self::FAILURE;
}
$this->loadState();
$this->buildCategoryMap();
$this->buildTeamMap();
$this->loadHeskStaffNames();
$this->importedHeskIds = Ticket::query()->whereNotNull('hesk_ticket_id')->pluck('hesk_ticket_id')->all();
$tickets = DB::connection('hesk')->table('help_tickets')
->where('email', 'like', '%@'.$domain)
->orderBy('id')
->when($limit, fn ($q) => $q->limit($limit))
->get();
// Only needed once a ticket is actually about to be imported, so
// skip the two bulk queries entirely on a dry run.
if ($commit) {
$ticketIds = $tickets->pluck('id')->all();
$this->repliesByTicket = DB::connection('hesk')->table('help_replies')
->whereIn('replyto', $ticketIds)->orderBy('dt')->get()->groupBy('replyto');
$this->notesByTicket = DB::connection('hesk')->table('help_notes')
->whereIn('ticket', $ticketIds)->orderBy('dt')->get()->groupBy('ticket');
}
$startMessage = sprintf(
'%s tryb: %d zgłoszeń z Heska pasuje do domeny @%s (%d już zaimportowanych wcześniej, zostaną pominięte).',
$commit ? 'KOMMIT' : 'DRY-RUN',
$tickets->count(),
$domain,
$tickets->pluck('id')->filter(fn ($id) => $this->alreadyImported($id))->count(),
);
$this->info($startMessage);
Log::channel('hesk_import')->info($startMessage);
$stats = ['created' => 0, 'skipped' => 0, 'skipped_unmapped_category' => 0, 'skipped_unknown_customer' => 0, 'failed' => 0, 'messages' => 0, 'operator_messages_linked' => 0];
$unmappedCategories = [];
$unknownCustomerEmails = [];
$bar = $this->output->createProgressBar($tickets->count());
$bar->start();
foreach ($tickets as $heskTicket) {
$bar->advance();
if ($this->alreadyImported($heskTicket->id)) {
$stats['skipped']++;
continue;
}
$categoryMapped = array_key_exists($heskTicket->category, $this->categoryMap);
if (! $categoryMapped && ! in_array($heskTicket->category, $unmappedCategories, true)) {
$unmappedCategories[] = $heskTicket->category;
}
// Default: a Hesk category with no servicedesk equivalent means
// this ticket is skipped entirely rather than imported without
// a category — --include-unmapped-categories opts back in.
if (! $categoryMapped && ! $includeUnmapped) {
$stats['skipped_unmapped_category']++;
continue;
}
// The local user base is authoritative as of the 2026-08
// re-import — a requester e-mail with no matching account is
// skipped rather than auto-provisioning a new client (see
// resolveCustomer()). Checked even in dry-run so the preview
// accurately reflects what --commit would do.
$customerEmail = trim($heskTicket->email);
if (! User::query()->where('email', $customerEmail)->exists()) {
$stats['skipped_unknown_customer']++;
if (! in_array($customerEmail, $unknownCustomerEmails, true)) {
$unknownCustomerEmails[] = $customerEmail;
}
continue;
}
if (! $commit) {
$stats['created']++;
continue;
}
try {
DB::transaction(function () use ($heskTicket, &$stats) {
$this->importOneTicket($heskTicket, $stats);
});
$this->state['imported'][] = $heskTicket->id;
$this->saveState();
} catch (\Throwable $e) {
$stats['failed']++;
$this->newLine();
$this->error("Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: ".$e->getMessage());
Log::channel('hesk_import')->error(
"Zgłoszenie Hesk #{$heskTicket->id} ({$heskTicket->trackid}) nie zostało zaimportowane: {$e->getMessage()}\n".$e->getTraceAsString()
);
}
}
$bar->finish();
$this->newLine(2);
$this->table(['Miara', 'Wartość'], [
['Zgłoszenia utworzone', $stats['created']],
['Wiadomości/notatki utworzone', $stats['messages']],
['...w tym powiązane z realnym kontem operatora', $stats['operator_messages_linked']],
['Pominięte (już zaimportowane)', $stats['skipped']],
['Pominięte (kategoria bez odpowiednika)', $stats['skipped_unmapped_category']],
['Pominięte (brak konta klienta w servicedesk)', $stats['skipped_unknown_customer']],
['Błędy', $stats['failed']],
]);
if ($unmappedCategories) {
$names = collect($unmappedCategories)
->map(fn ($id) => DB::connection('hesk')->table('help_categories')->where('id', $id)->value('name') ?? "id={$id}")
->implode(', ');
$action = $includeUnmapped ? 'zgłoszenia zaimportowane bez kategorii' : 'zgłoszenia POMINIĘTE — użyj --include-unmapped-categories, żeby jednak je zaimportować bez kategorii';
$this->warn("Kategorie Heska bez odpowiednika w servicedesk ({$action}): {$names}");
}
if ($unknownCustomerEmails) {
$this->warn(sprintf(
'E-maile z Heska bez konta w servicedesk (%d zgłoszeń POMINIĘTYCH): %s',
$stats['skipped_unknown_customer'],
implode(', ', $unknownCustomerEmails),
));
}
$summaryMessage = sprintf(
'Zakończono (%s): utworzone=%d, wiadomości=%d, pominięte=%d, pominięte(kategoria)=%d, pominięte(brak konta)=%d, błędy=%d.',
$commit ? 'commit' : 'dry-run',
$stats['created'],
$stats['messages'],
$stats['skipped'],
$stats['skipped_unmapped_category'],
$stats['skipped_unknown_customer'],
$stats['failed'],
);
Log::channel('hesk_import')->info($summaryMessage);
if (! $commit) {
$this->newLine();
$this->comment('To był dry-run — nic nie zostało zapisane. Uruchom ponownie z --commit, żeby faktycznie zaimportować.');
}
return self::SUCCESS;
}
/**
* State file first (cheap, in-memory), then the DB as a fallback/self-heal
* a hesk_ticket_id already present on a ticket row means it was
* genuinely committed even if the state file never got updated (crash
* between the transaction commit and saveState()). Tickets imported
* before the hesk_ticket_id column existed have no such row to fall back
* on, so the state file remains authoritative for those no regression.
*/
private function alreadyImported(int $heskTicketId): bool
{
if (in_array($heskTicketId, $this->state['imported'], true)) {
return true;
}
if (in_array($heskTicketId, $this->importedHeskIds, true)) {
$this->state['imported'][] = $heskTicketId;
return true;
}
return false;
}
private function configureHeskConnection(): bool
{
$host = env('HESK_DB_HOST');
$port = env('HESK_DB_PORT', 3306);
$database = env('HESK_DB_DATABASE');
$username = env('HESK_DB_USERNAME');
$password = env('HESK_DB_PASSWORD');
if (! $host || ! $database || ! $username) {
$this->error('Brakuje HESK_DB_HOST / HESK_DB_DATABASE / HESK_DB_USERNAME w środowisku (patrz scripts/hesk-import/hesk-import.sh).');
return false;
}
Config::set('database.connections.hesk', [
'driver' => 'mysql',
'host' => $host,
'port' => $port,
'database' => $database,
'username' => $username,
'password' => $password,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'options' => [PDO::ATTR_TIMEOUT => 10],
]);
try {
DB::connection('hesk')->getPdo();
} catch (\Throwable $e) {
$this->error('Nie udało się połączyć z bazą Heska: '.$e->getMessage());
return false;
}
return true;
}
/**
* Hesk category name (normalized) -> servicedesk category id. Only
* exact (case/whitespace-insensitive) name matches are mapped; anything
* else is left uncategorized and reported at the end instead of guessed at.
*/
private function buildCategoryMap(): void
{
$ours = Category::query()->get()->keyBy(fn (Category $c) => $this->normalizeCategoryName($c->name));
foreach (DB::connection('hesk')->table('help_categories')->get() as $heskCategory) {
$match = $ours->get($this->normalizeCategoryName($heskCategory->name));
if ($match) {
$this->categoryMap[$heskCategory->id] = $match->id;
}
}
}
private function normalizeCategoryName(string $name): string
{
return mb_strtolower(trim($name));
}
/**
* servicedesk category id -> servicedesk team id, only when every
* subcategory under that category belongs to the same single team (the
* existing team_subcategory routing see TicketService::autoAssignTeam()
* for the same rule applied to normal in-app ticket creation, there scoped
* to a specific subcategory rather than a whole category). A category
* whose subcategories are split across more than one team is left
* unmapped rather than guessed at.
*/
private function buildTeamMap(): void
{
foreach (Category::query()->pluck('id') as $categoryId) {
$teamIds = Team::query()
->whereHas('subcategories', fn ($q) => $q->where('subcategories.category_id', $categoryId))
->pluck('id')
->unique();
if ($teamIds->count() === 1) {
$this->teamByCategory[$categoryId] = $teamIds->first();
}
}
}
/**
* Backfill mode (--assign-teams): sets team_id on tickets that already
* exist (from an earlier --commit run, before team assignment was added)
* and don't have one yet — doesn't import anything, doesn't touch the
* Hesk database at all.
*/
private function runAssignTeams(bool $commit): int
{
$this->buildTeamMap();
if (! $this->teamByCategory) {
$this->warn('Żadna kategoria nie ma jednoznacznie przypisanego zespołu (na podstawie podkategorii) — nie ma czego przypisać.');
return self::SUCCESS;
}
$totalUpdated = 0;
foreach ($this->teamByCategory as $categoryId => $teamId) {
$query = Ticket::query()->where('category_id', $categoryId)->whereNull('team_id');
$count = $query->count();
if ($count === 0) {
continue;
}
$this->line(sprintf(
'Kategoria "%s" -> zespół "%s": %d zgłoszeń%s',
Category::query()->find($categoryId)?->name ?? "id={$categoryId}",
Team::query()->find($teamId)?->name ?? "id={$teamId}",
$count,
$commit ? '' : ' (dry-run)',
));
if ($commit) {
$query->update(['team_id' => $teamId]);
}
$totalUpdated += $count;
}
$this->newLine();
$this->info(($commit ? 'Zaktualizowano' : 'Do zaktualizowania').': '.$totalUpdated.' zgłoszeń.');
if (! $commit) {
$this->comment('To był dry-run — uruchom ponownie z --assign-teams --commit, żeby faktycznie zapisać.');
}
return self::SUCCESS;
}
/**
* Backfill mode (--fix-closed-dates): corrects already-imported closed
* tickets whose updated_at is Hesk's lastchange (any edit a later note
* or reply rather than the actual closure) instead of Hesk's dedicated
* closedat column, and adds the missing "Status zmieniony na: Zamknięte"
* history entry every closed ticket should have (importOneTicket() now
* does both automatically for new imports see there). Idempotent: an
* already-correct ticket, or one that already has that exact history
* line, is left alone.
*
* Also covers tickets imported before tickets.hesk_ticket_id existed
* (no direct link back to Hesk at all the vast majority of closed
* imported tickets are in this group) by matching them to their source
* Hesk row via (email, created_at) <-> Hesk's (email, dt): every
* imported ticket's created_at is Hesk's dt passed through unchanged, and
* that pair is unique across every hesk_import ticket today (checked
* zero collisions), since dt is second-precision and two tickets from
* the same requester in the same second essentially never happens. Their
* hesk_ticket_id gets backfilled too as a side effect, closing the gap
* where that link never existed for them.
*/
private function runFixClosedDates(bool $commit): int
{
$tickets = DB::table('tickets')
->where('source', 'hesk_import')
->where('status_key', 'closed')
->get(['id', 'hesk_ticket_id', 'email', 'created_at', 'updated_at']);
if ($tickets->isEmpty()) {
$this->warn('Brak zamkniętych, zaimportowanych z Heska zgłoszeń.');
return self::SUCCESS;
}
[$withId, $withoutId] = $tickets->partition(fn ($t) => $t->hesk_ticket_id !== null);
$heskById = $withId->isEmpty() ? collect() : DB::connection('hesk')->table('help_tickets')
->whereIn('id', $withId->pluck('hesk_ticket_id'))
->get(['id', 'closedat', 'lastchange'])
->keyBy('id');
$heskByEmailDt = $withoutId->isEmpty() ? collect() : DB::connection('hesk')->table('help_tickets')
->whereIn('email', $withoutId->pluck('email')->unique())
->get(['id', 'email', 'dt', 'closedat', 'lastchange'])
->keyBy(fn ($row) => $row->email.'|'.$row->dt);
$closureLabel = 'Status zmieniony na: '.Status::labelFor('closed');
$alreadyRecorded = TicketHistory::query()
->whereIn('ticket_id', $tickets->pluck('id'))
->where('text', $closureLabel)
->pluck('ticket_id')
->all();
$dateFixed = 0;
$historyAdded = 0;
$idBackfilled = 0;
$unmatched = 0;
foreach ($tickets as $ticket) {
$heskRow = $ticket->hesk_ticket_id !== null
? $heskById->get($ticket->hesk_ticket_id)
: $heskByEmailDt->get($ticket->email.'|'.$ticket->created_at);
if (! $heskRow) {
$unmatched++;
continue;
}
if ($ticket->hesk_ticket_id === null) {
$idBackfilled++;
if ($commit) {
DB::table('tickets')->where('id', $ticket->id)->update(['hesk_ticket_id' => $heskRow->id]);
}
}
$closedAt = $heskRow->closedat ?: $heskRow->lastchange;
if (! $closedAt) {
continue;
}
if ((string) $ticket->updated_at !== (string) $closedAt) {
$dateFixed++;
if ($commit) {
DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $closedAt]);
}
}
if (! in_array($ticket->id, $alreadyRecorded, true)) {
$historyAdded++;
if ($commit) {
TicketHistory::query()->create([
'ticket_id' => $ticket->id,
'text' => $closureLabel,
'created_at' => $closedAt,
]);
}
}
}
$this->table(['Miara', 'Wartość'], [
['Sprawdzone zamknięte zgłoszenia', $tickets->count()],
['Poprawiona data zamknięcia', $dateFixed],
['Dodany wpis historii zamknięcia', $historyAdded],
['Uzupełniony hesk_ticket_id (stare importy)', $idBackfilled],
['Bez dopasowania w bazie Heska', $unmatched],
]);
if (! $commit) {
$this->newLine();
$this->comment('To był dry-run — uruchom ponownie z --fix-closed-dates --commit, żeby faktycznie zapisać.');
}
return self::SUCCESS;
}
/**
* Hesk's dedicated closedat column (set once when a ticket transitions
* to Resolved) is a more accurate "when was this actually closed" than
* lastchange, which moves forward on ANY later edit a note added
* after closing, for instance. Falls back to lastchange for the rare
* closed ticket with no closedat recorded (seen on very old rows).
*/
private function heskClosedAt(object $heskTicket): ?string
{
return $heskTicket->closedat ?: $heskTicket->lastchange;
}
/**
* Backfill mode (--assign-operators): sets assignee_id (from Hesk's
* ticket owner, resolved to a local operator by e-mail same
* resolveOperator() used for reply/note authorship) on already-imported
* tickets that don't have one yet. importOneTicket() does this
* automatically for new imports see there. Never overwrites an
* assignee_id an operator may have since set manually in servicedesk
* (only touches tickets where it's still null), and never invents an
* assignment: a Hesk owner of 0 (unassigned) or one whose e-mail
* doesn't match any local operator (e.g. Hesk staff since deleted
* seen in practice on this data) is left unassigned rather than guessed.
*/
private function runAssignOperators(bool $commit): int
{
$tickets = DB::table('tickets')
->where('source', 'hesk_import')
->whereNotNull('hesk_ticket_id')
->whereNull('assignee_id')
->get(['id', 'hesk_ticket_id']);
if ($tickets->isEmpty()) {
$this->warn('Brak zaimportowanych zgłoszeń bez przypisanego operatora do sprawdzenia.');
return self::SUCCESS;
}
$this->loadHeskStaffNames();
$heskOwners = DB::connection('hesk')->table('help_tickets')
->whereIn('id', $tickets->pluck('hesk_ticket_id'))
->pluck('owner', 'id');
$assigned = 0;
$noHeskOwner = 0;
$ownerUnmatched = 0;
foreach ($tickets as $ticket) {
$ownerId = (int) ($heskOwners[$ticket->hesk_ticket_id] ?? 0);
if ($ownerId <= 0) {
$noHeskOwner++;
continue;
}
$operator = $this->resolveAssignee($ownerId);
if (! $operator) {
$ownerUnmatched++;
continue;
}
$assigned++;
if ($commit) {
DB::table('tickets')->where('id', $ticket->id)->update(['assignee_id' => $operator->id]);
}
}
$this->table(['Miara', 'Wartość'], [
['Sprawdzone zgłoszenia bez operatora', $tickets->count()],
['Przypisano operatora', $assigned],
['Brak właściciela w Hesku (nieprzypisane)', $noHeskOwner],
['Właściciel w Hesku bez konta w servicedesk', $ownerUnmatched],
]);
if (! $commit) {
$this->newLine();
$this->comment('To był dry-run — uruchom ponownie z --assign-operators --commit, żeby faktycznie zapisać.');
}
return self::SUCCESS;
}
private function loadHeskStaffNames(): void
{
$rows = DB::connection('hesk')->table('help_users')->select('id', 'name', 'email')->get();
$this->heskStaffNames = $rows->pluck('name', 'id')->all();
$this->heskStaffEmails = $rows->pluck('email', 'id')->all();
}
/**
* Matches a Hesk staff member to a real local User by e-mail, so
* imported replies/notes are linked to an actual clickable operator
* account rather than only carrying the right name via author_name.
* Falls back to null (unchanged prior behavior) when no local account
* has that e-mail this never creates operator accounts.
*/
private function resolveOperator(int $heskStaffId): ?User
{
if (! array_key_exists($heskStaffId, $this->operatorCache)) {
$email = $this->heskStaffEmails[$heskStaffId] ?? null;
$this->operatorCache[$heskStaffId] = $email ? User::query()->where('email', $email)->first() : null;
}
return $this->operatorCache[$heskStaffId];
}
/**
* Hesk staff ids whose account has since been fully deleted from Hesk
* (no help_users row left at all resolveOperator() has no e-mail to
* even look up for these) mapped to the servicedesk operator who should
* own their historical tickets now, per explicit instruction from the
* app owner. staffid 6 was Katarzyna Piewiszkis, 7 was Agnieszka
* Konopka identified from ticket-history text remnants, not from any
* structured Hesk data (there wasn't any left).
*/
private const DELETED_STAFF_REASSIGNMENT = [
6 => 'agolebiowska@polagent.com',
7 => 'agolebiowska@polagent.com',
];
/**
* Ticket-*ownership*-specific resolution: falls back to
* DELETED_STAFF_REASSIGNMENT when resolveOperator() can't do anything
* (no e-mail left in Hesk to look up). Deliberately not folded into
* resolveOperator() itself reassigning who now owns a ticket is
* reasonable, but reply/note *authorship* should keep reflecting who
* actually wrote it rather than being silently rewritten to whoever
* ticket ownership was reassigned to.
*/
private function resolveAssignee(int $heskOwnerId): ?User
{
return $this->resolveOperator($heskOwnerId)
?? (isset(self::DELETED_STAFF_REASSIGNMENT[$heskOwnerId])
? User::query()->where('email', self::DELETED_STAFF_REASSIGNMENT[$heskOwnerId])->first()
: null);
}
private function importOneTicket(object $heskTicket, array &$stats): void
{
$customer = $this->resolveCustomer($heskTicket->email);
if (! $customer) {
// Guarded against in handle() before this is ever called — kept
// here as a hard stop rather than silently creating an
// orphaned/incorrect ticket if that guard is ever bypassed.
throw new \RuntimeException("Brak konta klienta dla {$heskTicket->email} — nie powinno się zdarzyć, sprawdzono wcześniej w handle().");
}
$categoryId = $this->categoryMap[$heskTicket->category] ?? null;
$statusKey = self::STATUS_MAP[(int) $heskTicket->status] ?? 'open';
$closedAt = $statusKey === 'closed' ? $this->heskClosedAt($heskTicket) : null;
$assignee = ((int) $heskTicket->owner) > 0 ? $this->resolveAssignee((int) $heskTicket->owner) : null;
$ticket = Ticket::query()->create([
'number' => Ticket::nextNumber(),
'customer_id' => $customer->id,
'email' => $heskTicket->email,
'name' => $heskTicket->name ?: $heskTicket->email,
'category_id' => $categoryId,
'team_id' => $categoryId ? ($this->teamByCategory[$categoryId] ?? null) : null,
'assignee_id' => $assignee?->id,
'subject' => $this->cleanText($heskTicket->subject) ?: '(bez tematu)',
'body' => $this->cleanText($heskTicket->message),
'status_key' => $statusKey,
'priority_key' => self::PRIORITY_MAP[(int) $heskTicket->priority] ?? 'medium',
'source' => 'hesk_import',
'hesk_ticket_id' => $heskTicket->id,
'last_customer_activity_at' => $heskTicket->lastchange,
'created_at' => $heskTicket->dt,
'updated_at' => $closedAt ?? $heskTicket->lastchange,
]);
// Ticket::booted() re-saves the row right after create() to stamp a
// checksum, which — being a normal Eloquent save() — stomps
// updated_at back to "now". Restore the historical value via the
// query builder so it bypasses Eloquent's timestamp handling.
DB::table('tickets')->where('id', $ticket->id)->update(['updated_at' => $closedAt ?? $heskTicket->lastchange]);
$opening = $ticket->messages()->create([
'author_name' => $heskTicket->name ?: $heskTicket->email,
'body' => $this->cleanText($heskTicket->message),
'created_at' => $heskTicket->dt,
'updated_at' => $heskTicket->dt,
]);
$opening->attachAuthor($customer->id, 'client');
$stats['messages']++;
foreach ($this->heskReplies($heskTicket->id) as $reply) {
$this->importReply($ticket, $reply, $customer, $stats);
}
foreach ($this->heskNotes($heskTicket->id) as $note) {
$this->importNote($ticket, $note, $stats);
}
// Mirrors what a real in-app closure leaves behind (see
// TicketService::setStatus()) so an imported closed ticket's
// "Historia zmian" tab isn't empty and shows an accurate closure
// date — without this, nothing else records when/that it closed.
if ($closedAt) {
$ticket->histories()->create([
'text' => 'Status zmieniony na: '.Status::labelFor('closed'),
'created_at' => $closedAt,
]);
}
$stats['created']++;
}
/**
* Both pulled from repliesByTicket/notesByTicket, bulk-preloaded once
* for the whole run in handle() rather than queried per ticket here
* on a multi-thousand-ticket import that was 2 extra DB round-trips per
* ticket for no reason, since the domain filter already bounds the
* result set to something worth loading in one shot.
*/
private function heskReplies(int $heskTicketId): Collection
{
return $this->repliesByTicket->get($heskTicketId, collect());
}
private function heskNotes(int $heskTicketId): Collection
{
return $this->notesByTicket->get($heskTicketId, collect());
}
private function importReply(Ticket $ticket, object $reply, User $customer, array &$stats): void
{
$isStaff = (int) $reply->staffid > 0;
$operator = $isStaff ? $this->resolveOperator((int) $reply->staffid) : null;
$authorName = $isStaff
? ($operator->name ?? $this->heskStaffNames[$reply->staffid] ?? 'Personel')
: ($reply->name ?: $customer->name);
$message = $ticket->messages()->create([
'author_name' => $authorName,
'body' => $this->cleanText($reply->message),
'created_at' => $reply->dt,
'updated_at' => $reply->dt,
]);
// Linked to a real operator User when the Hesk staff e-mail matches
// one (resolveOperator()); otherwise falls back to the previous
// behavior — attachAuthor(null, 'operator') still tags the
// role/badge correctly via author_name, just without a clickable
// user behind it.
$message->attachAuthor($isStaff ? $operator?->id : $customer->id, $isStaff ? 'operator' : 'client');
$stats['messages']++;
if ($isStaff && $operator) {
$stats['operator_messages_linked']++;
}
}
private function importNote(Ticket $ticket, object $note, array &$stats): void
{
$operator = $this->resolveOperator((int) $note->who);
$message = $ticket->messages()->create([
'author_name' => $operator->name ?? $this->heskStaffNames[$note->who] ?? 'Personel',
'internal' => true,
'body' => $this->cleanText($note->message),
'created_at' => $note->dt,
'updated_at' => $note->dt,
]);
$message->attachAuthor($operator?->id, 'operator');
$stats['messages']++;
if ($operator) {
$stats['operator_messages_linked']++;
}
}
/**
* Finds the local client account for a Hesk requester e-mail. As of the
* 2026-08 re-import the servicedesk user base is considered
* authoritative/complete this deliberately never creates a User
* anymore (unlike the original 2026-08-04 run); handle() checks
* existence before a ticket ever reaches here, so returning null is not
* expected in normal operation (see importOneTicket()'s hard-stop guard).
* Only ever adds the 'client' role to a match never removes whatever
* roles the account already had.
*/
private function resolveCustomer(string $email): ?User
{
$user = User::query()->where('email', trim($email))->first();
if (! $user) {
return null;
}
if (! in_array('client', $user->roles, true)) {
$user->roles = [...$user->roles, 'client'];
$user->save();
}
return $user;
}
/**
* Hesk's "plain" message/subject columns still carry <br /> tags (and
* occasionally other inline HTML) from HTML-formatted source e-mails
* this app renders ticket/message bodies as escaped plain text
* (white-space:pre-wrap), so raw tags would show up literally instead
* of as line breaks.
*/
private function cleanText(?string $value): string
{
if ($value === null || $value === '') {
return '';
}
$text = preg_replace('/<br\s*\/?>/i', "\n", $value);
$text = strip_tags($text);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($text);
}
private function loadState(): void
{
if (is_file($this->statePath)) {
$decoded = json_decode(file_get_contents($this->statePath), true);
$this->state = is_array($decoded) ? $decoded : $this->state;
$this->state['imported'] ??= [];
}
}
private function saveState(): void
{
$dir = dirname($this->statePath);
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($this->statePath, json_encode($this->state));
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\TicketAiSummaryService;
use App\Services\TicketAiTriageService;
use Illuminate\Console\Command;
class RunAiTicketAutomation extends Command
{
protected $signature = 'ai:run-ticket-automation';
protected $description = 'Run AI-driven ticket triage (categorize/prioritize new tickets) and refresh AI ticket summaries for the operator view';
public function handle(TicketAiTriageService $triage, TicketAiSummaryService $summary): int
{
$triageTotals = $triage->run();
$summaryTotals = $summary->run();
$this->info(sprintf(
'AI triage: scanned %d, changed %d, failed %d.',
$triageTotals['scanned'],
$triageTotals['changed'],
$triageTotals['failed'],
));
$this->info(sprintf(
'AI summaries: scanned %d, updated %d, failed %d.',
$summaryTotals['scanned'],
$summaryTotals['updated'],
$summaryTotals['failed'],
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Jobs;
use App\Models\Ticket;
use App\Services\TicketAiSummaryService;
use Illuminate\Foundation\Bus\Dispatchable;
/**
* Deliberately NOT a queued job (no ShouldQueue) this app's queue worker
* is optional infrastructure (see install.md), so anything pushed onto the
* `jobs` table has no guarantee of ever being picked up. Dispatched with
* ::dispatchAfterResponse() instead, which runs it in-process right after
* the triggering HTTP/console response is sent, needing no worker at all.
*/
class GenerateTicketAiSummaryJob
{
use Dispatchable;
public function __construct(protected int $ticketId) {}
public function handle(TicketAiSummaryService $summary): void
{
$ticket = Ticket::find($this->ticketId);
if ($ticket) {
$summary->generateFor($ticket);
}
}
}

18
src/app/Ldap/AdUser.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Ldap;
use LdapRecord\Models\ActiveDirectory\User as ActiveDirectoryUser;
/**
* Active Directory counterpart to LldapUser same role (the LdapRecord
* model backing the 'users' auth provider), but for AD's schema instead of
* LLDAP/OpenLDAP's. AD user objects carry objectClass top/person/
* organizationalPerson/user (no inetOrgPerson/posixAccount/mailAccount, so
* LldapUser's object-class scope matches zero AD entries) and expose a
* binary objectGUID rather than entryUUID both already handled correctly
* by LdapRecord's stock ActiveDirectory\User, so no overrides are needed
* here, only the swap in AppServiceProvider::applyLdapSettingsOverride()
* (driven by the ldap_directory_type setting).
*/
class AdUser extends ActiveDirectoryUser {}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
/**
* Read-only viewer over storage/logs/*.log the only way to see what the
* scheduled integrations (IMAP fetch, automation rules, AI automation) are
* doing without shell access to the container. Deliberately whitelists
* files via files()/glob() rather than trusting $selectedFile directly,
* since it's a public Livewire property a client could otherwise tamper
* with into a path-traversal read of arbitrary files.
*/
class Logs extends Component
{
/**
* How much of a (possibly multi-MB, e.g. browser.log) file to read from
* the tail per request bounds memory/response size regardless of how
* large the underlying file grows.
*/
private const MAX_BYTES = 4 * 1024 * 1024;
private const LEVELS = ['EMERGENCY', 'ALERT', 'CRITICAL', 'ERROR', 'WARNING', 'NOTICE', 'INFO', 'DEBUG'];
public string $selectedFile = '';
public string $levelFilter = '';
public string $search = '';
public int $limit = 300;
public bool $autoRefresh = false;
public function mount(): void
{
$names = $this->files()->pluck('name');
$this->selectedFile = $names->first(fn (string $n) => $n === 'laravel.log') ?? $names->first() ?? '';
}
public static function availableLevels(): array
{
return self::LEVELS;
}
/**
* @return array{class: string, style: string} CSS for the level badge
* reuses the existing .tag-* palette (accent/accent-2/neutral) where
* it fits, and falls back to inline color-mix() (matching the
* danger/warning treatment already used elsewhere, e.g.
* admin/api-keys.blade.php's status tags) for severities with no
* existing tag class.
*/
public static function levelBadge(?string $level): array
{
return match ($level) {
'DEBUG' => ['class' => 'tag tag-neutral', 'style' => ''],
'INFO' => ['class' => 'tag tag-accent-2', 'style' => ''],
'NOTICE' => ['class' => 'tag tag-accent', 'style' => ''],
'WARNING' => ['class' => 'tag', 'style' => 'background:color-mix(in srgb, var(--color-warning) 20%, transparent);color:var(--color-warning)'],
'ERROR' => ['class' => 'tag', 'style' => 'background:color-mix(in srgb, var(--color-danger) 18%, transparent);color:var(--color-danger)'],
'CRITICAL', 'ALERT', 'EMERGENCY' => ['class' => 'tag', 'style' => 'background:var(--color-danger);color:#fff'],
default => ['class' => 'tag tag-outline', 'style' => ''],
};
}
#[Computed]
public function files(): Collection
{
$paths = glob(storage_path('logs/*.log')) ?: [];
return collect($paths)
->map(fn (string $path) => [
'name' => basename($path),
'size' => filesize($path) ?: 0,
'modified' => Carbon::createFromTimestamp(filemtime($path) ?: time()),
])
->sortByDesc('modified')
->values();
}
#[Computed]
public function entries(): Collection
{
if ($this->selectedFile === '' || ! $this->files()->pluck('name')->contains($this->selectedFile)) {
return collect();
}
$path = storage_path('logs/'.$this->selectedFile);
if (! is_file($path)) {
return collect();
}
$size = filesize($path);
$handle = fopen($path, 'r');
$truncated = $size > self::MAX_BYTES;
if ($truncated) {
fseek($handle, -self::MAX_BYTES, SEEK_END);
}
$content = stream_get_contents($handle);
fclose($handle);
// A new log entry starts at a "[YYYY-MM-DD HH:MM:SS]" line; anything
// after it (stack traces, multi-line messages) belongs to that entry.
$chunks = preg_split('/(?=^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/m', (string) $content);
$chunks = array_values(array_filter($chunks, fn (string $c) => trim($c) !== ''));
if ($truncated && count($chunks) > 1) {
// First chunk was very likely cut mid-entry by the seek above.
array_shift($chunks);
}
$entries = collect($chunks)->map(function (string $chunk) {
preg_match('/^\[[^\]]+\]\s+\S+\.(\w+):/', $chunk, $m);
return [
'level' => isset($m[1]) ? strtoupper($m[1]) : null,
'text' => rtrim($chunk),
];
});
if ($this->levelFilter !== '') {
$entries = $entries->filter(fn (array $e) => $e['level'] === $this->levelFilter);
}
if (trim($this->search) !== '') {
$needle = mb_strtolower($this->search);
$entries = $entries->filter(fn (array $e) => str_contains(mb_strtolower($e['text']), $needle));
}
return $entries->values()->slice(-$this->limit)->values();
}
public function selectFile(string $name): void
{
if ($this->files()->pluck('name')->contains($name)) {
$this->selectedFile = $name;
}
}
public function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return "{$bytes} B";
}
$units = ['KB', 'MB', 'GB'];
$value = $bytes / 1024;
foreach ($units as $unit) {
if ($value < 1024 || $unit === end($units)) {
return number_format($value, 1).' '.$unit;
}
$value /= 1024;
}
return "{$bytes} B";
}
public function render()
{
return view('livewire.admin.logs');
}
}

View File

@@ -17,8 +17,11 @@ use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Models\UserField; use App\Models\UserField;
use App\Services\AiClient;
use App\Services\BookStackClient; use App\Services\BookStackClient;
use App\Services\BookStackContentTagger;
use App\Services\LdapUserProvisioner; use App\Services\LdapUserProvisioner;
use App\Services\SnipeItClient;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -152,6 +155,32 @@ class Panel extends Component
public ?string $bookstackTestMessage = null; public ?string $bookstackTestMessage = null;
public ?array $bookstackTagResult = null;
public ?string $bookstackTagError = null;
public array $snipeitConfig = [];
public ?string $snipeitTestResult = null;
public ?string $snipeitTestMessage = null;
public array $aiConfig = [];
public ?string $aiTestResult = null;
public ?string $aiTestMessage = null;
public array $aiTriageConfig = [];
public bool $aiSummaryEnabled = false;
public bool $aiSummaryRegenerateOnMessage = false;
public string $aiSummaryPrompt = '';
public int $aiSummaryPromptVersion = 0;
// ---- generic pending-delete confirm ---- // ---- generic pending-delete confirm ----
public ?string $pendingDeleteType = null; public ?string $pendingDeleteType = null;
@@ -179,10 +208,18 @@ class Panel extends Component
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'), 'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'), 'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'), 'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
'refreshTicketViewSeconds' => Settings::get('refresh_ticket_view_seconds'),
'refreshQueueSeconds' => Settings::get('refresh_queue_seconds'),
'refreshNotificationsSeconds' => Settings::get('refresh_notifications_seconds'),
'scheduleSlaCheckMinutes' => Settings::get('schedule_sla_check_minutes'),
'scheduleAutomationRulesMinutes' => Settings::get('schedule_automation_rules_minutes'),
'scheduleImapFetchMinutes' => Settings::get('schedule_imap_fetch_minutes'),
'scheduleAiAutomationMinutes' => Settings::get('schedule_ai_automation_minutes'),
]; ];
$this->ldapConfig = [ $this->ldapConfig = [
'enabled' => Settings::bool('ldap_enabled'), 'enabled' => Settings::bool('ldap_enabled'),
'directoryType' => Settings::get('ldap_directory_type', 'lldap'),
'host' => Settings::get('ldap_host'), 'host' => Settings::get('ldap_host'),
'port' => Settings::get('ldap_port'), 'port' => Settings::get('ldap_port'),
'baseDn' => Settings::get('ldap_base_dn'), 'baseDn' => Settings::get('ldap_base_dn'),
@@ -202,10 +239,42 @@ class Panel extends Component
'tokenSecret' => Settings::get('bookstack_token_secret'), 'tokenSecret' => Settings::get('bookstack_token_secret'),
'verifySsl' => Settings::bool('bookstack_verify_ssl'), 'verifySsl' => Settings::bool('bookstack_verify_ssl'),
'showToGuests' => Settings::bool('bookstack_show_to_guests'), 'showToGuests' => Settings::bool('bookstack_show_to_guests'),
'searchTypes' => Settings::get('bookstack_search_types', 'both'), 'searchTypes' => BookStackClient::normalizeSearchTypes(Settings::get('bookstack_search_types', '')),
'searchBy' => in_array($searchBy = Settings::get('bookstack_search_by', 'both'), BookStackClient::SEARCH_BY_OPTIONS, true) ? $searchBy : 'both',
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')), 'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')), 'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
]; ];
$this->snipeitConfig = [
'enabled' => Settings::bool('snipeit_enabled'),
'baseUrl' => Settings::get('snipeit_base_url'),
'apiToken' => Settings::get('snipeit_api_token'),
'skipSslVerification' => ! Settings::bool('snipeit_verify_ssl'),
'clientCanSelectAsset' => Settings::bool('snipeit_client_can_select_asset'),
'clientAssetSubcategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_subcategory_ids', '')),
'clientAssetCategoryIds' => $this->parseShelfIds(Settings::get('snipeit_client_asset_category_ids', '')),
'operatorViewRequesterAssets' => Settings::bool('snipeit_operator_view_requester_assets'),
'operatorSearchInventory' => Settings::bool('snipeit_operator_search_inventory'),
];
$this->aiConfig = [
'enabled' => Settings::bool('ai_enabled'),
'baseUrl' => Settings::get('ai_base_url'),
'apiKey' => Settings::get('ai_api_key'),
'model' => Settings::get('ai_model'),
'verifySsl' => Settings::bool('ai_verify_ssl'),
];
$this->aiTriageConfig = [
'categoryWhenMissing' => Settings::bool('ai_triage_category_when_missing'),
'subcategoryWhenCategoryOnly' => Settings::bool('ai_triage_subcategory_when_category_only'),
'recheckCategorized' => Settings::bool('ai_triage_recheck_categorized'),
'fixSubject' => Settings::bool('ai_triage_fix_subject'),
'setPriority' => Settings::bool('ai_triage_set_priority'),
];
$this->aiSummaryEnabled = Settings::bool('ai_summary_enabled');
$this->aiSummaryRegenerateOnMessage = Settings::bool('ai_summary_regenerate_on_message');
$this->aiSummaryPrompt = Settings::get('ai_summary_prompt');
} }
public function setTab(string $tab): void public function setTab(string $tab): void
@@ -270,10 +339,55 @@ class Panel extends Component
return; return;
} }
Category::query()->find($categoryId)?->subcategories()->create(['name' => $name]); $category = Category::query()->find($categoryId);
if (! $category) {
return;
}
$nextPosition = ((int) $category->subcategories()->max('sort_order')) + 1;
$category->subcategories()->create(['name' => $name, 'sort_order' => $nextPosition]);
$this->newSubNames[$categoryId] = ''; $this->newSubNames[$categoryId] = '';
} }
public function moveSubcategoryUp(int $subcategoryId): void
{
$this->swapSubcategoryOrder($subcategoryId, -1);
}
public function moveSubcategoryDown(int $subcategoryId): void
{
$this->swapSubcategoryOrder($subcategoryId, 1);
}
protected function swapSubcategoryOrder(int $subcategoryId, int $direction): void
{
$sub = Subcategory::query()->find($subcategoryId);
if (! $sub) {
return;
}
$siblings = Subcategory::query()
->where('category_id', $sub->category_id)
->orderBy('sort_order')
->orderBy('id')
->get();
$idx = $siblings->search(fn (Subcategory $s) => $s->id === $sub->id);
$swapIdx = $idx + $direction;
if ($idx === false || $swapIdx < 0 || $swapIdx >= $siblings->count()) {
return;
}
$other = $siblings[$swapIdx];
[$order, $otherOrder] = [$sub->sort_order, $other->sort_order];
$sub->update(['sort_order' => $otherOrder]);
$other->update(['sort_order' => $order]);
}
public function openSubcategoryEditForm(int $subcategoryId): void public function openSubcategoryEditForm(int $subcategoryId): void
{ {
$sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId); $sub = Subcategory::query()->with('customFields')->findOrFail($subcategoryId);
@@ -666,6 +780,14 @@ class Panel extends Component
->values(); ->values();
} }
#[Computed]
public function categoriesForSnipeitForm()
{
return Category::query()->orderBy('name')->get()
->map(fn (Category $c) => ['id' => $c->id, 'label' => $c->name])
->values();
}
public function openTeamForm(): void public function openTeamForm(): void
{ {
$this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []]; $this->teamForm = ['id' => null, 'name' => '', 'memberIds' => [], 'subcategoryIds' => []];
@@ -1348,6 +1470,14 @@ class Panel extends Component
Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix'])); Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0'); Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength'])); Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
Settings::set('refresh_ticket_view_seconds', (string) max(1, (int) $this->systemConfig['refreshTicketViewSeconds']));
Settings::set('refresh_queue_seconds', (string) max(1, (int) $this->systemConfig['refreshQueueSeconds']));
Settings::set('refresh_notifications_seconds', (string) max(1, (int) $this->systemConfig['refreshNotificationsSeconds']));
Settings::set('schedule_sla_check_minutes', (string) max(1, (int) $this->systemConfig['scheduleSlaCheckMinutes']));
Settings::set('schedule_automation_rules_minutes', (string) max(1, (int) $this->systemConfig['scheduleAutomationRulesMinutes']));
Settings::set('schedule_imap_fetch_minutes', (string) max(1, (int) $this->systemConfig['scheduleImapFetchMinutes']));
Settings::set('schedule_ai_automation_minutes', (string) max(1, (int) $this->systemConfig['scheduleAiAutomationMinutes']));
} }
/** /**
@@ -1378,6 +1508,7 @@ class Panel extends Component
public function saveLdapConfig(): void public function saveLdapConfig(): void
{ {
Settings::set('ldap_enabled', $this->ldapConfig['enabled'] ? '1' : '0'); Settings::set('ldap_enabled', $this->ldapConfig['enabled'] ? '1' : '0');
Settings::set('ldap_directory_type', $this->ldapConfig['directoryType'] === 'ad' ? 'ad' : 'lldap');
Settings::set('ldap_host', $this->ldapConfig['host']); Settings::set('ldap_host', $this->ldapConfig['host']);
Settings::set('ldap_port', (string) $this->ldapConfig['port']); Settings::set('ldap_port', (string) $this->ldapConfig['port']);
Settings::set('ldap_base_dn', $this->ldapConfig['baseDn']); Settings::set('ldap_base_dn', $this->ldapConfig['baseDn']);
@@ -1439,8 +1570,10 @@ class Panel extends Component
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0'); Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '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', implode(',', BookStackClient::normalizeSearchTypes($this->bookstackConfig['searchTypes'])));
Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']);
if (in_array($this->bookstackConfig['searchBy'], BookStackClient::SEARCH_BY_OPTIONS, true)) {
Settings::set('bookstack_search_by', $this->bookstackConfig['searchBy']);
} }
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation'])); Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
@@ -1490,6 +1623,15 @@ class Panel extends Component
: [...$ids, $id]; : [...$ids, $id];
} }
public function toggleBookstackSearchType(string $type): void
{
$types = $this->bookstackConfig['searchTypes'];
$this->bookstackConfig['searchTypes'] = in_array($type, $types, true)
? array_values(array_diff($types, [$type]))
: [...$types, $type];
}
public function testBookstackConnection(): void public function testBookstackConnection(): void
{ {
$cfg = $this->bookstackConfig; $cfg = $this->bookstackConfig;
@@ -1508,6 +1650,159 @@ class Panel extends Component
$this->bookstackTestMessage = $result['message']; $this->bookstackTestMessage = $result['message'];
} }
/**
* Runs synchronously in the request (no queue worker runs in this
* deployment see CLAUDE.md so a dispatched job would just sit in the
* `jobs` table). Safe to click again if it times out on a large wiki:
* every write is idempotent, so a re-run just skips whatever already got
* tagged (or, for the --force variant, re-classifies from scratch).
*/
public function runBookstackTagging(bool $force = false): void
{
if (! app(BookStackClient::class)->enabled() || ! app(AiClient::class)->enabled()) {
$this->bookstackTagResult = null;
$this->bookstackTagError = 'Włącz i skonfiguruj obie integracje — BookStack oraz AI — przed uruchomieniem tagowania.';
return;
}
$this->bookstackTagError = null;
set_time_limit(0);
$this->bookstackTagResult = app(BookStackContentTagger::class)->run(force: $force);
}
public function runBookstackTaggingForce(): void
{
$this->runBookstackTagging(force: true);
}
// ===================== SNIPE-IT CONFIG =====================
public function saveSnipeitConfig(): void
{
Settings::set('snipeit_enabled', $this->snipeitConfig['enabled'] ? '1' : '0');
Settings::set('snipeit_base_url', $this->snipeitConfig['baseUrl']);
if ($this->snipeitConfig['apiToken']) {
Settings::set('snipeit_api_token', $this->snipeitConfig['apiToken']);
}
Settings::set('snipeit_verify_ssl', $this->snipeitConfig['skipSslVerification'] ? '0' : '1');
Settings::set('snipeit_client_can_select_asset', $this->snipeitConfig['clientCanSelectAsset'] ? '1' : '0');
Settings::set('snipeit_client_asset_subcategory_ids', implode(',', $this->snipeitConfig['clientAssetSubcategoryIds']));
Settings::set('snipeit_client_asset_category_ids', implode(',', $this->snipeitConfig['clientAssetCategoryIds']));
Settings::set('snipeit_operator_view_requester_assets', $this->snipeitConfig['operatorViewRequesterAssets'] ? '1' : '0');
Settings::set('snipeit_operator_search_inventory', $this->snipeitConfig['operatorSearchInventory'] ? '1' : '0');
$this->snipeitTestResult = null;
$this->snipeitTestMessage = null;
}
public function toggleSnipeitClientSubcategory(int $id): void
{
$ids = $this->snipeitConfig['clientAssetSubcategoryIds'];
$this->snipeitConfig['clientAssetSubcategoryIds'] = in_array($id, $ids, true)
? array_values(array_diff($ids, [$id]))
: [...$ids, $id];
}
/**
* The category-level counterpart to toggleSnipeitClientSubcategory()
* a coarser allow-list for admins who want the picker on for every
* subcategory of a category at once, without ticking each one
* individually. NewTicket::snipeitAssets() allows a ticket through if
* its category OR its subcategory is on either list.
*/
public function toggleSnipeitClientCategory(int $id): void
{
$ids = $this->snipeitConfig['clientAssetCategoryIds'];
$this->snipeitConfig['clientAssetCategoryIds'] = in_array($id, $ids, true)
? array_values(array_diff($ids, [$id]))
: [...$ids, $id];
}
public function testSnipeitConnection(): void
{
$cfg = $this->snipeitConfig;
if (empty($cfg['baseUrl'])) {
$this->snipeitTestResult = 'error';
$this->snipeitTestMessage = 'Uzupełnij adres API.';
return;
}
$token = $cfg['apiToken'] ?: Settings::get('snipeit_api_token');
$result = app(SnipeItClient::class)->testConnection($cfg['baseUrl'], $token ?? '', ! $cfg['skipSslVerification']);
$this->snipeitTestResult = $result['ok'] ? 'ok' : 'error';
$this->snipeitTestMessage = $result['message'];
}
// ===================== AI CONFIG =====================
public function saveAiConfig(): void
{
Settings::set('ai_enabled', $this->aiConfig['enabled'] ? '1' : '0');
Settings::set('ai_base_url', $this->aiConfig['baseUrl']);
if ($this->aiConfig['apiKey']) {
Settings::set('ai_api_key', $this->aiConfig['apiKey']);
}
Settings::set('ai_model', $this->aiConfig['model']);
Settings::set('ai_verify_ssl', $this->aiConfig['verifySsl'] ? '1' : '0');
$this->aiTestResult = null;
$this->aiTestMessage = null;
}
public function testAiConnection(): void
{
$cfg = $this->aiConfig;
if (empty($cfg['baseUrl']) || empty($cfg['model'])) {
$this->aiTestResult = 'error';
$this->aiTestMessage = 'Uzupełnij adres API i nazwę modelu.';
return;
}
$apiKey = $cfg['apiKey'] ?: Settings::get('ai_api_key');
$result = app(AiClient::class)->testConnection($cfg['baseUrl'], $apiKey ?? '', $cfg['model'], (bool) $cfg['verifySsl']);
$this->aiTestResult = $result['ok'] ? 'ok' : 'error';
$this->aiTestMessage = $result['message'];
}
public function saveAiTriageConfig(): void
{
Settings::set('ai_triage_category_when_missing', $this->aiTriageConfig['categoryWhenMissing'] ? '1' : '0');
Settings::set('ai_triage_subcategory_when_category_only', $this->aiTriageConfig['subcategoryWhenCategoryOnly'] ? '1' : '0');
Settings::set('ai_triage_recheck_categorized', $this->aiTriageConfig['recheckCategorized'] ? '1' : '0');
Settings::set('ai_triage_fix_subject', $this->aiTriageConfig['fixSubject'] ? '1' : '0');
Settings::set('ai_triage_set_priority', $this->aiTriageConfig['setPriority'] ? '1' : '0');
Settings::set('ai_summary_enabled', $this->aiSummaryEnabled ? '1' : '0');
Settings::set('ai_summary_regenerate_on_message', $this->aiSummaryRegenerateOnMessage ? '1' : '0');
}
public function saveAiSummaryPrompt(string $value): void
{
Settings::set('ai_summary_prompt', $value);
$this->aiSummaryPrompt = $value;
}
public function resetAiSummaryPrompt(): void
{
$default = Settings::default('ai_summary_prompt');
Settings::set('ai_summary_prompt', $default);
$this->aiSummaryPrompt = $default;
$this->aiSummaryPromptVersion++;
}
// ===================== GENERIC DELETE CONFIRM ===================== // ===================== GENERIC DELETE CONFIRM =====================
public function requestDelete(string $type, mixed $id, string $message): void public function requestDelete(string $type, mixed $id, string $message): void
@@ -1547,8 +1842,38 @@ class Panel extends Component
$this->cancelPendingDelete(); $this->cancelPendingDelete();
} }
/**
* Mirrors the labels in the $tabGroups array built inline in
* admin/panel.blade.php (icons/grouping live only there this is just
* the page-title-sized subset, not worth threading the whole structure
* through the PHP side for).
*/
private const TAB_LABELS = [
'categories' => 'Kategorie',
'fields' => 'Pola dodatkowe',
'statuses' => 'Statusy',
'priorities' => 'Priorytety i SLA',
'reply-quick-actions' => 'Szybkie akcje odpowiedzi',
'response-templates' => 'Szablony odpowiedzi',
'automation-rules' => 'Automatyzacja SLA',
'triggers' => 'Wyzwalacze',
'users' => 'Użytkownicy',
'teams' => 'Zespoły',
'user-fields' => 'Pola dodatkowe',
'templates' => 'Szablony e-mail',
'email' => 'Poczta',
'branding' => 'Wygląd i branding',
'config' => 'Konfiguracja',
'integrations' => 'Integracje',
'api-keys' => 'Klucze API',
'logs' => 'Logi',
'about' => 'O aplikacji',
];
public function render() public function render()
{ {
return view('livewire.admin.panel'); $tabLabel = self::TAB_LABELS[$this->tab] ?? 'Panel administratora';
return view('livewire.admin.panel')->title(Settings::pageTitle($tabLabel));
} }
} }

View File

@@ -75,6 +75,6 @@ class Login extends Component
public function render() public function render()
{ {
return view('livewire.auth.login'); return view('livewire.auth.login')->title(Settings::pageTitle('Logowanie'));
} }
} }

View File

@@ -3,45 +3,75 @@
namespace App\Livewire\Client; namespace App\Livewire\Client;
use App\Models\Status; use App\Models\Status;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination;
class Dashboard extends Component class Dashboard extends Component
{ {
use WithPagination;
private const PER_PAGE = 20;
#[Url]
public string $tab = 'current'; public string $tab = 'current';
public string $search = ''; public string $search = '';
#[Computed] /**
public function tickets() * Session-only, mirrors Operator\Queue::rememberQueueTab() lets
* "Wróć do listy" on the ticket-detail page return to whichever tab
* (Bieżące/Archiwum) was actually active, instead of always resetting
* to the default.
*/
public function mount(): void
{
session(['client_dashboard_tab' => $this->tab]);
}
protected function baseQuery()
{ {
return Auth::user()->ticketsAsCustomer() return Auth::user()->ticketsAsCustomer()
->search($this->search) ->search($this->search)
->with('subcategory.category') ->with('subcategory.category')
->orderByDesc('updated_at') ->orderByDesc('created_at');
->get();
} }
/**
* Separate named paginators (see pageName below) so switching tabs
* doesn't reset whichever page the other tab was on.
*/
#[Computed] #[Computed]
public function currentTickets() public function currentTickets()
{ {
return $this->tickets->whereNotIn('status_key', Status::closedKeys()); return $this->baseQuery()->whereNotIn('status_key', Status::closedKeys())
->paginate(self::PER_PAGE, pageName: 'currentPage');
} }
#[Computed] #[Computed]
public function archiveTickets() public function archiveTickets()
{ {
return $this->tickets->whereIn('status_key', Status::closedKeys()); return $this->baseQuery()->whereIn('status_key', Status::closedKeys())
->paginate(self::PER_PAGE, pageName: 'archivePage');
}
public function updatedSearch(): void
{
$this->resetPage('currentPage');
$this->resetPage('archivePage');
} }
public function setTab(string $tab): void public function setTab(string $tab): void
{ {
$this->tab = $tab; $this->tab = $tab;
session(['client_dashboard_tab' => $tab]);
} }
public function render() public function render()
{ {
return view('livewire.client.dashboard'); return view('livewire.client.dashboard')->title(Settings::pageTitle('Moje zgłoszenia'));
} }
} }

View File

@@ -5,6 +5,7 @@ namespace App\Livewire\Client;
use App\Models\Category; use App\Models\Category;
use App\Models\Subcategory; use App\Models\Subcategory;
use App\Services\BookStackClient; use App\Services\BookStackClient;
use App\Services\SnipeItClient;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -40,6 +41,82 @@ class NewTicket extends Component
$this->suggestedArticlesLoaded = true; $this->suggestedArticlesLoaded = true;
} }
// Same wire:init-deferred pattern as suggestedArticlesLoaded above,
// for the Snipe-IT "Twój sprzęt" picker.
public bool $snipeitAssetsLoaded = false;
public ?int $selectedSnipeitAssetId = null;
public function loadSnipeitAssets(): void
{
$this->snipeitAssetsLoaded = true;
}
/**
* Empty unless the admin turned the picker on AND allow-listed either
* the currently selected subcategory (snipeit_client_asset_subcategory_ids)
* or its parent category (snipeit_client_asset_category_ids) an empty
* pair of allow-lists means "nowhere", not "everywhere", mirroring how
* BookStack's shelf allow-lists work. The category list is the coarser
* of the two, for admins who want every subcategory of a category
* covered at once instead of ticking each one individually.
*
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
*/
#[Computed]
public function snipeitAssets(): array
{
if (! $this->snipeitAssetsLoaded || ! Settings::bool('snipeit_client_can_select_asset') || ! $this->subcategoryId) {
return [];
}
$subcategoryAllowed = in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true);
$categoryAllowed = $this->categoryId && in_array($this->categoryId, $this->snipeitAllowedCategoryIds(), true);
if (! $subcategoryAllowed && ! $categoryAllowed) {
return [];
}
return app(SnipeItClient::class)->assetsForEmail(Auth::user()->email);
}
/**
* @return int[]
*/
protected function snipeitAllowedSubcategoryIds(): array
{
return $this->parseIdList(Settings::get('snipeit_client_asset_subcategory_ids', ''));
}
/**
* @return int[]
*/
protected function snipeitAllowedCategoryIds(): array
{
return $this->parseIdList(Settings::get('snipeit_client_asset_category_ids', ''));
}
/**
* @return int[]
*/
private function parseIdList(string $raw): array
{
return collect(explode(',', $raw))
->map(fn ($v) => (int) trim($v))
->filter()
->values()
->all();
}
public function selectSnipeitAsset(int $id): void
{
if (! Settings::bool('snipeit_client_can_select_asset')) {
return;
}
$this->selectedSnipeitAssetId = $this->selectedSnipeitAssetId === $id ? null : $id;
}
#[Computed] #[Computed]
public function categories() public function categories()
{ {
@@ -62,12 +139,14 @@ class NewTicket extends Component
{ {
$this->categoryId = $id; $this->categoryId = $id;
$this->subcategoryId = null; $this->subcategoryId = null;
$this->selectedSnipeitAssetId = null;
$this->step = 2; $this->step = 2;
} }
public function selectSubcategory(int $id): void public function selectSubcategory(int $id): void
{ {
$this->subcategoryId = $id; $this->subcategoryId = $id;
$this->selectedSnipeitAssetId = null;
$this->step = 3; $this->step = 3;
} }
@@ -82,8 +161,9 @@ class NewTicket extends Component
} }
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
return app(BookStackClient::class)->search($query); return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
} }
public function backToCategory(): void public function backToCategory(): void
@@ -129,12 +209,18 @@ class NewTicket extends Component
$user = Auth::user(); $user = Auth::user();
$selectedAsset = $this->selectedSnipeitAssetId
? collect($this->snipeitAssets)->firstWhere('id', $this->selectedSnipeitAssetId)
: null;
$ticket = app(TicketService::class)->create([ $ticket = app(TicketService::class)->create([
'email' => $user->email, 'email' => $user->email,
'subcategory_id' => $this->subcategoryId, 'subcategory_id' => $this->subcategoryId,
'subject' => $this->subject, 'subject' => $this->subject,
'body' => $this->body, 'body' => $this->body,
'custom_values' => $this->customValues, 'custom_values' => $this->customValues,
'snipeit_asset_id' => $selectedAsset['id'] ?? null,
'snipeit_asset_name' => $selectedAsset['label'] ?? null,
], $user); ], $user);
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments); app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
@@ -144,6 +230,6 @@ class NewTicket extends Component
public function render() public function render()
{ {
return view('livewire.client.new-ticket'); return view('livewire.client.new-ticket')->title(Settings::pageTitle('Nowe zgłoszenie'));
} }
} }

View File

@@ -33,6 +33,10 @@ class TicketShow extends Component
public string $csatComment = ''; public string $csatComment = '';
public bool $showAllOtherTickets = false;
private const OTHER_TICKETS_PREVIEW_COUNT = 5;
// Set via wire:init (see the blade view) rather than on the initial // Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never // render, so the BookStack HTTP call in suggestedArticles() never
// delays the ticket page's first paint — it loads in a beat later instead. // delays the ticket page's first paint — it loads in a beat later instead.
@@ -103,7 +107,21 @@ class TicketShow extends Component
#[Computed] #[Computed]
public function otherTickets() public function otherTickets()
{ {
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get(); $query = Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->latest();
return $this->showAllOtherTickets ? $query->get() : $query->take(self::OTHER_TICKETS_PREVIEW_COUNT)->get();
}
#[Computed]
public function otherTicketsCount(): int
{
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->count();
}
public function revealAllOtherTickets(): void
{
$this->showAllOtherTickets = true;
unset($this->otherTickets);
} }
/** /**
@@ -122,8 +140,9 @@ class TicketShow extends Component
$subcategory = $this->ticket->subcategory; $subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? '')); $query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
$tagQuery = trim($subcategory?->name ?? '');
return app(BookStackClient::class)->search($query); return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
} }
public function updatedAttachments(): void public function updatedAttachments(): void
@@ -221,6 +240,6 @@ class TicketShow extends Component
public function render() public function render()
{ {
return view('livewire.client.ticket-show'); return view('livewire.client.ticket-show')->title(Settings::pageTitle($this->ticket->displayNumber().' — '.$this->ticket->subject));
} }
} }

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Livewire;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Component;
class GlobalSearch extends Component
{
public string $search = '';
/**
* Gmail-style "field:value" operators recognized keys (accent-
* insensitive, a couple of Polish synonyms each) narrow the match to
* that one field; anything left over after stripping them still runs
* through the broad Ticket::scopeSearch() default. Multiple operators
* combine with AND, same as Gmail's "from:x subject:y".
*/
private const FIELD_PREFIXES = [
'od' => 'from',
'nadawca' => 'from',
'temat' => 'subject',
'tytul' => 'subject',
'tytuł' => 'subject',
'tresc' => 'body',
'treść' => 'body',
'numer' => 'number',
'nr' => 'number',
];
#[Computed]
public function results()
{
$term = trim($this->search);
if ($term === '' || ! ($user = Auth::user())) {
return collect();
}
$query = $this->baseQuery($user);
if (! $query) {
return collect();
}
[$fields, $free] = $this->parseQuery($term);
foreach ($fields as $field => $values) {
foreach ($values as $value) {
$this->applyFieldFilter($query, $field, $value);
}
}
// search() itself no-ops on an empty term, so this is safe to call
// unconditionally — it only matters when there was no operator at
// all, or an operator left some free text behind.
$query->search($free);
return $query->orderByDesc('updated_at')->limit(8)->get();
}
/**
* Splits "od:kacper temat:drukarka reszta" into recognized field
* operators plus whatever free text is left over. An unrecognized
* "key:value" (e.g. a pasted URL) is left untouched in the free text
* rather than silently dropped.
*/
private function parseQuery(string $term): array
{
$fields = [];
$free = preg_replace_callback(
'/(\pL+):(\S+)/u',
function ($m) use (&$fields) {
$key = mb_strtolower($m[1]);
if (! isset(self::FIELD_PREFIXES[$key])) {
return $m[0];
}
$fields[self::FIELD_PREFIXES[$key]][] = $m[2];
return '';
},
$term
);
return [$fields, trim(preg_replace('/\s+/', ' ', $free))];
}
private function applyFieldFilter($query, string $field, string $value): void
{
$like = '%'.$value.'%';
match ($field) {
'from' => $query->where(fn ($q) => $q->where('name', 'like', $like)->orWhere('email', 'like', $like)),
'subject' => $query->where('subject', 'like', $like),
'number' => $query->where('number', 'like', $like),
'body' => $query->where(fn ($q) => $q->where('body', 'like', $like)
->orWhereIn('id', DB::table('ticket_messages')->where('body', 'like', $like)->pluck('ticket_id'))),
default => null,
};
}
public function urlFor(Ticket $ticket): string
{
$user = Auth::user();
return $user->isOperator()
? route('operator.ticket', $ticket)
: route('client.ticket', $ticket);
}
/**
* Scoped (and routed, see urlFor()) by the operator role specifically
* rather than isAdmin() admin alone doesn't grant the operator.*
* routes (see routes/web.php's role:operator middleware), so a result
* pointing there would 404 for an admin-only account.
*/
protected function baseQuery(User $user)
{
if ($user->isOperator()) {
return Ticket::query()->visibleToOperator($user);
}
if ($user->isClient()) {
return $user->ticketsAsCustomer();
}
return null;
}
public function render()
{
return view('livewire.global-search');
}
}

View File

@@ -10,6 +10,7 @@ use App\Services\BookStackClient;
use App\Services\LdapUserProvisioner; use App\Services\LdapUserProvisioner;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Component; use Livewire\Component;
use Livewire\WithFileUploads; use Livewire\WithFileUploads;
@@ -41,6 +42,13 @@ class Landing extends Component
// delays the page's first paint — it loads in a beat later instead. // delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false; public bool $suggestedArticlesLoaded = false;
public function mount(): void
{
if (Auth::check()) {
$this->redirect(route('client.dashboard'), navigate: true);
}
}
public function loadSuggestedArticles(): void public function loadSuggestedArticles(): void
{ {
$this->suggestedArticlesLoaded = true; $this->suggestedArticlesLoaded = true;
@@ -100,8 +108,9 @@ class Landing extends Component
} }
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
return app(BookStackClient::class)->search($query); return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
} }
public function backToCategory(): void public function backToCategory(): void
@@ -186,6 +195,8 @@ class Landing extends Component
public function render() public function render()
{ {
return view('livewire.landing'); $context = $this->submittedTicketId ? 'Zgłoszenie utworzone' : 'Zgłoś problem';
return view('livewire.landing')->title(Settings::pageTitle($context));
} }
} }

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Operator;
use App\Models\User;
use App\Support\Settings;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
class ClientSearch extends Component
{
#[Url]
public string $search = '';
/**
* A search box, not a browse-everything list capped rather than
* paginated, same reasoning as the debounced search inputs elsewhere
* (queue/dashboard): once there's a match count this large, the fix is a
* narrower search term, not another page to click through.
*/
private const MAX_RESULTS = 20;
/**
* Matches by name or e-mail across every account, not just role=client
* a ticket's customer_id can point at any user (e.g. an operator who
* also filed a ticket), so narrowing to clients only would hide valid
* results. Role badges in the view make it clear who's who.
*/
#[Computed]
public function results()
{
$term = trim($this->search);
if ($term === '') {
return collect();
}
return User::query()
->where(fn ($q) => $q->where('name', 'like', "%{$term}%")->orWhere('email', 'like', "%{$term}%"))
->withCount('ticketsAsCustomer')
->orderBy('name')
->limit(self::MAX_RESULTS)
->get();
}
public function render()
{
return view('livewire.operator.client-search')->title(Settings::pageTitle('Klienci'));
}
}

View File

@@ -91,8 +91,9 @@ class NewTicket extends Component
} }
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? '')); $query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
return app(BookStackClient::class)->search($query); return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
} }
public function backToCategory(): void public function backToCategory(): void
@@ -159,6 +160,6 @@ class NewTicket extends Component
public function render() public function render()
{ {
return view('livewire.operator.new-ticket'); return view('livewire.operator.new-ticket')->title(Settings::pageTitle('Nowe zgłoszenie'));
} }
} }

View File

@@ -10,14 +10,30 @@ use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination;
class Queue extends Component class Queue extends Component
{ {
use WithPagination;
/**
* Rows per page for the ticket table below. Sorting (see sortTickets())
* still happens in PHP over the full filtered result several sortable
* columns (kategoria, przypisany, zespół...) are derived labels with no
* single backing SQL column so this slices the already-sorted
* collection rather than using a query-level ->paginate(). That still
* bounds how many rows ever hit the DOM at once, which is what actually
* mattered once the ticket count grew into the thousands.
*/
private const PER_PAGE = 50;
#[Url] #[Url]
public string $queue = 'all'; public string $queue = 'all';
@@ -32,12 +48,12 @@ class Queue extends Component
public string $search = ''; public string $search = '';
public string $sortBy = 'updated_at'; public string $sortBy = 'created';
public string $sortDir = 'desc'; public string $sortDir = 'desc';
/** @var string[] */ /** @var string[] */
public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee']; public array $visibleColumns = ['id', 'number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
/** @var int[] */ /** @var int[] */
public array $selectedIds = []; public array $selectedIds = [];
@@ -56,7 +72,15 @@ class Queue extends Component
*/ */
public function mount(): void public function mount(): void
{ {
$savedColumns = Auth::user()->operator_queue_columns;
if (is_array($savedColumns) && $savedColumns !== []) {
$this->visibleColumns = array_values(array_intersect($savedColumns, array_keys($this->columnDefs())));
}
if ($this->savedViewId !== null) { if ($this->savedViewId !== null) {
$this->rememberQueueTab();
return; return;
} }
@@ -66,6 +90,20 @@ class Queue extends Component
$this->applyViewFilters($default->filters); $this->applyViewFilters($default->filters);
$this->savedViewId = $default->id; $this->savedViewId = $default->id;
} }
$this->rememberQueueTab();
}
/**
* Session-only (not the per-user `operator_queue_columns` column a tab
* selection is transient, not a durable preference): lets "Wróć do
* listy" on the ticket-detail page return to whichever queue tab was
* actually active, instead of always resetting to the "Otwarte" default.
* See TicketShow's back-link, which reads this same key.
*/
private function rememberQueueTab(): void
{
session(['operator_queue_tab' => $this->queue]);
} }
/** /**
@@ -129,6 +167,33 @@ class Queue extends Component
$this->sortDir = $filters['sortDir'] ?? $this->sortDir; $this->sortDir = $filters['sortDir'] ?? $this->sortDir;
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns; $this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
$this->selectedIds = []; $this->selectedIds = [];
$this->resetPage();
$this->rememberQueueTab();
}
/**
* Any change to a filter/search/queue-tab input can shrink the result
* set out from under whatever page the operator was on snap back to
* page 1 rather than showing an empty table.
*/
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedFilterStatus(): void
{
$this->resetPage();
}
public function updatedFilterPriority(): void
{
$this->resetPage();
}
public function updatedFilterCategory(): void
{
$this->resetPage();
} }
public function saveCurrentView(): void public function saveCurrentView(): void
@@ -285,6 +350,21 @@ class Queue extends Component
return $groups; return $groups;
} }
/**
* Last few tickets this operator actually opened (see
* Ticket::recordViewBy(), called from TicketShow::mount()) re-scoped
* through visibleToOperator() in case a team reassignment since the
* view happened would now hide it from them.
*/
#[Computed]
public function recentlyViewed()
{
return Auth::user()->recentlyViewedTickets()
->visibleToOperator(Auth::user())
->take(6)
->get();
}
#[Computed] #[Computed]
public function filteredTickets() public function filteredTickets()
{ {
@@ -297,7 +377,9 @@ class Queue extends Component
if ($this->filterPriority !== 'all') { if ($this->filterPriority !== 'all') {
$query->where('priority_key', $this->filterPriority); $query->where('priority_key', $this->filterPriority);
} }
if ($this->filterCategory !== 'all') { if ($this->filterCategory === 'none') {
$query->whereNull('subcategory_id')->whereNull('category_id');
} elseif ($this->filterCategory !== 'all') {
// A ticket carries a category either via its subcategory or, // A ticket carries a category either via its subcategory or,
// when routed to a whole category with no subcategory (e.g. an // when routed to a whole category with no subcategory (e.g. an
// IMAP mailbox), directly on tickets.category_id. // IMAP mailbox), directly on tickets.category_id.
@@ -314,8 +396,15 @@ class Queue extends Component
} }
$tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get(); $tickets = $query->with(['subcategory.category', 'category', 'assignee', 'priority', 'status', 'team'])->get();
$sorted = $this->sortTickets($tickets);
return $this->sortTickets($tickets); return new LengthAwarePaginator(
$sorted->forPage($this->getPage(), self::PER_PAGE)->values(),
$sorted->count(),
self::PER_PAGE,
$this->getPage(),
['path' => request()->url()],
);
} }
/** /**
@@ -330,17 +419,21 @@ class Queue extends Component
$desc = $this->sortDir === 'desc'; $desc = $this->sortDir === 'desc';
$sorted = match ($this->sortBy) { $sorted = match ($this->sortBy) {
'id' => $tickets->sortBy(fn (Ticket $t) => $t->id, SORT_REGULAR, $desc),
'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc), 'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc),
'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc), 'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc), 'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'email' => $tickets->sortBy('email', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc), 'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc),
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc), '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), '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), '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), '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), 'subcategory' => $tickets->sortBy(fn (Ticket $t) => $t->subcategory?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'source' => $tickets->sortBy(fn (Ticket $t) => $t->source ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
'updated' => $tickets->sortBy(fn (Ticket $t) => $t->updated_at, SORT_REGULAR, $desc),
'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc), 'created' => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc), default => $tickets->sortBy(fn (Ticket $t) => $t->created_at, SORT_REGULAR, $desc),
}; };
return $sorted->values(); return $sorted->values();
@@ -352,9 +445,11 @@ class Queue extends Component
public function columnDefs(): array public function columnDefs(): array
{ {
return [ return [
'id' => 'ID',
'number' => 'Numer', 'number' => 'Numer',
'subject' => 'Temat', 'subject' => 'Temat',
'customer' => 'Klient', 'customer' => 'Klient',
'email' => 'E-mail',
'category' => 'Kategoria', 'category' => 'Kategoria',
'subcategory' => 'Podkategoria', 'subcategory' => 'Podkategoria',
'priority' => 'Priorytet', 'priority' => 'Priorytet',
@@ -362,7 +457,9 @@ class Queue extends Component
'sla' => 'SLA', 'sla' => 'SLA',
'assignee' => 'Przypisany', 'assignee' => 'Przypisany',
'team' => 'Zespół', 'team' => 'Zespół',
'source' => 'Źródło',
'created' => 'Utworzono', 'created' => 'Utworzono',
'updated' => 'Zaktualizowano',
]; ];
} }
@@ -373,7 +470,7 @@ class Queue extends Component
*/ */
public function sortableColumns(): array public function sortableColumns(): array
{ {
return ['number', 'subject', 'customer', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'created']; return ['id', 'number', 'subject', 'customer', 'email', 'category', 'subcategory', 'priority', 'status', 'assignee', 'team', 'source', 'created', 'updated'];
} }
public function sortByColumn(string $column): void public function sortByColumn(string $column): void
@@ -401,12 +498,57 @@ class Queue extends Component
} else { } else {
$this->visibleColumns[] = $column; $this->visibleColumns[] = $column;
} }
$this->persistVisibleColumns();
}
public function moveColumnUp(string $column): void
{
$this->reorderColumn($column, -1);
}
public function moveColumnDown(string $column): void
{
$this->reorderColumn($column, 1);
}
private function reorderColumn(string $column, int $delta): void
{
$from = array_search($column, $this->visibleColumns, true);
if ($from === false) {
return;
}
$to = $from + $delta;
if ($to < 0 || $to >= count($this->visibleColumns)) {
return;
}
$columns = $this->visibleColumns;
[$columns[$from], $columns[$to]] = [$columns[$to], $columns[$from]];
$this->visibleColumns = $columns;
$this->persistVisibleColumns();
}
/**
* Auto-remembers shown/hidden columns *and* their order per operator,
* independent of the named/default SavedQueueView mechanism above a
* plain column toggle or drag shouldn't require explicitly "saving a
* view" for it to stick between visits.
*/
private function persistVisibleColumns(): void
{
Auth::user()->forceFill(['operator_queue_columns' => $this->visibleColumns])->save();
} }
public function setQueue(string $key): void public function setQueue(string $key): void
{ {
$this->queue = $key; $this->queue = $key;
$this->selectedIds = []; $this->selectedIds = [];
$this->rememberQueueTab();
// Every tab except "closed" now excludes closed-stage tickets (see // Every tab except "closed" now excludes closed-stage tickets (see
// queueDefs()), so a stale closed-stage status filter would silently // queueDefs()), so a stale closed-stage status filter would silently
@@ -415,11 +557,14 @@ class Queue extends Component
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') { if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
$this->filterStatus = 'all'; $this->filterStatus = 'all';
} }
$this->resetPage();
} }
public function clearCustomerFilter(): void public function clearCustomerFilter(): void
{ {
$this->filterCustomerId = null; $this->filterCustomerId = null;
$this->resetPage();
} }
public function toggleSelect(int $id): void public function toggleSelect(int $id): void
@@ -496,6 +641,8 @@ class Queue extends Component
public function render() public function render()
{ {
return view('livewire.operator.queue'); $queueLabel = $this->queueDefs()[$this->queue]['label'] ?? 'Kolejka';
return view('livewire.operator.queue')->title(Settings::pageTitle($queueLabel));
} }
} }

View File

@@ -9,6 +9,7 @@ use App\Models\Status;
use App\Models\Team; use App\Models\Team;
use App\Models\Ticket; use App\Models\Ticket;
use App\Models\User; use App\Models\User;
use App\Support\Settings;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -127,7 +128,17 @@ class Stats extends Component
$query->where('tickets.priority_key', $this->filterPriority); $query->where('tickets.priority_key', $this->filterPriority);
} }
if ($this->filterCategory !== 'all') { if ($this->filterCategory !== 'all') {
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory)); // A ticket can carry its category two ways — via a subcategory
// (whose own category_id we check through) or, when routed to a
// bare category with no subcategory, via tickets.category_id
// directly (see Ticket::category()). Only checking the
// subcategory relation silently dropped every bare-category
// ticket from the filter.
$categoryId = $this->filterCategory;
$query->where(function ($q) use ($categoryId) {
$q->where('tickets.category_id', $categoryId)
->orWhereHas('subcategory', fn ($sq) => $sq->where('category_id', $categoryId));
});
} }
if ($this->filterAssignee === 'unassigned') { if ($this->filterAssignee === 'unassigned') {
$query->whereNull('tickets.assignee_id'); $query->whereNull('tickets.assignee_id');
@@ -298,37 +309,70 @@ class Stats extends Component
])->values(); ])->values();
} }
/**
* A ticket carries its category two ways: via a subcategory (whose
* category_id we join through), or when routed to a bare category with
* no subcategory via tickets.category_id directly (see
* Ticket::category()). Counting only the subcategory join silently
* dropped every bare-category ticket, so this sums both paths per
* category id before joining to categories for the label.
*/
#[Computed] #[Computed]
public function byCategory() public function byCategory()
{ {
return (clone $this->baseQuery) $viaSubcategory = (clone $this->baseQuery)
->whereNotNull('tickets.subcategory_id') ->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id') ->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id') ->select('subcategories.category_id', DB::raw('count(*) as count'))
->select('categories.name as label', DB::raw('count(*) as count')) ->groupBy('subcategories.category_id')
->groupBy('categories.id', 'categories.name') ->pluck('count', 'category_id');
->orderByDesc('count')
->get() $viaCategory = (clone $this->baseQuery)
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]); ->whereNull('tickets.subcategory_id')
->whereNotNull('tickets.category_id')
->select('tickets.category_id', DB::raw('count(*) as count'))
->groupBy('tickets.category_id')
->pluck('count', 'category_id');
$counts = $viaSubcategory->keys()->merge($viaCategory->keys())->unique()
->mapWithKeys(fn ($id) => [$id => ($viaSubcategory[$id] ?? 0) + ($viaCategory[$id] ?? 0)]);
return Category::query()->whereIn('id', $counts->keys())->get()
->map(fn (Category $c) => ['label' => $c->name, 'count' => (int) $counts[$c->id]])
->sortByDesc('count')
->values();
} }
/** /**
* One level deeper than byCategory() same shape, but grouped by the * One level deeper than byCategory() grouped by the actual subcategory,
* actual subcategory, labeled "Category / Subcategory" to disambiguate * labeled "Category / Subcategory" to disambiguate subcategories that
* subcategories that share a name across different parent categories. * share a name across different parent categories. Tickets routed to a
* bare category (no subcategory see byCategory()'s docblock) have no
* subcategory to group by, so they get their own "Category (bez
* podkategorii)" row instead of being silently dropped.
*/ */
#[Computed] #[Computed]
public function bySubcategory() public function bySubcategory()
{ {
return (clone $this->baseQuery) $withSubcategory = (clone $this->baseQuery)
->whereNotNull('tickets.subcategory_id') ->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id') ->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_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')) ->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') ->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
->orderByDesc('count')
->get() ->get()
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]); ->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
$bareCategory = (clone $this->baseQuery)
->whereNull('tickets.subcategory_id')
->whereNotNull('tickets.category_id')
->join('categories', 'categories.id', '=', 'tickets.category_id')
->select('categories.name as category_name', DB::raw('count(*) as count'))
->groupBy('categories.id', 'categories.name')
->get()
->map(fn ($row) => ['label' => $row->category_name.' (bez podkategorii)', 'count' => (int) $row->count]);
return $withSubcategory->concat($bareCategory)->sortByDesc('count')->values();
} }
#[Computed] #[Computed]
@@ -624,6 +668,6 @@ class Stats extends Component
public function render() public function render()
{ {
return view('livewire.operator.stats'); return view('livewire.operator.stats')->title(Settings::pageTitle('Statystyki'));
} }
} }

View File

@@ -14,8 +14,11 @@ use App\Models\Ticket;
use App\Models\TicketMessage; use App\Models\TicketMessage;
use App\Models\User; use App\Models\User;
use App\Services\BookStackClient; use App\Services\BookStackClient;
use App\Services\SnipeItClient;
use App\Services\TicketAiSummaryService;
use App\Services\TicketService; use App\Services\TicketService;
use App\Support\Settings; use App\Support\Settings;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\On; use Livewire\Attributes\On;
@@ -78,6 +81,38 @@ class TicketShow extends Component
$this->suggestedArticlesLoaded = true; $this->suggestedArticlesLoaded = true;
} }
// Same wire:init-deferred pattern — the AI summary card just displays
// whatever the scheduled ai:run-ticket-automation command last computed
// (no live AI call from the ticket page), but refreshing the ticket here
// picks up a summary the command generated after this page's initial load.
public bool $aiSummaryLoaded = false;
public function loadAiSummary(): void
{
$this->aiSummaryLoaded = true;
$this->ticket->refresh();
}
public ?string $aiSummaryRegenerateError = null;
// Manual regeneration is an explicit operator action (unlike the
// wire:init-deferred load above), so it's fine to block on the AI call
// here rather than deferring it — the button's wire:loading state covers
// the wait.
public function regenerateAiSummary(): void
{
$this->aiSummaryRegenerateError = null;
set_time_limit(0);
if (! app(TicketAiSummaryService::class)->generateFor($this->ticket)) {
$this->aiSummaryRegenerateError = 'Nie udało się wygenerować podsumowania. Sprawdź konfigurację integracji AI.';
return;
}
$this->ticket->refresh();
}
public function mount(Ticket $ticket): void public function mount(Ticket $ticket): void
{ {
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403); abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
@@ -89,6 +124,26 @@ class TicketShow extends Component
// ticket-show.blade.php) — so every open resumes it, not just the // ticket-show.blade.php) — so every open resumes it, not just the
// very first one. // very first one.
$this->ticket->resumeTimer(); $this->ticket->resumeTimer();
$this->ticket->recordViewBy(Auth::user());
}
/**
* Livewire lifecycle hook, called for any exception raised while
* handling a request for this component including one thrown while
* re-hydrating the typed $ticket property itself (Livewire re-fetches
* it by id on every request), which happens before any of this
* component's own methods run and so can't be caught locally the way
* refreshOrRedirectAway() catches it during an explicit refresh().
* Covers a ticket deleted by someone else while an operator still has
* it open sends them back to their queue instead of a hard error.
*/
public function exception(\Throwable $e, $stopPropagation): void
{
if ($e instanceof ModelNotFoundException) {
$this->redirect(route('operator.queue'), navigate: true);
$stopPropagation();
}
} }
#[Computed] #[Computed]
@@ -209,6 +264,36 @@ class TicketShow extends Component
unset($this->publicMessages, $this->internalMessages); unset($this->publicMessages, $this->internalMessages);
} }
/**
* Re-fetches the ticket and, for a non-admin operator, sends them back
* to their queue instead of leaving them stuck on a page that can no
* longer legitimately show anything either because the ticket was
* deleted (refresh() throws ModelNotFoundException, same as
* Model::findOrFail() internally) or because a team/assignee change
* (by this operator or anyone else) moved it out of their visible
* scope. Returns false when it redirected, so callers can bail out of
* whatever they were doing instead of continuing to operate on a
* ticket that's about to disappear from under them.
*/
protected function refreshOrRedirectAway(): bool
{
try {
$this->ticket->refresh();
} catch (ModelNotFoundException) {
$this->redirect(route('operator.queue'), navigate: true);
return false;
}
if (! $this->ticket->isVisibleToOperator(Auth::user())) {
$this->redirect(route('operator.queue'), navigate: true);
return false;
}
return true;
}
/** /**
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js * Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
* and Queue::onQueueChanged()) lets a status/priority/team/assignee * and Queue::onQueueChanged()) lets a status/priority/team/assignee
@@ -222,7 +307,7 @@ class TicketShow extends Component
return; return;
} }
$this->ticket->refresh(); $this->refreshOrRedirectAway();
} }
/** /**
@@ -232,8 +317,11 @@ class TicketShow extends Component
*/ */
public function refreshTicketData(): void public function refreshTicketData(): void
{ {
if (! $this->refreshOrRedirectAway()) {
return;
}
unset($this->publicMessages, $this->internalMessages); unset($this->publicMessages, $this->internalMessages);
$this->ticket->refresh();
} }
#[Computed] #[Computed]
@@ -266,24 +354,121 @@ class TicketShow extends Component
$subcategory = $this->ticket->subcategory; $subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? '')); $query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
$tagQuery = trim($subcategory?->name ?? '');
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW); return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW, $tagQuery);
}
// -------- Snipe-IT --------
// Same wire:init-deferred pattern as suggestedArticlesLoaded above — the
// requester's asset list is a Snipe-IT HTTP call, deferred so it never
// delays the ticket page's first paint.
public bool $snipeitAssetsLoaded = false;
public function loadSnipeitAssets(): void
{
$this->snipeitAssetsLoaded = true;
}
public string $snipeitSearchQuery = '';
public array $snipeitSearchResults = [];
/**
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
*/
#[Computed]
public function snipeitRequesterAssets(): array
{
if (! $this->snipeitAssetsLoaded || ! Settings::bool('snipeit_operator_view_requester_assets') || ! $this->ticket->email) {
return [];
}
return app(SnipeItClient::class)->assetsForEmail($this->ticket->email);
} }
/** /**
* A non-admin operator can only reassign a ticket to one of their own * Live detail for the ticket's linked asset (if any) always fetched
* teams (mirrors the visibility scoping in Operator\Queue). * fresh so a status/assignment change made directly in Snipe-IT shows up
* without an operator having to re-link anything. Not gated behind
* snipeit_operator_view_requester_assets/snipeit_operator_search_inventory:
* showing what's already on the ticket isn't the same permission as
* browsing the rest of Snipe-IT. Falls back to the ticket's own cached
* snipeit_asset_name in the view when this comes back null (unreachable
* instance or the asset was deleted there).
*
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, assignedTo: ?string, url: string}|null
*/
#[Computed]
public function snipeitLinkedAsset(): ?array
{
return $this->ticket->snipeit_asset_id
? app(SnipeItClient::class)->asset($this->ticket->snipeit_asset_id)
: null;
}
public function searchSnipeitAssets(): void
{
if (! Settings::bool('snipeit_operator_search_inventory')) {
return;
}
$this->snipeitSearchResults = app(SnipeItClient::class)->searchAssets($this->snipeitSearchQuery);
}
/**
* $id must come from whichever list it was clicked from the requester's
* assets (gated on snipeit_operator_view_requester_assets) or an
* inventory search result (gated on snipeit_operator_search_inventory)
* rather than a direct Snipe-IT lookup by id, so an operator can't link
* an arbitrary asset via a source that's admin-disabled for them.
*/
public function linkSnipeitAsset(int $id): void
{
$fromRequesterAssets = Settings::bool('snipeit_operator_view_requester_assets')
? collect($this->snipeitRequesterAssets)->firstWhere('id', $id)
: null;
$fromSearchResults = Settings::bool('snipeit_operator_search_inventory')
? collect($this->snipeitSearchResults)->firstWhere('id', $id)
: null;
$asset = $fromRequesterAssets ?? $fromSearchResults;
if (! $asset) {
return;
}
app(TicketService::class)->setSnipeitAsset($this->ticket, ['id' => $asset['id'], 'label' => $asset['label']]);
$this->ticket->refresh();
unset($this->snipeitLinkedAsset);
}
/**
* Unlike linkSnipeitAsset(), not gated behind either visibility setting
* clearing a link a ticket already has is a correction, not a new way
* to browse Snipe-IT, so it stays available even if an admin later turns
* both of those off.
*/
public function unlinkSnipeitAsset(): void
{
app(TicketService::class)->setSnipeitAsset($this->ticket, null);
$this->ticket->refresh();
unset($this->snipeitLinkedAsset);
}
/**
* Every team, regardless of the viewing operator's own membership
* unlike ticket *visibility* (Operator\Queue, scoped to an operator's
* own teams), reassignment isn't restricted: an operator working a
* ticket needs to be able to route it to whichever team actually owns
* the problem, even one they don't personally belong to.
*/ */
#[Computed] #[Computed]
public function teams() public function teams()
{ {
$query = Team::query(); return Team::query()->get();
if (! Auth::user()->isAdmin()) {
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
}
return $query->get();
} }
#[Computed] #[Computed]
@@ -357,7 +542,12 @@ class TicketShow extends Component
public function setTeam(string $id): void public function setTeam(string $id): void
{ {
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null); app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null);
$this->ticket->refresh();
// Reassigning to a team the operator doesn't belong to can move the
// ticket out of their own visible scope (see Ticket::isVisibleToOperator())
// — send them back to their queue rather than leaving them on a
// ticket they can no longer legitimately keep viewing.
$this->refreshOrRedirectAway();
} }
// -------- reporter -------- // -------- reporter --------
@@ -585,6 +775,6 @@ class TicketShow extends Component
// progress is saved incrementally rather than only on explicit stop. // progress is saved incrementally rather than only on explicit stop.
$this->ticket->flushTimer(); $this->ticket->flushTimer();
return view('livewire.operator.ticket-show'); return view('livewire.operator.ticket-show')->title(Settings::pageTitle($this->ticket->displayNumber().' — '.$this->ticket->subject));
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Models\NotificationPreference; use App\Models\NotificationPreference;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
@@ -39,6 +40,7 @@ class NotificationPreferences extends Component
public function render() public function render()
{ {
return view('livewire.settings.notification-preferences', ['rows' => $this->rows()]); return view('livewire.settings.notification-preferences', ['rows' => $this->rows()])
->title(Settings::pageTitle('Powiadomienia'));
} }
} }

View File

@@ -11,6 +11,6 @@ class Category extends Model
{ {
public function subcategories(): HasMany public function subcategories(): HasMany
{ {
return $this->hasMany(Subcategory::class); return $this->hasMany(Subcategory::class)->orderBy('sort_order');
} }
} }

View File

@@ -5,7 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
#[Fillable(['key', 'name', 'trigger_label', 'subject', 'body'])] #[Fillable(['key', 'name', 'subject', 'body'])]
class EmailTemplate extends Model class EmailTemplate extends Model
{ {
public function render(array $placeholders): array public function render(array $placeholders): array

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['category_id', 'name', 'description', 'default_priority_key'])] #[Fillable(['category_id', 'name', 'description', 'default_priority_key', 'sort_order'])]
class Subcategory extends Model class Subcategory extends Model
{ {
public function category(): BelongsTo public function category(): BelongsTo

View File

@@ -9,17 +9,57 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
#[Fillable([ #[Fillable([
'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body', 'number', 'checksum', 'customer_id', 'email', 'name', 'subcategory_id', 'category_id', 'subject', 'body',
'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'status_key', 'priority_key', 'team_id', 'assignee_id', 'custom_fields', 'api_client_id', 'source', 'hesk_ticket_id',
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_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', 'created_at', 'updated_at', 'csat_rating', 'csat_comment', 'csat_rated_at',
'ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at',
'snipeit_asset_id', 'snipeit_asset_name',
])] ])]
class Ticket extends Model class Ticket extends Model
{ {
/**
* Every value ever written to tickets.source across the app web
* submission (the default), IMAP-fetched e-mail, and the Hesk import
* command. Enforced on save (see booted() below) so a typo'd literal
* fails loudly instead of silently sticking in the column.
*/
public const SOURCES = ['web', 'email', 'hesk_import'];
/**
* ai_* and snipeit_* fields are no longer real columns on `tickets`
* they live in aiSummary()/snipeitAsset(), one-to-one extension tables (see the
* 2026_08_05_000164/000165 migrations for why: both blocks are wide and
* null on most tickets). These maps back the getAttribute()/
* setAttribute() overrides below, which keep every existing
* `$ticket->ai_summary`/`$ticket->snipeit_asset_id` read/write working
* unchanged against the new tables, so callers never had to change.
*/
private const AI_SUMMARY_FIELD_MAP = [
'ai_triaged_at' => 'triaged_at',
'ai_summary' => 'summary',
'ai_suggested_action' => 'suggested_action',
'ai_summary_generated_at' => 'summary_generated_at',
];
private const SNIPEIT_FIELD_MAP = [
'snipeit_asset_id' => 'asset_id',
'snipeit_asset_name' => 'asset_name',
];
/**
* Queued writes to the virtual ai_* and snipeit_* fields above, flushed into
* the related row once the ticket itself is saved (see booted()) rather
* than applied immediately a brand-new ticket has no id yet to key the
* related row on.
*/
protected array $pendingVirtualAttributes = [];
/** /**
* Every ticket gets a stable, unique checksum the moment its id is known * Every ticket gets a stable, unique checksum the moment its id is known
* it never needs to change afterward, and having it always populated * it never needs to change afterward, and having it always populated
@@ -32,6 +72,87 @@ class Ticket extends Model
$ticket->checksum = static::generateUniqueChecksum($ticket->id); $ticket->checksum = static::generateUniqueChecksum($ticket->id);
$ticket->saveQuietly(); $ticket->saveQuietly();
}); });
static::saving(function (Ticket $ticket) {
if ($ticket->source !== null && ! in_array($ticket->source, self::SOURCES, true)) {
throw new \InvalidArgumentException("Invalid ticket source: {$ticket->source}");
}
});
static::saved(function (Ticket $ticket) {
$ticket->flushPendingVirtualAttributes();
// Keeps ticket_field_values (queryable EAV rows) in sync with the
// freeform custom_fields JSON blob — see syncFieldValues().
if ($ticket->wasChanged('custom_fields') || $ticket->wasRecentlyCreated) {
$ticket->syncFieldValues();
}
});
}
/**
* @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP
*/
public function getAttribute($key)
{
if (isset(self::AI_SUMMARY_FIELD_MAP[$key])) {
return array_key_exists($key, $this->pendingVirtualAttributes)
? $this->pendingVirtualAttributes[$key]
: $this->aiSummary?->{self::AI_SUMMARY_FIELD_MAP[$key]};
}
if (isset(self::SNIPEIT_FIELD_MAP[$key])) {
return array_key_exists($key, $this->pendingVirtualAttributes)
? $this->pendingVirtualAttributes[$key]
: $this->snipeitAsset?->{self::SNIPEIT_FIELD_MAP[$key]};
}
return parent::getAttribute($key);
}
/**
* @see AI_SUMMARY_FIELD_MAP, SNIPEIT_FIELD_MAP
*/
public function setAttribute($key, $value)
{
if (isset(self::AI_SUMMARY_FIELD_MAP[$key]) || isset(self::SNIPEIT_FIELD_MAP[$key])) {
$this->pendingVirtualAttributes[$key] = $value;
return $this;
}
return parent::setAttribute($key, $value);
}
protected function flushPendingVirtualAttributes(): void
{
if (! $this->pendingVirtualAttributes) {
return;
}
$ai = array_intersect_key($this->pendingVirtualAttributes, self::AI_SUMMARY_FIELD_MAP);
$snipeit = array_intersect_key($this->pendingVirtualAttributes, self::SNIPEIT_FIELD_MAP);
$this->pendingVirtualAttributes = [];
if ($ai) {
$this->aiSummary()->updateOrCreate([], collect($ai)
->mapWithKeys(fn ($value, $key) => [self::AI_SUMMARY_FIELD_MAP[$key] => $value])->all());
}
if ($snipeit) {
$this->snipeitAsset()->updateOrCreate([], collect($snipeit)
->mapWithKeys(fn ($value, $key) => [self::SNIPEIT_FIELD_MAP[$key] => $value])->all());
}
}
public function aiSummary(): HasOne
{
return $this->hasOne(TicketAiSummary::class);
}
public function snipeitAsset(): HasOne
{
return $this->hasOne(TicketSnipeitAsset::class);
} }
protected function casts(): array protected function casts(): array
@@ -82,6 +203,16 @@ class Ticket extends Model
return $this->belongsTo(Category::class); return $this->belongsTo(Category::class);
} }
/**
* Queryable counterpart to the custom_fields JSON blob see
* syncFieldValues(). Read-only from the app's perspective; write custom
* field values via the custom_fields attribute as before.
*/
public function fieldValues(): HasMany
{
return $this->hasMany(TicketFieldValue::class);
}
public function watchers(): BelongsToMany public function watchers(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'ticket_watchers'); return $this->belongsToMany(User::class, 'ticket_watchers');
@@ -92,6 +223,26 @@ class Ticket extends Model
return $this->watchers()->where('users.id', $user->id)->exists(); return $this->watchers()->where('users.id', $user->id)->exists();
} }
public function viewers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'ticket_views')->withPivot('viewed_at');
}
/**
* Bumps viewed_at for an existing view rather than duplicating it sync()
* updates pivot columns on already-attached rows, not just new ones.
*
* Formatted explicitly with microseconds: a plain Carbon instance gets
* bound through the connection's default date format (whole seconds,
* regardless of the column's own declared precision), so two views in
* the same second would otherwise tie and silently fall back to sorting
* by row id instead of actual recency.
*/
public function recordViewBy(User $user): void
{
$this->viewers()->syncWithoutDetaching([$user->id => ['viewed_at' => now()->format('Y-m-d H:i:s.u')]]);
}
public function status(): BelongsTo public function status(): BelongsTo
{ {
return $this->belongsTo(Status::class, 'status_key'); return $this->belongsTo(Status::class, 'status_key');
@@ -132,9 +283,18 @@ class Ticket extends Model
return $this->hasMany(AutomationRuleTicketLog::class); return $this->hasMany(AutomationRuleTicketLog::class);
} }
/**
* Numeric-safe "max + 1" without pulling every ticket's number into PHP
* memory (`number` is a plain string column, so a DB-level MAX() would
* sort lexicographically "999" > "1000" hence ordering by length
* first). LENGTH()/ORDER BY/LIMIT are portable across MySQL and the
* sqlite connection tests run against, unlike a driver-specific CAST.
*/
public static function nextNumber(): string public static function nextNumber(): string
{ {
$max = static::query()->pluck('number')->map(fn ($n) => (int) $n)->max(); $max = (int) static::query()
->orderByRaw('LENGTH(number) DESC, number DESC')
->value('number');
return (string) (($max ?: 1000) + 1); return (string) (($max ?: 1000) + 1);
} }
@@ -451,7 +611,7 @@ class Ticket extends Model
public function flushTimer(): void public function flushTimer(): void
{ {
if ($this->timer_started_at) { if ($this->timer_started_at) {
$this->update([ $this->updateTimerFields([
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
'timer_started_at' => now(), 'timer_started_at' => now(),
]); ]);
@@ -461,7 +621,7 @@ class Ticket extends Model
public function stopTimer(): void public function stopTimer(): void
{ {
if ($this->timer_started_at) { if ($this->timer_started_at) {
$this->update([ $this->updateTimerFields([
'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(), 'time_spent_seconds' => $this->time_spent_seconds + $this->secondsSinceTimerStarted(),
'timer_started_at' => null, 'timer_started_at' => null,
]); ]);
@@ -480,13 +640,13 @@ class Ticket extends Model
} }
if (! $this->timer_started_at) { if (! $this->timer_started_at) {
$this->update(['timer_started_at' => now()]); $this->updateTimerFields(['timer_started_at' => now()]);
} }
} }
public function resetTimer(): void public function resetTimer(): void
{ {
$this->update(['time_spent_seconds' => 0, 'timer_started_at' => null]); $this->updateTimerFields(['time_spent_seconds' => 0, 'timer_started_at' => null]);
} }
/** /**
@@ -497,12 +657,27 @@ class Ticket extends Model
*/ */
public function setTimeSpent(int $seconds): void public function setTimeSpent(int $seconds): void
{ {
$this->update([ $this->updateTimerFields([
'time_spent_seconds' => max(0, $seconds), 'time_spent_seconds' => max(0, $seconds),
'timer_started_at' => $this->timer_started_at ? now() : null, 'timer_started_at' => $this->timer_started_at ? now() : null,
]); ]);
} }
/**
* Timer bookkeeping alone is never a ticket "update" worth surfacing
* merely opening a ticket (resumeTimer on mount, stopTimer on leaving)
* would otherwise bump `updated_at` on every single view, drowning out
* genuinely stale tickets in queues/lists sorted by that column. Real
* content changes (replies, status/priority/assignee edits, ...) go
* through their own ->update() calls elsewhere and still touch it.
*/
private function updateTimerFields(array $attributes): void
{
$this->timestamps = false;
$this->update($attributes);
$this->timestamps = true;
}
/** /**
* Custom field values for this ticket's subcategory, in display order, skipping blanks. * Custom field values for this ticket's subcategory, in display order, skipping blanks.
* *
@@ -528,4 +703,33 @@ class Ticket extends Model
->values() ->values()
->all(); ->all();
} }
/**
* Mirrors the custom_fields JSON blob into ticket_field_values, one row
* per non-blank entry called automatically on save (see booted()).
* Deleted/blanked entries are removed rather than left stale, and
* unrecognized field ids (e.g. a value left over after its custom_fields
* definition was deleted) are skipped, matching the migration's
* backfill.
*/
public function syncFieldValues(): void
{
$values = $this->custom_fields ?? [];
$validFieldIds = CustomField::query()->pluck('id')->all();
$this->fieldValues()->whereNotIn('custom_field_id', array_keys($values))->delete();
foreach ($values as $fieldId => $value) {
if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) {
$this->fieldValues()->where('custom_field_id', $fieldId)->delete();
continue;
}
$this->fieldValues()->updateOrCreate(
['custom_field_id' => $fieldId],
['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value],
);
}
}
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'triaged_at', 'summary', 'suggested_action', 'summary_generated_at'])]
class TicketAiSummary extends Model
{
protected function casts(): array
{
return [
'triaged_at' => 'datetime',
'summary_generated_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'custom_field_id', 'value'])]
class TicketFieldValue extends Model
{
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
public function field(): BelongsTo
{
return $this->belongsTo(CustomField::class, 'custom_field_id');
}
}

View File

@@ -12,6 +12,23 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough;
#[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])] #[Fillable(['ticket_id', 'author_name', 'internal', 'body', 'edited', 'api_client_id', 'source', 'created_at', 'updated_at'])]
class TicketMessage extends Model class TicketMessage extends Model
{ {
/**
* Non-null values ever written to ticket_messages.source null means
* "web" (see the migration that added this column); only IMAP-fetched
* replies set it to 'email'. Enforced on save (see booted() below) so a
* typo'd literal fails loudly instead of silently sticking.
*/
public const SOURCES = ['email'];
protected static function booted(): void
{
static::saving(function (TicketMessage $message) {
if ($message->source !== null && ! in_array($message->source, self::SOURCES, true)) {
throw new \InvalidArgumentException("Invalid ticket message source: {$message->source}");
}
});
}
protected function casts(): array protected function casts(): array
{ {
return [ return [

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'asset_id', 'asset_name'])]
class TicketSnipeitAsset extends Model
{
protected function casts(): array
{
return [
'asset_id' => 'integer',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -15,8 +14,8 @@ use Illuminate\Notifications\Notifiable;
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap; use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
use LdapRecord\Laravel\Auth\LdapAuthenticatable; use LdapRecord\Laravel\Auth\LdapAuthenticatable;
#[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values'])] #[Fillable(['name', 'email', 'password', 'roles', 'custom_field_values', 'operator_queue_columns'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password'])]
class User extends Authenticatable implements LdapAuthenticatable class User extends Authenticatable implements LdapAuthenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
@@ -109,8 +108,8 @@ class User extends Authenticatable implements LdapAuthenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'operator_queue_columns' => 'array',
]; ];
} }
@@ -189,6 +188,13 @@ class User extends Authenticatable implements LdapAuthenticatable
return $this->belongsToMany(Ticket::class, 'ticket_watchers'); return $this->belongsToMany(Ticket::class, 'ticket_watchers');
} }
public function recentlyViewedTickets(): BelongsToMany
{
return $this->belongsToMany(Ticket::class, 'ticket_views')
->withPivot('viewed_at')
->orderByPivot('viewed_at', 'desc');
}
public function ticketsAssigned(): HasMany public function ticketsAssigned(): HasMany
{ {
return $this->hasMany(Ticket::class, 'assignee_id'); return $this->hasMany(Ticket::class, 'assignee_id');

View File

@@ -3,6 +3,8 @@
namespace App\Providers; namespace App\Providers;
use App\Events\NotificationCreated; use App\Events\NotificationCreated;
use App\Events\TicketMessagePosted;
use App\Jobs\GenerateTicketAiSummaryJob;
use App\Models\ApiClient; use App\Models\ApiClient;
use App\Models\User; use App\Models\User;
use App\Notifications\TicketNotification; use App\Notifications\TicketNotification;
@@ -40,6 +42,7 @@ class AppServiceProvider extends ServiceProvider
$this->applyTimezoneSettingsOverride(); $this->applyTimezoneSettingsOverride();
$this->configureApiRateLimiting(); $this->configureApiRateLimiting();
$this->broadcastBellNotifications(); $this->broadcastBellNotifications();
$this->regenerateAiSummaryOnNewMessage();
// 'user' backs the polymorphic notifiable_type column on the // 'user' backs the polymorphic notifiable_type column on the
// database-notifications table (in-app notification bell). // database-notifications table (in-app notification bell).
@@ -68,6 +71,25 @@ class AppServiceProvider extends ServiceProvider
}); });
} }
/**
* Admin-optional: when enabled, every reply/note/API message re-runs the
* AI summary for its ticket right away instead of waiting for the next
* ai:run-ticket-automation sweep (up to schedule_ai_automation_minutes
* stale). dispatchAfterResponse() runs in-process after the triggering
* request finishes rather than going through the queue table see
* GenerateTicketAiSummaryJob's docblock for why.
*/
protected function regenerateAiSummaryOnNewMessage(): void
{
Event::listen(TicketMessagePosted::class, function (TicketMessagePosted $event) {
if (! Settings::bool('ai_summary_enabled') || ! Settings::bool('ai_summary_regenerate_on_message')) {
return;
}
GenerateTicketAiSummaryJob::dispatchAfterResponse($event->ticketId);
});
}
/** /**
* API keys get a generous per-key budget; unauthenticated requests (which * API keys get a generous per-key budget; unauthenticated requests (which
* only ever hit the guard before rejecting with 401) get a much smaller * only ever hit the guard before rejecting with 401) get a much smaller
@@ -136,6 +158,12 @@ class AppServiceProvider extends ServiceProvider
Config::set('ldap.connections.default', $config); Config::set('ldap.connections.default', $config);
Container::addConnection(new Connection($config), 'default'); Container::addConnection(new Connection($config), 'default');
// Active Directory's objectClass chain (top/person/organizationalPerson/
// user) and login attribute (sAMAccountName) differ from the
// LLDAP/OpenLDAP schema LldapUser is scoped to — swap in AdUser so a
// directory switch doesn't leave every login matching zero entries.
Config::set('auth.providers.users.model', Settings::ldapUserModelClass());
} }
/** /**

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Services;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Generic OpenAI-compatible chat-completions client works against Groq,
* OpenAI itself, or a self-hosted Ollama instance's OpenAI-compat endpoint,
* whichever the admin points ai_base_url at. Not BookStack-specific; the
* BookStack content tagger is just the first consumer.
*/
class AiClient
{
public function enabled(): bool
{
// Deliberately no api-key requirement here — a self-hosted Ollama
// instance typically has no auth at all.
return Settings::bool('ai_enabled')
&& Settings::get('ai_base_url')
&& Settings::get('ai_model');
}
/**
* @param array<int, array{role: string, content: string}> $messages
* @return string|null the assistant message content, or null on any failure
*/
public function chat(array $messages, array $options = []): ?string
{
if (! $this->enabled()) {
return null;
}
$model = Settings::get('ai_model');
try {
$response = $this->client()->post('/chat/completions', [
'model' => $model,
'messages' => $messages,
...$options,
]);
if (! $response->successful()) {
Log::channel('ai')->warning(sprintf(
'Zapytanie do modelu %s zakończone błędem HTTP %d: %s',
$model,
$response->status(),
$response->json('error.message') ?? Str::limit($response->body(), 300),
));
return null;
}
$content = $response->json('choices.0.message.content');
Log::channel('ai')->debug(sprintf(
'Zapytanie do modelu %s: %d wiadomości wejściowych, odpowiedź %d znaków.',
$model,
count($messages),
mb_strlen((string) $content),
));
return $content;
} catch (\Throwable $e) {
Log::channel('ai')->error("Zapytanie do modelu {$model} nie powiodło się: {$e->getMessage()}");
return null;
}
}
/**
* Tests unsaved admin-form values directly, rather than whatever's
* currently stored mirrors BookStackClient::testConnection().
*
* @return array{ok: bool, message: ?string}
*/
public function testConnection(string $baseUrl, string $apiKey, string $model, bool $verifySsl = true): array
{
try {
$http = Http::withOptions(['verify' => $verifySsl])
->timeout(10)
->baseUrl(rtrim($baseUrl, '/'));
if ($apiKey !== '') {
$http = $http->withToken($apiKey);
}
$response = $http->post('/chat/completions', [
'model' => $model,
'messages' => [['role' => 'user', 'content' => 'ping']],
'max_tokens' => 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()
{
$http = Http::withOptions(['verify' => Settings::bool('ai_verify_ssl')])
->timeout(60)
->baseUrl(rtrim(Settings::get('ai_base_url'), '/'));
$apiKey = Settings::get('ai_api_key');
return $apiKey ? $http->withToken($apiKey) : $http;
}
}

View File

@@ -23,6 +23,31 @@ class BookStackClient
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view', self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
]; ];
/**
* Content types selectable via the admin's "Przeszukuj" checkboxes.
* Deliberately excludes 'bookshelf' shelves are only ever a filter
* (dozwolone półki), never a suggestion result in their own right.
*/
public const SEARCH_TYPES = ['book', 'page', 'chapter'];
/**
* How the query text is matched, via the admin's "Szukaj po" option
* 'name' restricts to the title ({in_name:...}), 'tags' matches a tag
* whose name equals the query (expected to hold the helpdesk
* category/subcategory name, e.g. a "Drukarki" tag on the relevant
* BookStack pages), 'both' runs both and merges the results (BookStack's
* query syntax ANDs filters together, so there's no single-query way to
* express "name OR tag").
*/
public const SEARCH_BY_OPTIONS = ['name', 'tags', 'both'];
/**
* Content types the bulk-tagging command operates over same set as
* SEARCH_TYPES (book/page/chapter, no bookshelf), named separately since
* the two consts serve different features that happen to share a domain.
*/
public const CONTENT_TYPES = self::SEARCH_TYPES;
public function enabled(): bool public function enabled(): bool
{ {
return Settings::bool('bookstack_enabled') return Settings::bool('bookstack_enabled')
@@ -39,44 +64,56 @@ class BookStackClient
* before any content is ever suggested, independently per context). * before any content is ever suggested, independently per context).
* Cached briefly since the same category/subcategory query repeats * Cached briefly since the same category/subcategory query repeats
* across every ticket created/viewed with that combination. Respects the * across every ticket created/viewed with that combination. Respects the
* admin-configured bookstack_search_types setting ('both'|'page'|'book') * admin-configured bookstack_search_types (subset of SEARCH_TYPES, via
* via BookStack's own `{type:x}` query syntax. The cache key folds in the * BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'|
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected * 'tags'|'both', via `{in_name:...}`/`[...]`) settings. The cache key
* immediately, instead of possibly serving a pre-change result for up to * folds in the allowed-shelf list so changing it in Admin > Konfiguracja
* 10 minutes. * is reflected immediately, instead of possibly serving a pre-change
* result for up to 10 minutes.
*
* $tagQuery is the text matched by the 'tags' variant, separate from
* $query (matched by the 'name' variant) callers pass the bare
* subcategory name here (what bookstack:tag-content actually writes as
* a tag), while $query stays the fuller "Category Subcategory" text
* that's more useful for a plain title/body search. Defaults to $query
* so existing call sites that don't pass it keep working.
* *
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}> * @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 public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION, ?string $tagQuery = null): array
{ {
$query = trim($query); $query = trim($query);
$tagQuery = trim($tagQuery ?? $query);
$allowedShelfIds = $this->allowedShelfIds($context); $allowedShelfIds = $this->allowedShelfIds($context);
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) { if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
return []; return [];
} }
$typeFilter = Settings::get('bookstack_search_types', 'both'); $searchBy = $this->searchBy();
$bookstackQueries = $searchBy === 'both'
? [$this->buildQuery($query, 'name'), $this->buildQuery($tagQuery, 'tags')]
: [$this->buildQuery($searchBy === 'tags' ? $tagQuery : $query, $searchBy)];
if (in_array($typeFilter, ['page', 'book'], true)) { $cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds));
$query .= " {type:{$typeFilter}}";
}
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds)); return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) {
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
try { try {
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
if (! $response->successful()) {
return [];
}
$shelfMap = $this->shelfBookMap(); $shelfMap = $this->shelfBookMap();
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds); $allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
$bookShelfNames = $this->bookShelfNames($shelfMap); $bookShelfNames = $this->bookShelfNames($shelfMap);
return collect($response->json('data', [])) $items = collect();
foreach ($bookstackQueries as $bookstackQuery) {
$response = $this->client()->get('/api/search', ['query' => $bookstackQuery, 'count' => $limit]);
if ($response->successful()) {
$items = $items->concat($response->json('data', []));
}
}
return $items
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) { ->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
$type = $item['type'] ?? null; $type = $item['type'] ?? null;
@@ -103,6 +140,8 @@ class BookStackClient
]; ];
}) })
->filter(fn (array $item) => $item['name'] !== '') ->filter(fn (array $item) => $item['name'] !== '')
->unique(fn (array $item) => $item['url'] ?? $item['name'])
->take($limit)
->values() ->values()
->all(); ->all();
} catch (\Throwable) { } catch (\Throwable) {
@@ -111,6 +150,68 @@ class BookStackClient
}); });
} }
/**
* Builds one BookStack search-syntax query string for $query, restricted
* to $by ('name' -> `{in_name:...}`, 'tags' -> `[...]`) and to the
* configured content types (`{type:a|b}`, omitted if all types are
* allowed since that's equivalent to no filter).
*/
protected function buildQuery(string $query, string $by): string
{
$parts = [$by === 'tags' ? "[{$query}]" : "{in_name:{$query}}"];
$types = $this->searchTypes();
if (array_diff(self::SEARCH_TYPES, $types)) {
$parts[] = '{type:'.implode('|', $types).'}';
}
return implode(' ', $parts);
}
/**
* @return string[] non-empty subset of SEARCH_TYPES
*/
protected function searchTypes(): array
{
return self::normalizeSearchTypes(Settings::get('bookstack_search_types', ''));
}
/**
* Normalizes bookstack_search_types storage into a non-empty subset of
* SEARCH_TYPES shared with Admin\Panel so the checkbox UI and the
* actual search agree on the same format. Also understands the legacy
* single-value 'both'/'page'/'book' storage from before the setting
* became a checkbox list, so existing configuration keeps working.
*
* @param string[]|string $raw
* @return string[]
*/
public static function normalizeSearchTypes(array|string $raw): array
{
$legacy = ['both' => self::SEARCH_TYPES, 'page' => ['page'], 'book' => ['book']];
if (is_string($raw) && isset($legacy[$raw])) {
return $legacy[$raw];
}
$types = collect(is_array($raw) ? $raw : explode(',', $raw))
->map(fn ($v) => trim((string) $v))
->filter(fn ($v) => in_array($v, self::SEARCH_TYPES, true))
->unique()
->values()
->all();
return $types ?: self::SEARCH_TYPES;
}
protected function searchBy(): string
{
$raw = Settings::get('bookstack_search_by', 'both');
return in_array($raw, self::SEARCH_BY_OPTIONS, true) ? $raw : 'both';
}
/** /**
* Drops the cached shelf list and shelf>book membership map used by * 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/ * the admin's "Odśwież listę półek" button so a shelf renamed/added/
@@ -155,6 +256,103 @@ class BookStackClient
}); });
} }
/**
* Every book/chapter/page of $type across the whole BookStack instance
* NOT filtered by the allowed-shelf settings, unlike search(). Those only
* gate which suggestions are ever shown to a client/operator; the bulk
* tagger is meant to cover every piece of content regardless. Paginates
* through BookStack's count/offset list endpoints (count capped at 500,
* the API's own per-page maximum). Uncached this is a one-shot batch
* read, not a repeated request-path lookup.
*
* @return array<int, array{id: int, name: string}>
*/
public function listAll(string $type): array
{
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
return [];
}
$items = [];
$offset = 0;
try {
do {
$response = $this->client()->get("/api/{$type}s", ['count' => 500, 'offset' => $offset]);
if (! $response->successful()) {
break;
}
$page = $response->json('data', []);
$items = [...$items, ...$page];
$offset += 500;
$total = $response->json('total', 0);
} while (count($page) > 0 && count($items) < $total);
} catch (\Throwable) {
return $items;
}
return $items;
}
/**
* Full detail for a single book/chapter/page its current tags (needed
* to merge rather than clobber when the tagger writes new ones) and the
* text used to classify it (a page's markdown source, or a book/
* chapter's description). Null if $type is invalid or the item can't be
* fetched.
*
* @return array{id: int, name: string, tags: array<int, array{name: string, value: string}>, content: string}|null
*/
public function detail(string $type, int $id): ?array
{
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
return null;
}
try {
$response = $this->client()->get("/api/{$type}s/{$id}");
if (! $response->successful()) {
return null;
}
$data = $response->json();
return [
'id' => $data['id'],
'name' => $data['name'] ?? '',
'tags' => $data['tags'] ?? [],
'content' => $data['markdown'] ?? $data['description'] ?? '',
];
} catch (\Throwable) {
return null;
}
}
/**
* Overwrites just the tags field on a book/chapter/page BookStack
* treats every field on its update endpoints as optional, so this never
* touches the item's name/content/other attributes. Callers are
* responsible for merging in any tags they want to keep (this replaces
* the whole array, it doesn't append).
*
* @param array<int, array{name: string, value: string}> $tags
*/
public function updateTags(string $type, int $id, array $tags): bool
{
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
return false;
}
try {
return $this->client()->put("/api/{$type}s/{$id}", ['tags' => $tags])->successful();
} catch (\Throwable) {
return false;
}
}
/** /**
* @return int[] * @return int[]
*/ */

View File

@@ -0,0 +1,260 @@
<?php
namespace App\Services;
use App\Models\Subcategory;
use Illuminate\Support\Str;
/**
* Bulk-assigns BookStack tags to every book/chapter/page, using the
* configured AiClient to classify each item's title+content against the
* helpdesk's current subcategory vocabulary — so BookStackClient::search()'s
* 'tags'/'both' mode has something to actually match against. Idempotent by
* default: an item already carrying a tag matching a current subcategory
* name is skipped, so re-running after adding a handful of new pages is
* cheap; $force re-classifies everything.
*/
class BookStackContentTagger
{
protected const BATCH_SIZE = 20;
protected const CONTENT_EXCERPT_CHARS = 2000;
public function __construct(
protected BookStackClient $bookstack,
protected AiClient $ai,
) {}
/**
* @return array{scanned: int, tagged: int, skipped: int, failed_batches: int}
*/
public function run(bool $dryRun = false, bool $force = false, ?int $limit = null): array
{
$totals = ['scanned' => 0, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 0];
if (! $this->bookstack->enabled() || ! $this->ai->enabled()) {
return $totals;
}
$vocabulary = $this->subcategoryVocabulary();
if (! $vocabulary) {
return $totals;
}
foreach (BookStackClient::CONTENT_TYPES as $type) {
foreach ($this->itemsToProcess($type, $vocabulary, $force, $limit, $totals) as $batch) {
$this->processBatch($type, $batch, $vocabulary, $dryRun, $totals);
if ($limit !== null && $totals['scanned'] >= $limit) {
break 2;
}
}
}
return $totals;
}
/**
* @return string[] current subcategory names, deduped case-insensitively
*/
protected function subcategoryVocabulary(): array
{
return Subcategory::query()
->pluck('name')
->filter()
->unique(fn (string $name) => Str::lower($name))
->values()
->all();
}
/**
* Yields item detail records (id, name, tags, content) in chunks of
* BATCH_SIZE, skipping already-tagged items unless $force. Detail is
* fetched per item since BookStack's list endpoints don't include
* tags/content acceptable here since this is an offline batch job, not
* a request-path call.
*
* @param string[] $vocabulary
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
* @return \Generator<int, array<int, array{id: int, name: string, tags: array, content: string}>>
*/
protected function itemsToProcess(string $type, array $vocabulary, bool $force, ?int $limit, array &$totals): \Generator
{
$batch = [];
foreach ($this->bookstack->listAll($type) as $summary) {
if ($limit !== null && $totals['scanned'] >= $limit) {
return;
}
$detail = $this->bookstack->detail($type, $summary['id']);
if (! $detail) {
continue;
}
$totals['scanned']++;
if (! $force && $this->alreadyTagged($detail['tags'], $vocabulary)) {
$totals['skipped']++;
continue;
}
$batch[] = $detail;
if (count($batch) >= self::BATCH_SIZE) {
yield $batch;
$batch = [];
}
}
if ($batch) {
yield $batch;
}
}
/**
* @param array<int, array{name: string, value: string}> $tags
* @param string[] $vocabulary
*/
protected function alreadyTagged(array $tags, array $vocabulary): bool
{
$existing = collect($tags)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
$vocabLower = collect($vocabulary)->map(fn (string $v) => Str::lower($v));
return $existing->intersect($vocabLower)->isNotEmpty();
}
/**
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
* @param string[] $vocabulary
* @param array{scanned: int, tagged: int, skipped: int, failed_batches: int} $totals
*/
protected function processBatch(string $type, array $batch, array $vocabulary, bool $dryRun, array &$totals): void
{
$prompt = $this->buildPrompt($batch, $vocabulary);
$raw = $this->ai->chat([
['role' => 'system', 'content' => $prompt['system']],
['role' => 'user', 'content' => $prompt['user']],
], ['temperature' => 0]);
$assignments = $this->parseAssignments($raw, $vocabulary);
if ($assignments === null) {
$totals['failed_batches']++;
return;
}
foreach ($batch as $item) {
$labels = $assignments[(string) $item['id']] ?? [];
if (! $labels) {
continue;
}
if ($dryRun) {
$totals['tagged']++;
continue;
}
$mergedTags = $this->mergeTags($item['tags'], $labels);
if ($this->bookstack->updateTags($type, $item['id'], $mergedTags)) {
$totals['tagged']++;
}
}
}
/**
* @param array<int, array{id: int, name: string, tags: array, content: string}> $batch
* @param string[] $vocabulary
* @return array{system: string, user: string}
*/
protected function buildPrompt(array $batch, array $vocabulary): array
{
$system = 'Klasyfikujesz artykuły bazy wiedzy do kategorii zgłoszeń helpdesk. '
.'Dostępne kategorie (użyj DOKŁADNIE tej pisowni): '.implode(', ', $vocabulary).'. '
.'Dla każdego elementu przypisz 0, 1 lub więcej pasujących kategorii — nie zgaduj '
.'kategorii, jeśli żadna sensownie nie pasuje, zwróć pustą tablicę. '
.'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania, '
.'gdzie klucz to id elementu (jako string), a wartość to tablica dopasowanych nazw '
.'kategorii. Przykład: {"12": ["Drukarki i skanery"], "13": []}';
$user = collect($batch)->map(function (array $item) {
$excerpt = Str::limit(strip_tags($item['content']), self::CONTENT_EXCERPT_CHARS, '');
return "id={$item['id']} nazwa=\"{$item['name']}\"\n{$excerpt}";
})->implode("\n---\n");
return ['system' => $system, 'user' => $user];
}
/**
* Defensively parses the LLM's JSON response not every OpenAI-
* compatible provider (esp. self-hosted Ollama models) reliably honors a
* "respond with only JSON" instruction, so this first tries to pull out
* the first {...} block (in case the model wrapped it in prose or a
* markdown code fence) before decoding. A malformed/non-JSON response
* fails just this one batch (caller counts it in failed_batches) rather
* than aborting the whole run. Only vocabulary-matching labels survive
* (case-insensitive); anything else the model returns is discarded.
*
* @param string[] $vocabulary
* @return array<string, string[]>|null
*/
protected function parseAssignments(?string $raw, array $vocabulary): ?array
{
if (! $raw) {
return null;
}
if (! preg_match('/\{.*\}/s', $raw, $matches)) {
return null;
}
$decoded = json_decode($matches[0], true);
if (! is_array($decoded)) {
return null;
}
$vocabByLower = collect($vocabulary)->mapWithKeys(fn (string $v) => [Str::lower($v) => $v]);
$result = [];
foreach ($decoded as $id => $labels) {
if (! is_array($labels)) {
continue;
}
$result[(string) $id] = collect($labels)
->map(fn ($l) => $vocabByLower[Str::lower((string) $l)] ?? null)
->filter()
->unique()
->values()
->all();
}
return $result;
}
/**
* @param array<int, array{name: string, value: string}> $existing
* @param string[] $newLabels
* @return array<int, array{name: string, value: string}>
*/
protected function mergeTags(array $existing, array $newLabels): array
{
$existingLower = collect($existing)->map(fn (array $t) => Str::lower($t['name'] ?? ''));
$additions = collect($newLabels)
->reject(fn (string $label) => $existingLower->contains(Str::lower($label)))
->map(fn (string $label) => ['name' => $label, 'value' => '']);
return [...$existing, ...$additions->values()->all()];
}
}

View File

@@ -2,10 +2,11 @@
namespace App\Services; namespace App\Services;
use App\Ldap\LldapUser;
use App\Models\User; use App\Models\User;
use App\Models\UserField; use App\Models\UserField;
use App\Support\Settings;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use LdapRecord\Models\Model as LdapModel;
use Throwable; use Throwable;
/** /**
@@ -80,11 +81,26 @@ class LdapUserProvisioner
return $matched; return $matched;
} }
protected function findLdapEntryForUser(User $user): ?LldapUser /**
* The LdapRecord model class for whichever directory is currently
* configured (LLDAP vs Active Directory see
* Settings::ldapUserModelClass()) resolved fresh on every call rather
* than cached, since an admin can flip the directory type mid-session.
*/
protected function ldapUserModel(): string
{
return Settings::ldapUserModelClass();
}
protected function findLdapEntryForUser(User $user): ?LdapModel
{ {
if ($user->guid) { if ($user->guid) {
try { try {
$byGuid = LldapUser::query()->where('entryuuid', '=', $user->guid)->first(); // findByGuid() builds the right raw filter for either a
// binary objectGUID (AD) or a plain entryUUID string
// (LLDAP/OpenLDAP) — unlike a plain ->where(), it doesn't
// need to know which attribute that is.
$byGuid = $this->ldapUserModel()::query()->findByGuid($user->guid);
} catch (Throwable $e) { } catch (Throwable $e) {
Log::warning('LDAP lookup by guid failed: '.$e->getMessage()); Log::warning('LDAP lookup by guid failed: '.$e->getMessage());
$byGuid = null; $byGuid = null;
@@ -98,10 +114,10 @@ class LdapUserProvisioner
return $this->findLdapEntryByEmail($user->email); return $this->findLdapEntryByEmail($user->email);
} }
protected function findLdapEntryByEmail(string $email): ?LldapUser protected function findLdapEntryByEmail(string $email): ?LdapModel
{ {
try { try {
return LldapUser::query()->where('mail', '=', $email)->first(); return $this->ldapUserModel()::query()->where('mail', '=', $email)->first();
} catch (Throwable $e) { } catch (Throwable $e) {
Log::warning('LDAP lookup by email failed: '.$e->getMessage()); Log::warning('LDAP lookup by email failed: '.$e->getMessage());
@@ -109,7 +125,7 @@ class LdapUserProvisioner
} }
} }
public function applyFieldsFromLdap(User $user, LldapUser $ldapEntry): void public function applyFieldsFromLdap(User $user, LdapModel $ldapEntry): void
{ {
$values = $user->custom_field_values ?? []; $values = $user->custom_field_values ?? [];

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Services;
use App\Support\Settings;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class SnipeItClient
{
public function enabled(): bool
{
return Settings::bool('snipeit_enabled')
&& Settings::get('snipeit_base_url')
&& Settings::get('snipeit_api_token');
}
/**
* Assets Snipe-IT has checked out to $email feeds the "Sprzęt
* zgłaszającego" sidebar shown to a client creating a ticket and to an
* operator viewing one. Snipe-IT has no "assets by e-mail" endpoint, so
* this looks the requester up as a Snipe-IT user first, then lists what's
* assigned to them. Cached briefly per e-mail since the ticket-creation
* form and ticket-view page both re-render this on every interaction.
*
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
*/
public function assetsForEmail(string $email): array
{
$email = trim($email);
if (! $this->enabled() || $email === '') {
return [];
}
return Cache::remember('snipeit:user-assets:'.md5(strtolower($email)), now()->addMinutes(5), function () use ($email) {
try {
$user = $this->findUserByEmail($email);
if (! $user) {
return [];
}
$response = $this->client()->get("/users/{$user['id']}/assets");
if (! $response->successful()) {
return [];
}
return collect($response->json('rows', []))
->map(fn (array $a) => $this->normalizeAsset($a))
->values()
->all();
} catch (\Throwable) {
return [];
}
});
}
/**
* Full-inventory search behind the operator's "przeszukaj cały
* inwentarz" picker — unlike assetsForEmail() this isn't scoped to any
* one requester. Uncached: it's a live, as-you-type lookup.
*
* @return array<int, array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}>
*/
public function searchAssets(string $query, int $limit = 10): array
{
$query = trim($query);
if (! $this->enabled() || $query === '') {
return [];
}
try {
$response = $this->client()->get('/hardware', ['search' => $query, 'limit' => $limit]);
if (! $response->successful()) {
return [];
}
return collect($response->json('rows', []))
->map(fn (array $a) => $this->normalizeAsset($a))
->values()
->all();
} catch (\Throwable) {
return [];
}
}
/**
* Live detail for a ticket's linked asset fetched fresh rather than
* trusting the ticket's cached snipeit_asset_name, so a status/
* reassignment change in Snipe-IT is reflected immediately. Null if
* unreachable or the asset was deleted there; callers fall back to the
* cached label in that case.
*
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, assignedTo: ?string, url: string}|null
*/
public function asset(int $id): ?array
{
if (! $this->enabled()) {
return null;
}
try {
$response = $this->client()->get("/hardware/{$id}");
if (! $response->successful()) {
return null;
}
$data = $response->json();
return [
...$this->normalizeAsset($data),
'assignedTo' => $data['assigned_to']['name'] ?? null,
];
} catch (\Throwable) {
return null;
}
}
/**
* Tests unsaved admin-form values directly, rather than whatever's
* currently stored mirrors BookStackClient::testConnection(). Hits a
* plain list endpoint (rather than e.g. /users/me, which isn't present
* on every Snipe-IT version) so this works as a version-agnostic
* auth+reachability check.
*
* @return array{ok: bool, message: ?string}
*/
public function testConnection(string $baseUrl, string $token, bool $verifySsl = true): array
{
try {
$response = Http::withToken($token)
->acceptJson()
->withOptions(['verify' => $verifySsl])
->timeout(6)
->get(rtrim($baseUrl, '/').'/api/v1/hardware', ['limit' => 1]);
if ($response->successful()) {
return ['ok' => true, 'message' => null];
}
$message = $response->json('messages') ?? $response->json('message');
return [
'ok' => false,
'message' => is_string($message) ? $message : ($message ? json_encode($message) : ('HTTP '.$response->status())),
];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => $e->getMessage()];
}
}
protected function findUserByEmail(string $email): ?array
{
$response = $this->client()->get('/users', ['search' => $email, 'limit' => 5]);
if (! $response->successful()) {
return null;
}
return collect($response->json('rows', []))
->first(fn (array $u) => isset($u['email']) && strcasecmp($u['email'], $email) === 0);
}
/**
* label is "numer środka - numer seryjny - producent model" joining
* whichever of those three pieces is actually present (Snipe-IT doesn't
* guarantee any of them), falling back to the bare asset id if all three
* are blank. The display format requested for the "Sprzęt zgłaszającego"
* picker and sidebar, everywhere an asset is listed.
*
* @return array{id: int, label: string, serial: ?string, manufacturer: ?string, model: ?string, category: ?string, status: ?string, url: string}
*/
protected function normalizeAsset(array $a): array
{
$assetTag = $a['asset_tag'] ?? null;
$serial = $a['serial'] ?? null;
$manufacturer = $a['manufacturer']['name'] ?? null;
$model = $a['model']['name'] ?? null;
$modelDisplay = trim(($manufacturer ? "{$manufacturer} " : '').($model ?? ''));
$labelParts = collect([$assetTag, $serial, $modelDisplay])
->map(fn ($v) => trim((string) $v))
->filter(fn ($v) => $v !== '');
$label = $labelParts->isNotEmpty() ? $labelParts->implode(' - ') : 'Zasób #'.$a['id'];
return [
'id' => $a['id'],
'label' => $label,
'serial' => $serial,
'manufacturer' => $manufacturer,
'model' => $model,
'category' => $a['category']['name'] ?? null,
'status' => $a['status_label']['name'] ?? null,
'url' => rtrim(Settings::get('snipeit_base_url'), '/').'/hardware/'.$a['id'],
];
}
protected function client()
{
return Http::withToken(Settings::get('snipeit_api_token'))
->acceptJson()
->withOptions(['verify' => Settings::bool('snipeit_verify_ssl')])
->timeout(6)
->baseUrl(rtrim(Settings::get('snipeit_base_url'), '/').'/api/v1');
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Services;
use App\Models\Ticket;
use App\Support\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Generates an AI summary + suggested next action for every ticket, cached
* on the ticket row and shown only in the operator view (see
* TicketAiTriageService's docblock for why this runs from the scheduled
* ai:run-ticket-automation command rather than live on page load). Stays
* reasonably fresh by regenerating whenever a ticket's latest message
* postdates its last summary, not on every scheduler tick for every ticket.
*/
class TicketAiSummaryService
{
protected const BATCH_LIMIT = 25;
protected const MESSAGE_EXCERPT_CHARS = 1500;
protected const BODY_EXCERPT_CHARS = 4000;
protected const TRANSCRIPT_MESSAGE_LIMIT = 30;
public function __construct(protected AiClient $ai) {}
/**
* @return array{scanned: int, updated: int, failed: int}
*/
public function run(?int $limit = null): array
{
$totals = ['scanned' => 0, 'updated' => 0, 'failed' => 0];
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
return $totals;
}
$this->staleQuery()
->limit($limit ?? self::BATCH_LIMIT)
->get()
->each(function (Ticket $ticket) use (&$totals) {
$totals['scanned']++;
$this->summarizeOne($ticket) ? $totals['updated']++ : $totals['failed']++;
});
Log::channel('ai')->info(sprintf(
'Podsumowania: przeskanowano %d, zaktualizowano %d, błędów %d.',
$totals['scanned'],
$totals['updated'],
$totals['failed'],
));
return $totals;
}
/**
* Tickets with no summary yet, or whose latest message postdates the
* last summary generation. Deliberately compares against
* ticket_messages.created_at rather than tickets.updated_at the
* latter also changes on unrelated actions (status/priority/timer
* edits), which would otherwise trigger spurious re-summarization on
* every scheduler tick for an active ticket.
*
* ai_summary_generated_at now lives on the related ticket_ai_summaries
* row (see Ticket::aiSummary()), so this joins to it directly rather
* than going through the model relation a plain whereNull() on the
* left-joined column covers "no row yet" the same way it used to cover
* "column is null" when it lived on tickets itself.
*/
protected function staleQuery(): Builder
{
return Ticket::query()
->leftJoin('ticket_ai_summaries', 'ticket_ai_summaries.ticket_id', '=', 'tickets.id')
->where(function (Builder $q) {
$q->whereNull('ticket_ai_summaries.summary_generated_at')
->orWhere(function (Builder $q2) {
$q2->whereNotNull('ticket_ai_summaries.summary_generated_at')
->whereColumn('ticket_ai_summaries.summary_generated_at', '<', DB::raw(
'(select max(ticket_messages.created_at) from ticket_messages where ticket_messages.ticket_id = tickets.id)'
));
});
})
->select('tickets.*')
->orderBy('tickets.id');
}
/**
* Regenerates the summary for a single ticket right now, bypassing the
* staleness check used by the manual "regenerate" button and the
* on-new-message hook, as opposed to run()'s scheduled batch sweep.
*/
public function generateFor(Ticket $ticket): bool
{
if (! $this->ai->enabled() || ! Settings::bool('ai_summary_enabled')) {
return false;
}
return $this->summarizeOne($ticket);
}
protected function summarizeOne(Ticket $ticket): bool
{
$raw = $this->ai->chat([
['role' => 'system', 'content' => Settings::get('ai_summary_prompt')],
['role' => 'user', 'content' => $this->buildTranscript($ticket)],
], ['temperature' => 0.2]);
$parsed = $this->parseResponse($raw);
if ($parsed === null) {
// Leaves any prior summary untouched and generated_at unchanged,
// so the ticket stays in the stale set and gets retried next run
// rather than silently losing a working summary.
Log::channel('ai')->warning("Podsumowanie zgłoszenia #{$ticket->number}: nie udało się wygenerować (brak lub niepoprawna odpowiedź modelu).");
return false;
}
$ticket->update([
'ai_summary' => $parsed['summary'],
'ai_suggested_action' => $parsed['suggested_action'],
'ai_summary_generated_at' => now(),
]);
Log::channel('ai')->debug("Podsumowanie zgłoszenia #{$ticket->number}: zaktualizowane.");
return true;
}
/**
* Includes tickets.body explicitly (the opening description, separate
* from ticket_messages) rather than relying on it showing up as the
* thread's first message that row falls outside the last-N transcript
* window on any ticket with more than TRANSCRIPT_MESSAGE_LIMIT messages,
* which would otherwise silently drop the original request from long
* threads. Mirrors TicketAiTriageService's own subject+body framing.
*/
protected function buildTranscript(Ticket $ticket): string
{
$lines = [
"Temat: {$ticket->subject}",
"Treść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS),
];
$ticket->messages()->latest('created_at')->limit(self::TRANSCRIPT_MESSAGE_LIMIT)->get()
->sortBy('created_at')
->each(function ($message) use (&$lines) {
$role = $message->internal ? 'notatka wewnętrzna' : ($message->role === 'client' ? 'klient' : 'operator');
$body = Str::limit(strip_tags($message->body), self::MESSAGE_EXCERPT_CHARS, '');
$lines[] = "[{$role}] {$message->author_name}: {$body}";
});
return implode("\n\n", $lines);
}
/**
* Same defensive-parsing shape used elsewhere in this app's AI services
* (BookStackContentTagger, TicketAiTriageService) extracts the first
* {...} block before decoding.
*
* @return array{summary: string, suggested_action: ?string}|null
*/
protected function parseResponse(?string $raw): ?array
{
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
return null;
}
$decoded = json_decode($matches[0], true);
if (! is_array($decoded) || empty($decoded['summary']) || ! is_string($decoded['summary'])) {
return null;
}
return [
'summary' => trim($decoded['summary']),
'suggested_action' => ! empty($decoded['suggested_action']) && is_string($decoded['suggested_action'])
? trim($decoded['suggested_action'])
: null,
];
}
}

View File

@@ -0,0 +1,328 @@
<?php
namespace App\Services;
use App\Models\Category;
use App\Models\Priority;
use App\Models\Ticket;
use App\Support\Settings;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* One-shot AI classification of new tickets, gated by 5 independent
* settings toggles (ai_triage_category_when_missing/
* subcategory_when_category_only/recheck_categorized/fix_subject/
* set_priority). Runs from the scheduled ai:run-ticket-automation command,
* never synchronously at ticket creation, so it never adds LLM latency to a
* live customer submitting a ticket. Every scanned ticket gets
* ai_triaged_at stamped exactly once this is a one-shot pass per ticket,
* not a continuous recheck, and there's no manual re-trigger by design.
*/
class TicketAiTriageService
{
protected const BATCH_LIMIT = 25;
protected const BODY_EXCERPT_CHARS = 4000;
public function __construct(
protected AiClient $ai,
protected TicketService $tickets,
) {}
/**
* @return array{scanned: int, changed: int, failed: int}
*/
public function run(?int $limit = null): array
{
$totals = ['scanned' => 0, 'changed' => 0, 'failed' => 0];
if (! $this->ai->enabled() || ! $this->anyToggleEnabled()) {
return $totals;
}
$vocabulary = $this->buildVocabulary();
$priorities = Priority::query()->orderBy('sort_order')->pluck('label', 'key')->all();
// ai_triaged_at now lives on the related ticket_ai_summaries row (see
// Ticket::aiSummary()) — whereDoesntHave() matches both "no row yet"
// and "row exists but triaged_at is still null", same as the plain
// whereNull() this replaces did when the column lived on tickets.
Ticket::query()
->whereDoesntHave('aiSummary', fn ($q) => $q->whereNotNull('triaged_at'))
->orderBy('id')
->limit($limit ?? self::BATCH_LIMIT)
->get()
->each(function (Ticket $ticket) use ($vocabulary, $priorities, &$totals) {
$totals['scanned']++;
$this->triageOne($ticket, $vocabulary, $priorities, $totals);
});
Log::channel('ai')->info(sprintf(
'Triage: przeskanowano %d, zmieniono %d, błędów %d.',
$totals['scanned'],
$totals['changed'],
$totals['failed'],
));
return $totals;
}
protected function anyToggleEnabled(): bool
{
return Settings::bool('ai_triage_category_when_missing')
|| Settings::bool('ai_triage_subcategory_when_category_only')
|| Settings::bool('ai_triage_recheck_categorized')
|| Settings::bool('ai_triage_fix_subject')
|| Settings::bool('ai_triage_set_priority');
}
/**
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
* @param array<string, string> $priorities
* @param array{scanned: int, changed: int, failed: int} $totals
*/
protected function triageOne(Ticket $ticket, array $vocabulary, array $priorities, array &$totals): void
{
$prompt = $this->buildPrompt($ticket, $vocabulary, $priorities);
if ($prompt === null) {
$ticket->update(['ai_triaged_at' => now()]);
return;
}
$raw = $this->ai->chat([
['role' => 'system', 'content' => $prompt['system']],
['role' => 'user', 'content' => $prompt['user']],
], ['temperature' => 0]);
$parsed = $this->parseResponse($raw);
// A response came back but couldn't be parsed — not fatal to the
// run, just means this ticket wasn't classified this time.
// ai_triaged_at is still stamped below so a persistently-bad
// response doesn't get retried forever.
if ($raw !== null && $parsed === null) {
$totals['failed']++;
Log::channel('ai')->warning("Triage zgłoszenia #{$ticket->number}: odpowiedź modelu nie dała się zinterpretować jako JSON.");
}
[$changes, $historyLines] = $parsed
? $this->resolveChanges($ticket, $parsed, $vocabulary, $priorities, $prompt['scope'])
: [[], []];
if ($changes) {
$this->tickets->applyAiTriage($ticket, $changes, $historyLines);
$totals['changed']++;
Log::channel('ai')->info("Triage zgłoszenia #{$ticket->number}: ".implode('; ', $historyLines));
} else {
Log::channel('ai')->debug("Triage zgłoszenia #{$ticket->number}: bez zmian.");
}
$ticket->update(['ai_triaged_at' => now()]);
}
/**
* @return array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}>
*/
protected function buildVocabulary(): array
{
return Category::query()->with('subcategories')->get()
->map(fn (Category $c) => [
'id' => $c->id,
'name' => $c->name,
'subcategories' => $c->subcategories->map(fn ($s) => ['id' => $s->id, 'name' => $s->name])->all(),
])
->all();
}
/**
* Determines which of the 3 mutually-exclusive category scenarios (if
* any) applies to $ticket's current state, and whether subject/priority
* are also in scope, then builds the prompt around exactly that. Returns
* null when nothing is applicable/enabled for this ticket, so the caller
* can skip straight to stamping ai_triaged_at without an AI call.
*
* @param array<int, array{id: int, name: string, subcategories: array}> $vocabulary
* @param array<string, string> $priorities
* @return array{system: string, user: string, scope: ?string}|null
*/
protected function buildPrompt(Ticket $ticket, array $vocabulary, array $priorities): ?array
{
$scope = null;
if (! $ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_category_when_missing')) {
$scope = 'missing';
} elseif ($ticket->category_id && ! $ticket->subcategory_id && Settings::bool('ai_triage_subcategory_when_category_only')) {
$scope = 'category_only';
} elseif ($ticket->subcategory_id && Settings::bool('ai_triage_recheck_categorized')) {
$scope = 'recheck';
}
$wantSubject = Settings::bool('ai_triage_fix_subject');
$wantPriority = Settings::bool('ai_triage_set_priority');
if ($scope === null && ! $wantSubject && ! $wantPriority) {
return null;
}
$parts = ['Klasyfikujesz zgłoszenia helpdesku na podstawie tematu i treści.'];
if ($scope === 'missing') {
$parts[] = 'To zgłoszenie nie ma jeszcze przypisanej kategorii ani podkategorii. Wybierz najlepiej '
.'pasującą kategorię z listy poniżej (użyj DOKŁADNIE tej pisowni) i, jeśli to możliwe, także '
.'konkretną podkategorię w jej ramach. Jeśli żadna kategoria sensownie nie pasuje, zwróć null dla obu pól.';
} elseif ($scope === 'category_only') {
$parts[] = "To zgłoszenie ma już przypisaną kategorię \"{$ticket->category->name}\", ale brak konkretnej "
.'podkategorii. Wybierz najlepiej pasującą podkategorię z listy poniżej (należącą do tej kategorii, '
.'użyj DOKŁADNIE tej pisowni). Jeśli żadna nie pasuje dobrze, zwróć null.';
} elseif ($scope === 'recheck') {
$parts[] = "To zgłoszenie ma już przypisaną podkategorię \"{$ticket->subcategory->label()}\". Sprawdź, "
.'czy to nadal najlepsze dopasowanie na podstawie treści. Jeśli tak — zwróć null (nic nie zmieniaj). '
.'Jeśli lepiej pasuje inna kategoria/podkategoria z listy poniżej, zwróć ją.';
}
if ($scope !== null) {
$parts[] = "Dostępne kategorie i podkategorie:\n".$this->vocabularyText($vocabulary, $scope, $ticket);
}
if ($wantSubject) {
$parts[] = 'Jeśli obecny temat zgłoszenia jest niejasny lub mylący, zaproponuj lepszy, zwięzły temat po '
.'polsku w polu "subject" (w przeciwnym razie null).';
}
if ($wantPriority) {
$priorityList = collect($priorities)->map(fn ($label, $key) => "{$key} ({$label})")->implode(', ');
$parts[] = 'Na podstawie treści oceń priorytet zgłoszenia i zwróć jego klucz w polu "priority" — '
."dostępne klucze: {$priorityList}.";
}
$parts[] = 'Odpowiedz WYŁĄCZNIE obiektem JSON, bez żadnego innego tekstu ani formatowania: '
.'{"category": "...", "subcategory": "...", "subject": "...", "priority": "..."} — pola, o które nie '
.'proszono powyżej, ustaw na null.';
$user = "Temat: {$ticket->subject}\n\nTreść:\n".Str::limit(strip_tags($ticket->body), self::BODY_EXCERPT_CHARS);
return ['system' => implode("\n\n", $parts), 'user' => $user, 'scope' => $scope];
}
/**
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
*/
protected function vocabularyText(array $vocabulary, string $scope, Ticket $ticket): string
{
$categories = $scope === 'category_only'
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
: collect($vocabulary);
return $categories
->map(fn (array $c) => "- {$c['name']}: ".collect($c['subcategories'])->pluck('name')->implode(', '))
->implode("\n");
}
/**
* Same defensive-parsing shape as BookStackContentTagger::parseAssignments()
* extracts the first {...} block before decoding, so prose-wrapped or
* malformed responses fail gracefully instead of crashing the run.
*/
protected function parseResponse(?string $raw): ?array
{
if (! $raw || ! preg_match('/\{.*\}/s', $raw, $matches)) {
return null;
}
$decoded = json_decode($matches[0], true);
return is_array($decoded) ? $decoded : null;
}
/**
* Fail-closed resolution: every value from the model is matched against
* the real vocabulary/priority list before being trusted an
* unmatched/hallucinated category, a subcategory outside its claimed
* category, or an unknown priority key is silently dropped rather than
* written to the ticket. Only fields whose resolved value actually
* differs from the ticket's current value produce a change + history
* line, so e.g. a "recheck" that confirms the existing subcategory
* leaves no trace.
*
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
* @param array<string, string> $priorities
* @return array{0: array<string, mixed>, 1: string[]}
*/
protected function resolveChanges(Ticket $ticket, array $parsed, array $vocabulary, array $priorities, ?string $scope): array
{
$changes = [];
$historyLines = [];
if ($scope !== null) {
$resolved = $this->resolveCategory($parsed, $vocabulary, $scope, $ticket);
if ($resolved) {
[$categoryId, $subcategoryId, $label] = $resolved;
if ($categoryId !== $ticket->category_id || $subcategoryId !== $ticket->subcategory_id) {
$changes['category_id'] = $categoryId;
$changes['subcategory_id'] = $subcategoryId;
$historyLines[] = "Kategoria zmieniona na: {$label}";
}
}
}
if (Settings::bool('ai_triage_fix_subject') && ! empty($parsed['subject']) && is_string($parsed['subject'])) {
$newSubject = Str::limit(trim($parsed['subject']), 255, '');
if ($newSubject !== '' && $newSubject !== $ticket->subject) {
$changes['subject'] = $newSubject;
$historyLines[] = "Temat zmieniony na: „{$newSubject}";
}
}
if (Settings::bool('ai_triage_set_priority') && ! empty($parsed['priority']) && is_string($parsed['priority'])) {
$key = collect($priorities)->keys()->first(fn ($k) => Str::lower($k) === Str::lower($parsed['priority']));
if ($key !== null && $key !== $ticket->priority_key) {
$changes['priority_key'] = $key;
$historyLines[] = 'Priorytet zmieniony na: '.Priority::labelFor($key);
}
}
return [$changes, $historyLines];
}
/**
* @param array<int, array{id: int, name: string, subcategories: array<int, array{id: int, name: string}>}> $vocabulary
* @return array{0: ?int, 1: ?int, 2: string}|null [category_id, subcategory_id, display label]
*/
protected function resolveCategory(array $parsed, array $vocabulary, string $scope, Ticket $ticket): ?array
{
$categories = $scope === 'category_only'
? collect($vocabulary)->filter(fn (array $c) => $c['id'] === $ticket->category_id)
: collect($vocabulary);
$subName = $parsed['subcategory'] ?? null;
if (is_string($subName) && $subName !== '') {
foreach ($categories as $category) {
foreach ($category['subcategories'] as $sub) {
if (Str::lower($sub['name']) === Str::lower($subName)) {
return [null, $sub['id'], "{$category['name']} / {$sub['name']}"];
}
}
}
}
$catName = $parsed['category'] ?? null;
if (is_string($catName) && $catName !== '') {
foreach ($categories as $category) {
if (Str::lower($category['name']) === Str::lower($catName)) {
return [$category['id'], null, $category['name']];
}
}
}
return null;
}
}

View File

@@ -55,6 +55,8 @@ class TicketService
'custom_fields' => $data['custom_values'] ?? [], 'custom_fields' => $data['custom_values'] ?? [],
'last_customer_activity_at' => now(), 'last_customer_activity_at' => now(),
'source' => $data['source'] ?? 'web', 'source' => $data['source'] ?? 'web',
'snipeit_asset_id' => $data['snipeit_asset_id'] ?? null,
'snipeit_asset_name' => $data['snipeit_asset_name'] ?? null,
]); ]);
$message = $ticket->messages()->create([ $message = $ticket->messages()->create([
@@ -161,12 +163,79 @@ class TicketService
TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id()); TicketQueueChanged::dispatch($ticket->id, 'team_changed', Auth::id());
} }
/**
* Applies one AI-triage pass's changes (see TicketAiTriageService) in a
* single update, rather than composing setPriority()/updateDetails()
* a pass can touch category, subcategory, subject and priority
* together, and those would each write their own generic history line
* and fire notify()/TriggerEngine::handle() per field instead of once
* per pass, fragmenting one semantic AI decision into several
* unrelated-looking edits. $historyLines carries one mechanical
* "X changed to: Y" line per changed field (built by the caller, since
* it already knows the human-readable labels); this always appends one
* more attribution line on top, mirroring how RunAutomationRules logs
* "Automatyzacja: {label}" after its own field-change lines.
*
* @param array<string, mixed> $changes column => value, only the fields that actually changed
* @param string[] $historyLines
*/
public function applyAiTriage(Ticket $ticket, array $changes, array $historyLines): void
{
if (! $changes) {
return;
}
$categoryChanged = array_key_exists('category_id', $changes) || array_key_exists('subcategory_id', $changes);
$priorityChanged = array_key_exists('priority_key', $changes);
$ticket->update($changes);
foreach ($historyLines as $line) {
$ticket->addHistory($line);
}
$ticket->addHistory('Automatyzacja: klasyfikacja AI');
if ($categoryChanged) {
$this->notify($ticket, 'category_changed');
app(TriggerEngine::class)->handle($ticket, 'category_changed');
}
if ($priorityChanged) {
$this->notify($ticket, 'priority_changed');
app(TriggerEngine::class)->handle($ticket, 'priority_changed');
}
app(TriggerEngine::class)->handle($ticket, 'ticket_updated');
TicketQueueChanged::dispatch($ticket->id, 'ai_triage', Auth::id());
}
public function setReporter(Ticket $ticket, User $customer): void public function setReporter(Ticket $ticket, User $customer): void
{ {
$ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]); $ticket->update(['customer_id' => $customer->id, 'email' => $customer->email, 'name' => $customer->name]);
$ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name); $ticket->addHistory('Zgłaszający zmieniony na: '.$customer->name);
} }
/**
* Links/unlinks the Snipe-IT asset attached to a ticket $asset null
* unlinks. Only the label (not live status/assignment) is cached on the
* ticket row, so it still shows something if Snipe-IT later becomes
* unreachable or the asset is deleted there, without a live API call on
* every ticket list render (see SnipeItClient::asset() for the live
* fetch used on the ticket-detail page itself).
*
* @param array{id: int, label: string}|null $asset
*/
public function setSnipeitAsset(Ticket $ticket, ?array $asset): void
{
$ticket->update([
'snipeit_asset_id' => $asset['id'] ?? null,
'snipeit_asset_name' => $asset['label'] ?? null,
]);
$ticket->addHistory($asset
? 'Powiązano sprzęt (inwentarz): '.$asset['label']
: 'Odpięto powiązany sprzęt (inwentarz)');
}
public function updateDetails(Ticket $ticket, array $data): void public function updateDetails(Ticket $ticket, array $data): void
{ {
$categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id; $categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id;

View File

@@ -2,6 +2,8 @@
namespace App\Support; namespace App\Support;
use App\Ldap\AdUser;
use App\Ldap\LldapUser;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
@@ -25,6 +27,13 @@ class Settings
'ticket_number_prefix' => '#', 'ticket_number_prefix' => '#',
'ticket_number_obfuscate' => '0', 'ticket_number_obfuscate' => '0',
'ticket_number_min_length' => '4', 'ticket_number_min_length' => '4',
'refresh_ticket_view_seconds' => '30',
'refresh_queue_seconds' => '60',
'refresh_notifications_seconds' => '30',
'schedule_sla_check_minutes' => '15',
'schedule_automation_rules_minutes' => '15',
'schedule_imap_fetch_minutes' => '5',
'schedule_ai_automation_minutes' => '5',
'ldap_enabled' => '1', 'ldap_enabled' => '1',
'ldap_host' => '', 'ldap_host' => '',
'ldap_port' => '389', 'ldap_port' => '389',
@@ -48,9 +57,38 @@ class Settings
'bookstack_token_secret' => '', 'bookstack_token_secret' => '',
'bookstack_verify_ssl' => '1', 'bookstack_verify_ssl' => '1',
'bookstack_show_to_guests' => '0', 'bookstack_show_to_guests' => '0',
'bookstack_search_types' => 'both', 'bookstack_search_types' => 'book,page,chapter',
'bookstack_search_by' => 'both',
'bookstack_allowed_shelf_ids_creation' => '', 'bookstack_allowed_shelf_ids_creation' => '',
'bookstack_allowed_shelf_ids_ticket_view' => '', 'bookstack_allowed_shelf_ids_ticket_view' => '',
'snipeit_enabled' => '0',
'snipeit_base_url' => '',
'snipeit_api_token' => '',
'snipeit_verify_ssl' => '1',
'snipeit_client_can_select_asset' => '0',
'snipeit_client_asset_subcategory_ids' => '',
'snipeit_client_asset_category_ids' => '',
'snipeit_operator_view_requester_assets' => '1',
'snipeit_operator_search_inventory' => '1',
'ai_enabled' => '0',
'ai_base_url' => '',
'ai_api_key' => '',
'ai_model' => '',
'ai_verify_ssl' => '1',
'ai_triage_category_when_missing' => '0',
'ai_triage_subcategory_when_category_only' => '0',
'ai_triage_recheck_categorized' => '0',
'ai_triage_fix_subject' => '0',
'ai_triage_set_priority' => '0',
'ai_summary_enabled' => '0',
'ai_summary_regenerate_on_message' => '0',
'ai_summary_prompt' => 'Jesteś asystentem operatora helpdesku. Otrzymujesz temat, treść oraz historię '
.'wiadomości zgłoszenia. Podsumuj sprawę rzeczowo po polsku (2-3 zdania, czego dotyczy problem i na '
.'jakim jest etapie — np. czeka na odpowiedź klienta czy na działanie operatora) i zaproponuj krótką, '
.'konkretną kolejną akcję (jedno zdanie), np. "Poproś klienta o zrzut ekranu błędu" albo "Zamknij '
.'zgłoszenie — klient potwierdził rozwiązanie". Odpowiedz WYŁĄCZNIE obiektem JSON, bez innego tekstu: '
.'{"summary": "...", "suggested_action": "..."}. Jeśli nie da się ocenić kolejnego kroku, ustaw '
.'"suggested_action" na pusty string.',
'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>', 'email_footer' => '<p>Ta wiadomość została wygenerowana automatycznie przez system {firma} — prosimy na nią nie odpowiadać.</p>',
'accent_color' => '#7c6fd6', 'accent_color' => '#7c6fd6',
'login_notice_type' => 'info', 'login_notice_type' => 'info',
@@ -63,7 +101,7 @@ class Settings
.'</div>', .'</div>',
]; ];
protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret']; protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret', 'ai_api_key', 'snipeit_api_token'];
public static function get(string $key, ?string $default = null): ?string public static function get(string $key, ?string $default = null): ?string
{ {
@@ -194,6 +232,20 @@ class Settings
return $path ? Storage::disk('public')->url($path) : asset('branding/default-mark.svg'); return $path ? Storage::disk('public')->url($path) : asset('branding/default-mark.svg');
} }
/**
* Per-page <title>: the context first (so it's still visible once the
* browser truncates a long tab title, and so tabs are distinguishable
* at a glance) with the company name as a trailing, always-present
* anchor. Falls back to just the company name for pages with no more
* specific context (landing, login).
*/
public static function pageTitle(?string $context = null): string
{
$company = static::get('company_name');
return $context ? "{$context}{$company}" : $company;
}
/** /**
* The admin-configured timezone (IANA identifier, e.g. "Europe/Warsaw"), * The admin-configured timezone (IANA identifier, e.g. "Europe/Warsaw"),
* applied at runtime by AppServiceProvider so every date/time displayed * applied at runtime by AppServiceProvider so every date/time displayed
@@ -209,6 +261,24 @@ class Settings
: static::$defaults['timezone']; : static::$defaults['timezone'];
} }
/**
* Whether an admin-configurable "every N minutes" scheduled command
* (schedule_*_minutes, routes/console.php) is due to run this minute.
* Used inside a Schedule::command(...)->when(...) closure rather than
* building a cron string up front a closure is only evaluated when
* schedule:run actually processes due events, whereas eagerly reading
* Settings (which queries the DB) at routes/console.php's top level
* would run on every artisan boot (migrate, tinker, tests, ...), before
* the settings table necessarily even exists. Clamped to a minimum of 1
* so a blank/zero/negative stored value can never busy-loop.
*/
public static function dueEveryMinutes(string $key, int $default): bool
{
$minutes = max(1, (int) static::get($key, (string) $default));
return now()->minute % $minutes === 0;
}
/** /**
* Wraps a single e-mail template's rendered HTML body in the fixed * Wraps a single e-mail template's rendered HTML body in the fixed
* "box" layout company name, ticket content and footer so every * "box" layout company name, ticket content and footer so every
@@ -233,19 +303,46 @@ class Settings
]); ]);
} }
/**
* Whether the admin has pointed LDAP auth at Active Directory rather
* than an LLDAP/OpenLDAP-schema directory changes which LdapRecord
* model class backs logins (see ldapUserModelClass()) and which
* attribute a bare username search defaults to (see
* ldapUsernameAttribute()), since AD's objectClass chain and login
* attribute (sAMAccountName, not uid) differ from LLDAP's.
*/
public static function isActiveDirectory(): bool
{
return static::get('ldap_directory_type') === 'ad';
}
/**
* The LdapRecord model class the 'users' auth provider should use
* wired into config('auth.providers.users.model') at runtime by
* AppServiceProvider::applyLdapSettingsOverride(), same as the
* connection host/base DN below.
*/
public static function ldapUserModelClass(): string
{
return static::isActiveDirectory() ? AdUser::class : LldapUser::class;
}
/** /**
* Parses the admin-configurable LDAP user filter (e.g. "(uid={0})") to * Parses the admin-configurable LDAP user filter (e.g. "(uid={0})") to
* find which LDAP attribute logins are matched against. * find which LDAP attribute logins are matched against. Defaults to
* Active Directory's sAMAccountName when no filter is set and the
* directory type is AD uid is never populated on a stock AD user.
*/ */
public static function ldapUsernameAttribute(): string public static function ldapUsernameAttribute(): string
{ {
$filter = static::get('ldap_user_filter', '(uid={0})'); $default = static::isActiveDirectory() ? '(sAMAccountName={0})' : '(uid={0})';
$filter = static::get('ldap_user_filter') ?: $default;
if (preg_match('/\(([a-zA-Z0-9-]+)=\{0\}\)/', (string) $filter, $matches)) { if (preg_match('/\(([a-zA-Z0-9-]+)=\{0\}\)/', (string) $filter, $matches)) {
return $matches[1]; return $matches[1];
} }
return 'uid'; return static::isActiveDirectory() ? 'sAMAccountName' : 'uid';
} }
/** /**

View File

@@ -86,6 +86,29 @@ return [
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
// Dedicated, always-verbose channel for the scheduled AI ticket
// automation (ai:run-ticket-automation — triage + summaries) — same
// rationale as 'imap' below: full visibility into what the AI
// integration did on every run without depending on LOG_LEVEL.
'ai' => [
'driver' => 'daily',
'path' => storage_path('logs/ai.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
// Dedicated channel for the one-off Hesk import command (hesk:import)
// — console output alone is lost once the terminal is closed, so
// failures/summary go here too.
'hesk_import' => [
'driver' => 'daily',
'path' => storage_path('logs/hesk-import.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'slack' => [ 'slack' => [
'driver' => 'slack', 'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'), 'url' => env('LOG_SLACK_WEBHOOK_URL'),

View File

@@ -5,7 +5,6 @@ namespace Database\Factories;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/** /**
* @extends Factory<User> * @extends Factory<User>
@@ -27,20 +26,8 @@ class UserFactory extends Factory
return [ return [
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'roles' => ['client'], 'roles' => ['client'],
'remember_token' => Str::random(10),
]; ];
} }
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
} }

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* ai_triaged_at marks that the AI auto-triage pass has run for this
* ticket (regardless of whether it changed anything) never reset, so
* the scheduled command's query is just "tickets where this is null".
*
* ai_summary/ai_suggested_action/ai_summary_generated_at cache the AI
* ticket summary shown to operators; generated_at lets the summary
* command cheaply tell whether a ticket's summary is stale relative to
* its latest message, without re-summarizing every ticket every run.
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->timestamp('ai_triaged_at')->nullable()->after('source');
$table->text('ai_summary')->nullable()->after('ai_triaged_at');
$table->text('ai_suggested_action')->nullable()->after('ai_summary');
$table->timestamp('ai_summary_generated_at')->nullable()->after('ai_suggested_action');
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']);
});
}
};

View File

@@ -0,0 +1,27 @@
<?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('subcategories', function (Blueprint $table) {
$table->unsignedInteger('sort_order')->default(0)->after('default_priority_key');
});
foreach (DB::table('subcategories')->orderBy('category_id')->orderBy('id')->get() as $position => $sub) {
DB::table('subcategories')->where('id', $sub->id)->update(['sort_order' => $position]);
}
}
public function down(): void
{
Schema::table('subcategories', function (Blueprint $table) {
$table->dropColumn('sort_order');
});
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* snipeit_asset_name is a cached label (asset tag + name/model) captured
* at link time kept alongside the id so the ticket list/header still
* shows something meaningful if Snipe-IT is unreachable or the asset was
* later deleted there, without depending on a live API call.
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedInteger('snipeit_asset_id')->nullable()->after('ai_summary_generated_at');
$table->string('snipeit_asset_name')->nullable()->after('snipeit_asset_id');
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn(['snipeit_asset_id', 'snipeit_asset_name']);
});
}
};

View File

@@ -0,0 +1,29 @@
<?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_views', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Microsecond precision (not the plain-timestamp default) so two
// views landing in the same second — plausible with fast repeat
// clicks, not just test speed — still order correctly instead of
// tying and falling back to row id.
$table->timestamp('viewed_at', 6);
$table->unique(['ticket_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('ticket_views');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Ties an imported ticket back to its source Hesk ticket id (source is
* otherwise just the generic string 'hesk_import', shared by every
* imported row). Nullable only ever set by hesk:import and unique so
* a repeat INSERT for the same Hesk ticket (e.g. the resume-state JSON
* file was lost or desynced from a crash between commit and state save)
* fails loudly at the DB level instead of silently duplicating the ticket.
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedInteger('hesk_ticket_id')->nullable()->unique()->after('source');
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn('hesk_ticket_id');
});
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Three columns confirmed dead against live production data (not just
* code checked actual row counts before writing this):
*
* - users.remember_token: 0 non-null rows. No "remember me" checkbox in
* the login form, Auth::attempt() never passes $remember.
* - users.email_verified_at: 0 non-null rows. MustVerifyEmail was never
* implemented on the User model (this app authenticates via LDAP +
* local password fallback, not e-mail verification).
* - email_templates.trigger_label: fully populated (10/10 rows) but
* write-only the "Szablony e-mail" admin tab actually renders
* notification_settings.trigger_label, a different table that
* happens to share the column name.
*
* users.domain was also on this list initially grep found no app-level
* read/write, but the test suite caught what grep couldn't: LdapRecord's
* own Import\Synchronizer (vendor/directorytree/ldaprecord-laravel)
* force-fills it on every LDAP sync, bypassing $fillable entirely. Left
* alone; dropping it would break LDAP login.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['remember_token', 'email_verified_at']);
});
Schema::table('email_templates', function (Blueprint $table) {
$table->dropColumn('trigger_label');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->rememberToken();
$table->timestamp('email_verified_at')->nullable();
});
Schema::table('email_templates', function (Blueprint $table) {
$table->string('trigger_label')->default('');
});
}
};

View File

@@ -0,0 +1,77 @@
<?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
{
/**
* Queryable counterpart to tickets.custom_fields (a freeform JSON blob)
* mirrors the user_fields/user_field_values pattern, so reporting can
* filter/join on "tickets where custom field X = Y" without scanning
* JSON. The JSON column stays the source of truth for reads/writes (see
* Ticket::syncFieldValues(), called on every save); this table is kept
* in sync automatically and exists purely for querying.
*/
public function up(): void
{
Schema::create('ticket_field_values', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('custom_field_id')->constrained()->cascadeOnDelete();
$table->text('value')->nullable();
$table->timestamps();
$table->unique(['ticket_id', 'custom_field_id']);
});
$this->backfill();
}
/**
* One-time backfill from the existing custom_fields JSON blob, so
* reporting against ticket_field_values also covers tickets created
* before this table existed. Skips any field id no longer present in
* custom_fields (a deleted field definition would otherwise violate the
* FK constraint) and any blank/null value, matching
* Ticket::syncFieldValues()'s own filtering.
*/
private function backfill(): void
{
$validFieldIds = DB::table('custom_fields')->pluck('id')->all();
$now = now();
DB::table('tickets')->whereNotNull('custom_fields')->orderBy('id')
->chunkById(500, function ($tickets) use ($validFieldIds, $now) {
$rows = [];
foreach ($tickets as $ticket) {
$values = json_decode($ticket->custom_fields, true) ?? [];
foreach ($values as $fieldId => $value) {
if ($value === '' || $value === null || ! in_array((int) $fieldId, $validFieldIds, true)) {
continue;
}
$rows[] = [
'ticket_id' => $ticket->id,
'custom_field_id' => (int) $fieldId,
'value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value,
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($rows) {
DB::table('ticket_field_values')->insert($rows);
}
});
}
public function down(): void
{
Schema::dropIfExists('ticket_field_values');
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Each of these pivot tables has a composite primary key covering both
* FK columns, in a fixed order e.g. team_subcategory's PK is
* (team_id, subcategory_id). Under the leftmost-prefix rule that index
* only serves lookups by the first column; a query keyed on the second
* column alone (e.g. "which teams can see subcategory X") has no index
* to use. Adds the missing reverse index to each.
*/
public function up(): void
{
Schema::table('team_subcategory', function (Blueprint $table) {
$table->index('subcategory_id');
});
Schema::table('role_user', function (Blueprint $table) {
$table->index('user_id');
});
Schema::table('team_user', function (Blueprint $table) {
$table->index('user_id');
});
Schema::table('custom_field_subcategory', function (Blueprint $table) {
$table->index('subcategory_id');
});
}
public function down(): void
{
Schema::table('team_subcategory', function (Blueprint $table) {
$table->dropIndex(['subcategory_id']);
});
Schema::table('role_user', function (Blueprint $table) {
$table->dropIndex(['user_id']);
});
Schema::table('team_user', function (Blueprint $table) {
$table->dropIndex(['user_id']);
});
Schema::table('custom_field_subcategory', function (Blueprint $table) {
$table->dropIndex(['subcategory_id']);
});
}
};

View File

@@ -0,0 +1,74 @@
<?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
{
/**
* Moves the four AI columns off `tickets` (added by
* 2026_07_24_000156_add_ai_triage_and_summary_to_tickets.php) into their
* own one-to-one table most tickets never get an AI pass at all, so
* this keeps the wide, mostly-null block off the main row. Ticket's
* getAttribute()/setAttribute() overrides keep every existing
* `$ticket->ai_summary` etc. read/write working unchanged against this
* table (see Ticket::AI_SUMMARY_FIELD_MAP).
*/
public function up(): void
{
Schema::create('ticket_ai_summaries', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->unique()->constrained()->cascadeOnDelete();
$table->timestamp('triaged_at')->nullable();
$table->text('summary')->nullable();
$table->text('suggested_action')->nullable();
$table->timestamp('summary_generated_at')->nullable();
$table->timestamps();
});
$this->backfill();
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn(['ai_triaged_at', 'ai_summary', 'ai_suggested_action', 'ai_summary_generated_at']);
});
}
private function backfill(): void
{
$now = now();
DB::table('tickets')
->where(function ($q) {
$q->whereNotNull('ai_triaged_at')
->orWhereNotNull('ai_summary')
->orWhereNotNull('ai_suggested_action')
->orWhereNotNull('ai_summary_generated_at');
})
->orderBy('id')
->chunkById(500, function ($tickets) use ($now) {
DB::table('ticket_ai_summaries')->insert($tickets->map(fn ($t) => [
'ticket_id' => $t->id,
'triaged_at' => $t->ai_triaged_at,
'summary' => $t->ai_summary,
'suggested_action' => $t->ai_suggested_action,
'summary_generated_at' => $t->ai_summary_generated_at,
'created_at' => $now,
'updated_at' => $now,
])->all());
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->timestamp('ai_triaged_at')->nullable()->after('source');
$table->text('ai_summary')->nullable()->after('ai_triaged_at');
$table->text('ai_suggested_action')->nullable()->after('ai_summary');
$table->timestamp('ai_summary_generated_at')->nullable()->after('ai_suggested_action');
});
Schema::dropIfExists('ticket_ai_summaries');
}
};

View File

@@ -0,0 +1,64 @@
<?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
{
/**
* Moves the two Snipe-IT columns off `tickets` (added by
* 2026_07_27_000158_add_snipeit_asset_to_tickets_table.php) into their
* own one-to-one table only a small subset of tickets ever link an
* asset. Ticket's getAttribute()/setAttribute() overrides keep every
* existing `$ticket->snipeit_asset_id`/`snipeit_asset_name` read/write
* working unchanged against this table (see Ticket::SNIPEIT_FIELD_MAP).
*/
public function up(): void
{
Schema::create('ticket_snipeit_assets', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->unique()->constrained()->cascadeOnDelete();
$table->unsignedInteger('asset_id')->nullable();
$table->string('asset_name')->nullable();
$table->timestamps();
});
$this->backfill();
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn(['snipeit_asset_id', 'snipeit_asset_name']);
});
}
private function backfill(): void
{
$now = now();
DB::table('tickets')
->where(function ($q) {
$q->whereNotNull('snipeit_asset_id')->orWhereNotNull('snipeit_asset_name');
})
->orderBy('id')
->chunkById(500, function ($tickets) use ($now) {
DB::table('ticket_snipeit_assets')->insert($tickets->map(fn ($t) => [
'ticket_id' => $t->id,
'asset_id' => $t->snipeit_asset_id,
'asset_name' => $t->snipeit_asset_name,
'created_at' => $now,
'updated_at' => $now,
])->all());
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedInteger('snipeit_asset_id')->nullable()->after('source');
$table->string('snipeit_asset_name')->nullable()->after('snipeit_asset_id');
});
Schema::dropIfExists('ticket_snipeit_assets');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Remembers which operator queue table columns a user has shown/hidden
* (App\Livewire\Operator\Queue::$visibleColumns), independent of the
* named/default SavedQueueView mechanism a plain column toggle
* shouldn't require the operator to explicitly "save a view" for it to
* stick between visits. Null means "no preference saved yet, use the
* component's built-in default".
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->json('operator_queue_columns')->nullable();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('operator_queue_columns');
});
}
};

View File

@@ -296,52 +296,52 @@ class DatabaseSeeder extends Seeder
$templates = [ $templates = [
'tpl-new' => [ 'tpl-new' => [
'name' => 'Nowe zgłoszenie przyjęte', 'trigger_label' => 'Zgłoszenie utworzone', 'name' => 'Nowe zgłoszenie przyjęte',
'subject' => 'Otrzymaliśmy Twoje zgłoszenie #{numer}', 'subject' => 'Otrzymaliśmy Twoje zgłoszenie #{numer}',
'body' => '<p>Cześć {imie},</p><p>Otrzymaliśmy Twoje zgłoszenie „{temat}”. Nasz zespół zajmie się nim najszybciej jak to możliwe.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Otrzymaliśmy Twoje zgłoszenie „{temat}”. Nasz zespół zajmie się nim najszybciej jak to możliwe.</p>'.$link.$footer,
], ],
'tpl-status' => [ 'tpl-status' => [
'name' => 'Zmiana statusu', 'trigger_label' => 'Status zgłoszenia zmieniony', 'name' => 'Zmiana statusu',
'subject' => 'Aktualizacja zgłoszenia #{numer}', 'subject' => 'Aktualizacja zgłoszenia #{numer}',
'body' => '<p>Cześć {imie},</p><p>Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Status Twojego zgłoszenia „{temat}” zmienił się na: {status}.</p>'.$link.$footer,
], ],
'tpl-category' => [ 'tpl-category' => [
'name' => 'Zmiana kategorii', 'trigger_label' => 'Kategoria zgłoszenia zmieniona', 'name' => 'Zmiana kategorii',
'subject' => 'Zmieniono kategorię zgłoszenia #{numer}', 'subject' => 'Zmieniono kategorię zgłoszenia #{numer}',
'body' => '<p>Cześć {imie},</p><p>Kategoria Twojego zgłoszenia „{temat}” została zmieniona na: {kategoria}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Kategoria Twojego zgłoszenia „{temat}” została zmieniona na: {kategoria}.</p>'.$link.$footer,
], ],
'tpl-assignee' => [ 'tpl-assignee' => [
'name' => 'Zmiana przypisanego operatora', 'trigger_label' => 'Przypisany operator zmieniony', 'name' => 'Zmiana przypisanego operatora',
'subject' => 'Zmieniono osobę obsługującą zgłoszenie #{numer}', 'subject' => 'Zmieniono osobę obsługującą zgłoszenie #{numer}',
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmie się teraz: {operator}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmie się teraz: {operator}.</p>'.$link.$footer,
], ],
'tpl-priority' => [ 'tpl-priority' => [
'name' => 'Zmiana priorytetu', 'trigger_label' => 'Priorytet zgłoszenia zmieniony', 'name' => 'Zmiana priorytetu',
'subject' => 'Zmieniono priorytet zgłoszenia #{numer}', 'subject' => 'Zmieniono priorytet zgłoszenia #{numer}',
'body' => '<p>Cześć {imie},</p><p>Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Priorytet Twojego zgłoszenia „{temat}” zmienił się na: {priorytet}.</p>'.$link.$footer,
], ],
'tpl-team' => [ 'tpl-team' => [
'name' => 'Zmiana zespołu', 'trigger_label' => 'Zespół obsługujący zmieniony', 'name' => 'Zmiana zespołu',
'subject' => 'Zmieniono zespół obsługujący zgłoszenie #{numer}', 'subject' => 'Zmieniono zespół obsługujący zgłoszenie #{numer}',
'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Twoim zgłoszeniem „{temat}” zajmuje się teraz zespół: {zespol}.</p>'.$link.$footer,
], ],
'tpl-closed' => [ 'tpl-closed' => [
'name' => 'Zgłoszenie zamknięte', 'trigger_label' => 'Status = Zamknięte', 'name' => 'Zgłoszenie zamknięte',
'subject' => 'Zgłoszenie #{numer} zostało zamknięte', 'subject' => 'Zgłoszenie #{numer} zostało zamknięte',
'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer, 'body' => '<p>Cześć {imie},</p><p>Twoje zgłoszenie „{temat}” zostało zamknięte. Jeśli temat nie został rozwiązany, odpowiedz na tego maila lub zgłoś sprawę ponownie.</p>'.$link.$csatLink.$footer,
], ],
'tpl-reply' => [ 'tpl-reply' => [
'name' => 'Nowa odpowiedź operatora', 'trigger_label' => 'Operator odpowiedział', 'name' => 'Nowa odpowiedź operatora',
'subject' => 'Nowa odpowiedź w zgłoszeniu #{numer}', 'subject' => 'Nowa odpowiedź w zgłoszeniu #{numer}',
'body' => '<p>Cześć {imie},</p><p>Otrzymałeś/aś nową odpowiedź w zgłoszeniu „{temat}”.</p>'.$link.$footer, 'body' => '<p>Cześć {imie},</p><p>Otrzymałeś/aś nową odpowiedź w zgłoszeniu „{temat}”.</p>'.$link.$footer,
], ],
'tpl-sla-breach' => [ 'tpl-sla-breach' => [
'name' => 'Przekroczenie SLA', 'trigger_label' => 'SLA przekroczone — operator', 'name' => 'Przekroczenie SLA',
'subject' => 'Przekroczono SLA zgłoszenia #{numer}', 'subject' => 'Przekroczono SLA zgłoszenia #{numer}',
'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer, 'body' => '<p>Cześć {operator},</p><p>Zgłoszenie „{temat}” (#{numer}) przekroczyło ustalony czas rozwiązania SLA.</p>'.$link.$footer,
], ],
'tpl-team-new-ticket' => [ 'tpl-team-new-ticket' => [
'name' => 'Nowe zgłoszenie w zespole', 'trigger_label' => 'Nowe zgłoszenie w zespole — operator', 'name' => 'Nowe zgłoszenie w zespole',
'subject' => 'Nowe zgłoszenie w Twoim zespole (#{numer})', '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, 'body' => '<p>Cześć,</p><p>Nowe zgłoszenie „{temat}” (#{numer}, kategoria: {kategoria}) trafiło do zespołu {zespol}.</p>'.$link.$footer,
], ],
@@ -352,7 +352,6 @@ class DatabaseSeeder extends Seeder
foreach ($templates as $key => $tpl) { foreach ($templates as $key => $tpl) {
$ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [ $ids[$key] = EmailTemplate::query()->firstOrCreate(['key' => $key], [
'name' => $tpl['name'], 'name' => $tpl['name'],
'trigger_label' => $tpl['trigger_label'],
'subject' => $tpl['subject'], 'subject' => $tpl['subject'],
'body' => $tpl['body'], 'body' => $tpl['body'],
])->id; ])->id;

View File

@@ -0,0 +1,8 @@
<?php
return [
'previous' => '&laquo; Poprzednia',
'next' => 'Następna &raquo;',
];

View File

@@ -55,11 +55,6 @@
[data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); } [data-theme='light'] .tag-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); }
[data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); } [data-theme='light'] .dialog-backdrop { background: color-mix(in srgb, var(--color-neutral-900) 35%, transparent); }
[data-theme='light'] .login-notice-info { background: color-mix(in srgb, var(--color-accent) 14%, white); color: var(--color-accent-700); }
[data-theme='light'] .login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, white); color: color-mix(in srgb, var(--color-warning) 80%, black); }
[data-theme='light'] .login-notice-success { background: color-mix(in srgb, var(--color-success) 18%, white); color: color-mix(in srgb, var(--color-success) 75%, black); }
[data-theme='light'] .login-notice-danger { background: color-mix(in srgb, var(--color-danger) 16%, white); color: color-mix(in srgb, var(--color-danger) 80%, black); }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
@@ -211,6 +206,97 @@ body {
.seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); } .seg-opt:has(input:checked) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); color: var(--color-accent); }
.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; } .seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
.pagination-wrap { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
.pagination-summary { font-size: 12.5px; color: color-mix(in srgb, var(--color-text) 55%, transparent); }
.pagination-links { display: inline-flex; gap: 4px; flex-wrap: wrap; }
.pagination-links a,
.pagination-links button,
.pagination-links span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0;
font-size: 12.5px;
font-weight: 500;
font-family: inherit;
border-radius: 7px;
border: none;
background: var(--color-surface);
color: var(--color-text);
text-decoration: none;
cursor: pointer;
}
.pagination-links > span { padding: 0; }
.pagination-links a:hover,
.pagination-links button:hover { background: color-mix(in srgb, var(--color-text) 6%, transparent); }
.pagination-links span[aria-disabled='true'] { opacity: 0.4; cursor: not-allowed; }
.pagination-links span.pagination-dots { background: transparent; }
.pagination-links button:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination-links [aria-current='page'] span {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.panel-switch {
display: inline-flex;
padding: 3px;
border: 1px solid var(--color-divider);
border-radius: 999px;
background: color-mix(in srgb, var(--color-text) 4%, transparent);
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.panel-switch::-webkit-scrollbar { display: none; }
.panel-switch-indicator {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 999px;
background: var(--color-accent);
transition: transform 0.2s ease;
z-index: 0;
}
.nav .panel-switch-option {
position: relative;
z-index: 1;
flex: 1 1 0;
min-width: 128px;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
font-size: 12.5px;
font-weight: 500;
color: var(--color-text);
text-decoration: none;
white-space: nowrap;
border-radius: 999px;
}
.nav .panel-switch-option:hover { color: #fff; text-decoration: none; }
.nav .panel-switch-option-active, .nav .panel-switch-option-active:hover { color: #fff; }
/* Preview the destination before the click actually navigates there:
the indicator follows whichever option is under the cursor (falling
back to the real active position, set inline per-request by
panel-switcher.blade.php, the moment the pointer leaves). Position is
purely by hovered index independent of how many roles/options
exist so these three rules cover the max of three areas regardless
of which subset a given user has. */
.panel-switch:has(> .panel-switch-option:nth-of-type(1):hover) .panel-switch-indicator { transform: translateX(0%) !important; }
.panel-switch:has(> .panel-switch-option:nth-of-type(2):hover) .panel-switch-indicator { transform: translateX(100%) !important; }
.panel-switch:has(> .panel-switch-option:nth-of-type(3):hover) .panel-switch-indicator { transform: translateX(200%) !important; }
/* The real active option's white text is only correct while the
indicator sits under it once hover has pulled the indicator away to
a neighboring option, drop it back to normal text color so it doesn't
read as near-invisible white-on-track. */
.panel-switch:hover .panel-switch-option-active:not(:hover) { color: var(--color-text); }
.theme-toggle-option { .theme-toggle-option {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -234,9 +320,18 @@ body {
and Tailwind's @layer'd rules always lose to unlayered ones including and Tailwind's @layer'd rules always lose to unlayered ones including
Quill's CDN stylesheet regardless of source order, so anything meant to Quill's CDN stylesheet regardless of source order, so anything meant to
override .ql-editor here (like the padding reset) has to be unlayered too. */ override .ql-editor here (like the padding reset) has to be unlayered too. */
/* Fixed (not [data-theme]-dependent) colors on purpose this box holds
admin-authored Quill HTML with its own inline text colors (including
plain "white"), so the container needs one stable dark background in
both app themes rather than a tint derived from --color-accent, which
changed hue/lightness between light and dark mode and could swallow
light-colored text the admin picked. */
.login-notice { .login-notice {
padding: 0 14px; padding: 14px;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: #17262d;
color: #eef3f4;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
height: auto; height: auto;
@@ -245,10 +340,10 @@ body {
} }
.login-notice > *:first-child { margin-top: 0; } .login-notice > *:first-child { margin-top: 0; }
.login-notice > *:last-child { margin-bottom: 0; } .login-notice > *:last-child { margin-bottom: 0; }
.login-notice-info { background: color-mix(in srgb, var(--color-accent) 20%, transparent); color: var(--color-accent); } .login-notice hr { border-color: rgba(255, 255, 255, 0.14); }
.login-notice-warning { background: color-mix(in srgb, var(--color-warning) 20%, transparent); color: var(--color-warning); } .login-notice-warning { border-color: color-mix(in srgb, var(--color-warning) 45%, rgba(255, 255, 255, 0.14)); }
.login-notice-success { background: color-mix(in srgb, var(--color-success) 20%, transparent); color: var(--color-success); } .login-notice-success { border-color: color-mix(in srgb, var(--color-success) 45%, rgba(255, 255, 255, 0.14)); }
.login-notice-danger { background: color-mix(in srgb, var(--color-danger) 20%, transparent); color: var(--color-danger); } .login-notice-danger { border-color: color-mix(in srgb, var(--color-danger) 45%, rgba(255, 255, 255, 0.14)); }
/* ---- Responsive layout (phones/tablets) ---- */ /* ---- Responsive layout (phones/tablets) ---- */
@@ -341,8 +436,27 @@ body {
@media (max-width: 640px) { @media (max-width: 640px) {
.page-pad { padding: 16px !important; } .page-pad { padding: 16px !important; }
.nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; } .nav { padding-left: 14px !important; padding-right: 14px !important; gap: 10px; flex-wrap: wrap; }
.nav-panel-label { display: none; } .pagination-links { width: 100%; justify-content: center; }
/* At this width the switcher's own centered slot collides with the
brand text and the icon buttons sharing the row (nothing left to
shrink once labels are already at their minimum width) same
"restructure instead of cram" fix as the mobile table pattern above:
drop it to its own full-width row below instead of fighting for
space with everything else in the bar. */
.panel-switch {
position: relative !important;
left: auto !important;
top: auto !important;
transform: none !important;
order: 10;
width: 100%;
max-width: 100%;
justify-content: center;
margin-top: 10px;
}
.nav .panel-switch-option { padding: 10px; font-size: 11.5px; min-width: 92px; }
/* Theme/notifications/profile dropdowns are anchored (position:absolute) /* Theme/notifications/profile dropdowns are anchored (position:absolute)
to their own small trigger button by default, which overflows off the to their own small trigger button by default, which overflows off the

View File

@@ -0,0 +1,50 @@
@props(['area' => null])
@php
// $area is passed explicitly by each page (e.g. <x-topbar area="operator" />)
// rather than inferred from request()->routeIs() here: this component is
// rendered as part of each top-level Livewire page's own template, so it
// re-renders on every wire:click/wire:model round-trip on that page (tab
// switches, pagination, search, ...) — and during that AJAX request,
// request()->route() is Livewire's own update route, not client./operator./
// admin.*, which silently broke the highlight on every in-page interaction
// when this used to key off the ambient request instead of an explicit prop.
$user = auth()->user();
$areas = [];
if ($user) {
if ($user->isClient()) {
$areas[] = ['label' => 'Klient', 'url' => route('client.dashboard'), 'active' => $area === 'client'];
}
if ($user->isOperator()) {
$areas[] = ['label' => 'Operator', 'url' => route('operator.queue'), 'active' => $area === 'operator'];
}
if ($user->isAdmin()) {
$areas[] = ['label' => 'Administrator', 'url' => route('admin.panel'), 'active' => $area === 'admin'];
}
}
$activeIndex = collect($areas)->search(fn ($area) => $area['active']);
@endphp
@if (count($areas))
<div
class="panel-switch"
style="position:absolute;left:50%;top:50%;transform:translate(-50%, -50%)"
>
@if ($activeIndex !== false)
<span
class="panel-switch-indicator"
style="width:calc(100% / {{ count($areas) }});transform:translateX({{ $activeIndex * 100 }}%)"
></span>
@endif
@foreach ($areas as $item)
<a
href="{{ $item['url'] }}"
wire:navigate
class="panel-switch-option {{ $item['active'] ? 'panel-switch-option-active' : '' }}"
>{{ $item['label'] }}</a>
@endforeach
</div>
@endif

View File

@@ -1,18 +1,5 @@
@php @php
$user = auth()->user(); $user = auth()->user();
$areas = [];
if ($user) {
if ($user->isClient()) {
$areas[] = ['label' => 'Panel Klienta', 'url' => route('client.dashboard'), 'active' => request()->routeIs('client.*')];
}
if ($user->isOperator()) {
$areas[] = ['label' => 'Panel Operatora', 'url' => route('operator.queue'), 'active' => request()->routeIs('operator.*')];
}
if ($user->isAdmin()) {
$areas[] = ['label' => 'Panel Administratora', 'url' => route('admin.panel'), 'active' => request()->routeIs('admin.*')];
}
}
@endphp @endphp
@if ($user) @if ($user)
@@ -26,23 +13,27 @@
x-show="open" x-show="open"
x-cloak x-cloak
class="nav-dropdown" class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:200px;overflow:hidden;z-index:30" style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:30"
> >
@foreach ($areas as $area) <div style="display:flex;flex-direction:column;gap:6px;padding:12px">
<a <span style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $user->name }}</span>
href="{{ $area['url'] }}" <span style="font-size:11.5px;color:color-mix(in srgb, var(--color-text) 60%, transparent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $user->email }}</span>
wire:navigate <div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:2px">
@click="open = false" @if ($user->isClient())
class="theme-toggle-option" <span class="tag tag-neutral">Klient</span>
style="text-decoration:none;color:{{ $area['active'] ? 'var(--color-accent)' : 'var(--color-text)' }};font-size:12.5px" @endif
>{{ $area['label'] }}</a> @if ($user->isOperator())
@endforeach <span class="tag tag-accent-2">Operator</span>
@endif
@if ($user->isAdmin())
<span class="tag tag-accent">Administrator</span>
@endif
</div>
</div>
@if (count($areas)) <div style="border-top:1px solid var(--color-divider)"></div>
<div style="border-top:1px solid var(--color-divider)"></div>
@endif
@if ($user && ($user->isOperator() || $user->isAdmin())) @if ($user->isOperator() || $user->isAdmin())
<a <a
href="{{ route('settings.notifications') }}" href="{{ route('settings.notifications') }}"
wire:navigate wire:navigate

View File

@@ -0,0 +1,64 @@
@props([
'assets',
'variant' => 'banner',
'title' => 'Twój sprzęt (inwentarz)',
'selectable' => false,
'selectAction' => 'selectSnipeitAsset',
'selectedId' => null,
// false when embedded inside a caller-provided card (e.g. the operator's
// "Przeszukaj inwentarz" search box + results in one container) — skips
// this component's own wrapping card/title so the two don't nest.
'card' => true,
])
@php
$isSidebar = $variant === 'sidebar';
@endphp
@if (count($assets))
@if ($card)
<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">devices</span>
{{ $title }}
</div>
@endif
@endif
<div style="display:flex;flex-direction:column;gap:2px">
@foreach ($assets as $a)
@php $isSelected = $selectedId === $a['id']; @endphp
<div style="display:flex;gap:8px;align-items:center;padding:8px;border-radius:6px;{{ $isSelected ? 'background:color-mix(in srgb, var(--color-accent) 10%, transparent)' : '' }}">
<a
href="{{ $a['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)">devices</span>
<span style="min-width:0;flex:1">
<span style="display:block;font-size:13px;font-weight:500;{{ $isSelected ? 'color:var(--color-accent)' : '' }}">{{ $a['label'] }}</span>
@if (! empty($a['category']))
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent);margin-top:1px">{{ $a['category'] }}</span>
@endif
</span>
</a>
@if ($selectable)
@if ($isSelected)
<span style="flex:none;display:flex;align-items:center;gap:4px;font-size:11px;color:var(--color-accent);white-space:nowrap">
<span class="material-symbols-outlined" style="font-size:16px">check_circle</span>
Powiązano
</span>
@else
<button type="button" class="btn btn-secondary" style="flex:none;font-size:11px;padding:4px 8px;white-space:nowrap" wire:click="{{ $selectAction }}({{ $a['id'] }})">Powiąż</button>
@endif
@endif
</div>
@endforeach
</div>
@if ($card)
</div>
@endif
@endif

View File

@@ -16,10 +16,10 @@
style="position:relative;display:inline-block" style="position:relative;display:inline-block"
> >
<button type="button" class="btn btn-secondary btn-icon" @click="open = !open"> <button type="button" class="btn btn-secondary btn-icon" @click="open = !open">
<span class="material-symbols-outlined" x-text="icon()"></span> <span class="material-symbols-outlined" x-text="typeof icon === 'function' ? icon() : ''"></span>
</button> </button>
<div <div
x-show="open" x-show="typeof open !== 'undefined' && open"
x-cloak x-cloak
class="nav-dropdown" class="nav-dropdown"
style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20" style="position:absolute;top:100%;right:0;margin-top:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:150px;overflow:hidden;z-index:20"

View File

@@ -1,18 +1,9 @@
@php @props(['area' => null])
$panelLabel = match (true) {
request()->routeIs('client.*') => 'Panel Klienta',
request()->routeIs('operator.*') => 'Panel Operatora',
request()->routeIs('admin.*') => 'Panel Administratora',
default => null,
};
@endphp
<div class="nav" style="position:relative;padding:16px 28px;border-bottom:1px solid var(--color-divider)"> <div class="nav" style="position:relative;padding:16px 28px;border-bottom:1px solid var(--color-divider)">
<span class="nav-brand">{{ \App\Support\Settings::get('company_name') }}</span> <span class="nav-brand">{{ \App\Support\Settings::get('company_name') }}</span>
@if ($panelLabel) <x-panel-switcher :area="$area" />
<span class="nav-panel-label" style="position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);font-weight:500;font-size:13.5px;white-space:nowrap">{{ $panelLabel }}</span>
@endif
<x-theme-toggle /> <x-theme-toggle />
@@ -20,6 +11,7 @@
@auth @auth
<livewire:notification-bell /> <livewire:notification-bell />
<livewire:global-search />
@endauth @endauth
<x-profile-menu /> <x-profile-menu />

View File

@@ -0,0 +1,69 @@
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<h3 style="margin:0">Logi</h3>
<label style="display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:400;color:var(--color-text)">
<input type="checkbox" wire:model.live="autoRefresh" style="position:static;opacity:1;width:auto;height:auto">
Odświeżaj automatycznie (5 s)
</label>
</div>
<p class="text-muted" style="font-size:12.5px;margin:0 0 14px">
Podgląd plików z <code>storage/logs/</code> co robi aplikacja i zaplanowane integracje (IMAP, automatyzacje, SLA). Widok tylko do odczytu, pokazuje ostatni fragment pliku.
</p>
@if ($autoRefresh)
<div wire:poll.5s="$refresh" style="display:none"></div>
@endif
@if ($this->files->isEmpty())
<p class="text-muted" style="font-size:13px">Brak plików logów w <code>storage/logs/</code>.</p>
@else
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px">
@foreach ($this->files as $file)
<button type="button" wire:click="selectFile('{{ $file['name'] }}')"
style="display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:6px 12px;border-radius:8px;cursor:pointer;font-size:12.5px;text-align:left;border:1px solid {{ $selectedFile === $file['name'] ? 'var(--color-accent)' : 'var(--color-divider)' }};background:{{ $selectedFile === $file['name'] ? 'color-mix(in srgb, var(--color-accent) 14%, transparent)' : 'transparent' }};color:{{ $selectedFile === $file['name'] ? 'var(--color-accent)' : 'var(--color-text)' }}">
<span style="font-weight:500">{{ $file['name'] }}</span>
<span class="text-muted" style="font-size:11px">{{ $this->formatBytes($file['size']) }} &middot; {{ $file['modified']->diffForHumans() }}</span>
</button>
@endforeach
</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px">
<select class="input" wire:model.live="levelFilter" style="width:auto">
<option value="">Wszystkie poziomy</option>
@foreach (\App\Livewire\Admin\Logs::availableLevels() as $level)
<option value="{{ $level }}">{{ $level }}</option>
@endforeach
</select>
<input class="input" type="search" wire:model.live.debounce.400ms="search" placeholder="Szukaj w treści..." style="width:220px">
<select class="input" wire:model.live="limit" style="width:auto">
<option value="100">ostatnie 100</option>
<option value="300">ostatnie 300</option>
<option value="1000">ostatnie 1000</option>
<option value="3000">ostatnie 3000</option>
</select>
<button type="button" class="btn btn-ghost" wire:click="$refresh">
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:-3px">refresh</span>
Odśwież
</button>
<span class="text-muted" style="font-size:12px;margin-left:auto">{{ $this->entries->count() }} wpisów</span>
</div>
<div wire:key="log-box-{{ $selectedFile }}-{{ $levelFilter }}-{{ $search }}-{{ $limit }}"
x-data x-init="$el.scrollTop = $el.scrollHeight"
style="height:65vh;overflow:auto;border:1px solid var(--color-divider);border-radius:8px;background:color-mix(in srgb, var(--color-text) 4%, var(--color-bg));padding:10px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5">
@forelse ($this->entries as $entry)
@php $badge = \App\Livewire\Admin\Logs::levelBadge($entry['level']); @endphp
<div style="display:flex;gap:8px;align-items:flex-start;padding:3px 0;border-bottom:1px solid color-mix(in srgb, var(--color-text) 6%, transparent)">
<span class="{{ $badge['class'] }}" style="flex:none;font-size:10px;padding:1px 6px;margin-top:2px;{{ $badge['style'] }}">{{ $entry['level'] ?? '?' }}</span>
<span style="white-space:pre-wrap;word-break:break-word;flex:1">{{ $entry['text'] }}</span>
</div>
@empty
<p class="text-muted" style="margin:0">Brak wpisów spełniających kryteria.</p>
@endforelse
</div>
@endif
</div>

View File

@@ -22,15 +22,21 @@ $tabGroups = [
['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'], ['key' => 'config', 'label' => 'Konfiguracja', 'icon' => 'settings'],
['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'], ['key' => 'integrations', 'label' => 'Integracje', 'icon' => 'hub'],
['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'], ['key' => 'api-keys', 'label' => 'Klucze API', 'icon' => 'vpn_key'],
['key' => 'logs', 'label' => 'Logi', 'icon' => 'terminal'],
['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'], ['key' => 'about', 'label' => 'O aplikacji', 'icon' => 'info'],
], ],
]; ];
@endphp @endphp
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="admin" />
<div class="app-split" style="flex:1;display:flex;min-height:0"> <div class="app-split" style="flex:1;display:flex;min-height:0">
<div class="side-rail" style="padding:16px 10px;gap:16px;overflow:auto;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))"> <div class="side-rail" style="padding:16px 10px;gap:16px;overflow:auto;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))">
<button type="button" @click="window.dispatchEvent(new CustomEvent('open-global-search'))" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;border:none;cursor:pointer;font-size:13.5px;text-align:left;white-space:nowrap;background:transparent;color:var(--color-text)">
<span class="material-symbols-outlined" style="font-size:18px">search</span>
<span>Szukaj</span>
</button>
@foreach ($tabGroups as $groupLabel => $items) @foreach ($tabGroups as $groupLabel => $items)
<div style="display:flex;flex-direction:column;gap:1px"> <div style="display:flex;flex-direction:column;gap:1px">
<div style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">{{ $groupLabel }}</div> <div style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">{{ $groupLabel }}</div>
@@ -85,7 +91,7 @@ $tabGroups = [
<table class="table" style="margin:0;border-top:none"> <table class="table" style="margin:0;border-top:none">
<thead><tr><th>Podkategoria</th><th></th></tr></thead> <thead><tr><th>Podkategoria</th><th></th></tr></thead>
<tbody> <tbody>
@foreach ($cat->subcategories as $sub) @foreach ($cat->subcategories as $i => $sub)
<tr> <tr>
<td> <td>
<div style="display:flex;flex-direction:column;gap:2px"> <div style="display:flex;flex-direction:column;gap:2px">
@@ -97,7 +103,9 @@ $tabGroups = [
</div> </div>
</td> </td>
<td> <td>
<div style="display:flex;gap:6px;justify-content:flex-end"> <div style="display:flex;gap:6px;justify-content:flex-end;align-items:center">
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryUp({{ $sub->id }})" @disabled($i === 0) style="padding:2px;width:24px;height:24px" title="Przesuń wyżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_upward</span></button>
<button class="btn btn-icon" type="button" wire:click="moveSubcategoryDown({{ $sub->id }})" @disabled($i === $cat->subcategories->count() - 1) style="padding:2px;width:24px;height:24px" title="Przesuń niżej"><span class="material-symbols-outlined" style="font-size:16px">arrow_downward</span></button>
<button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button> <button class="btn btn-ghost" type="button" wire:click="openSubcategoryEditForm({{ $sub->id }})">Edytuj</button>
<button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button> <button class="btn btn-ghost" type="button" wire:click="removeSubcategory({{ $sub->id }})">Usuń</button>
</div> </div>
@@ -613,9 +621,12 @@ $tabGroups = [
<h4 style="margin:0">Sesja i strefa czasowa</h4> <h4 style="margin:0">Sesja i strefa czasowa</h4>
<div class="field"><label>Czas wygaśnięcia sesji (minuty)</label><input class="input" type="number" wire:model="systemConfig.sessionLifetimeMinutes"></div> <div class="field"><label>Czas wygaśnięcia sesji (minuty)</label><input class="input" type="number" wire:model="systemConfig.sessionLifetimeMinutes"></div>
<div class="field" <div class="field"
wire:key="admin-timezone-clock"
x-data="{ x-data="{
tz: @js($systemConfig['timezone']), tz: @js($systemConfig['timezone']),
now: '', now: '',
tickHandle: null,
navigatingHandler: null,
tick() { tick() {
try { try {
this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date()); this.now = new Intl.DateTimeFormat('pl-PL', { timeZone: this.tz, dateStyle: 'medium', timeStyle: 'medium' }).format(new Date());
@@ -623,8 +634,13 @@ $tabGroups = [
this.now = '—'; this.now = '—';
} }
}, },
}" init() {
x-init="tick(); setInterval(() => tick(), 1000)"> this.tick();
this.tickHandle = setInterval(() => this.tick(), 1000);
this.navigatingHandler = () => clearInterval(this.tickHandle);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}">
<label>Strefa czasowa</label> <label>Strefa czasowa</label>
<select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()"> <select class="input" wire:model="systemConfig.timezone" x-on:change="tz = $event.target.value; tick()">
@foreach (\DateTimeZone::listIdentifiers() as $tzId) @foreach (\DateTimeZone::listIdentifiers() as $tzId)
@@ -633,11 +649,36 @@ $tabGroups = [
</select> </select>
<div style="display:flex;gap:4px;font-size:12px"> <div style="display:flex;gap:4px;font-size:12px">
<span class="text-muted">Aktualny czas:</span> <span class="text-muted">Aktualny czas:</span>
<strong x-text="now"></strong> <strong x-text="typeof now !== 'undefined' ? now : ''"></strong>
</div> </div>
</div> </div>
</div> </div>
<div class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Częstotliwość odświeżania i harmonogramu</h4>
<div class="card-kicker">Odświeżanie w przeglądarce</div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Widok zgłoszenia (klient i operator)</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshTicketViewSeconds"></div>
<div class="field" style="flex:1"><label>Lista zgłoszeń operatora</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshQueueSeconds"></div>
</div>
<div class="field"><label>Dzwonek powiadomień</label><input class="input" type="number" min="1" wire:model="systemConfig.refreshNotificationsSeconds"></div>
<p class="text-muted" style="font-size:12px;margin:0">Sekundy między automatycznymi odświeżeniami w przeglądarce (niezależnie od odświeżenia na żądanie po kliknięciu w licznik).</p>
<div class="hr"></div>
<div class="card-kicker">Zadania w tle</div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Sprawdzanie naruszeń SLA</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleSlaCheckMinutes"></div>
<div class="field" style="flex:1"><label>Reguły automatyzacji</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAutomationRulesMinutes"></div>
</div>
<div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Pobieranie e-maili (IMAP)</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleImapFetchMinutes"></div>
<div class="field" style="flex:1"><label>Automatyczna kategoryzacja i podsumowania AI</label><input class="input" type="number" min="1" wire:model="systemConfig.scheduleAiAutomationMinutes"></div>
</div>
<p class="text-muted" style="font-size:12px;margin:0">Minuty między uruchomieniami zadań w tle. Zmiana obowiązuje od najbliższego uruchomienia harmonogramu (co minutę), bez potrzeby restartu.</p>
</div>
<div style="grid-column:1/-1;display:flex"> <div style="grid-column:1/-1;display:flex">
<button type="submit" class="btn btn-primary">Zapisz</button> <button type="submit" class="btn btn-primary">Zapisz</button>
</div> </div>
@@ -657,6 +698,13 @@ $tabGroups = [
<label class="radio"><input type="checkbox" wire:model="ldapConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz autentykację LDAP/AD</strong></label> <label class="radio"><input type="checkbox" wire:model="ldapConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz autentykację LDAP/AD</strong></label>
@if ($ldapConfig['enabled']) @if ($ldapConfig['enabled'])
<div class="field">
<label>Typ katalogu</label>
<select class="input" wire:model="ldapConfig.directoryType">
<option value="lldap">LLDAP / OpenLDAP</option>
<option value="ad">Active Directory</option>
</select>
</div>
<div class="field"><label>Host serwera LDAP</label><input class="input" placeholder="ldap.example.com" wire:model="ldapConfig.host"></div> <div class="field"><label>Host serwera LDAP</label><input class="input" placeholder="ldap.example.com" wire:model="ldapConfig.host"></div>
<div style="display:flex;gap:10px"> <div style="display:flex;gap:10px">
<div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="389" wire:model="ldapConfig.port"></div> <div class="field" style="flex:1"><label>Port</label><input class="input" type="number" placeholder="389" wire:model="ldapConfig.port"></div>
@@ -665,7 +713,13 @@ $tabGroups = [
<div class="field"><label>Base DN</label><input class="input" placeholder="dc=example,dc=com" wire:model="ldapConfig.baseDn"></div> <div class="field"><label>Base DN</label><input class="input" placeholder="dc=example,dc=com" wire:model="ldapConfig.baseDn"></div>
<div class="field"><label>Bind DN</label><input class="input" placeholder="cn=admin,dc=example,dc=com" wire:model="ldapConfig.bindDn"></div> <div class="field"><label>Bind DN</label><input class="input" placeholder="cn=admin,dc=example,dc=com" wire:model="ldapConfig.bindDn"></div>
<div class="field"><label>Hasło Bind</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="ldapConfig.bindPassword"></div> <div class="field"><label>Hasło Bind</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="ldapConfig.bindPassword"></div>
<div class="field"><label>User Filter</label><input class="input" placeholder="(uid={0})" wire:model="ldapConfig.userFilter"></div> <div class="field">
<label>User Filter</label>
<input class="input" placeholder="{{ $ldapConfig['directoryType'] === 'ad' ? '(sAMAccountName={0})' : '(uid={0})' }}" wire:model="ldapConfig.userFilter">
@if ($ldapConfig['directoryType'] === 'ad')
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Active Directory nie ma atrybutu "uid" puste pole domyślnie użyje sAMAccountName.</p>
@endif
</div>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.autoProvisionGuests" style="position:static;opacity:1;width:auto;height:auto">Automatycznie zakładaj konto dla gościa zgłaszającego, jeśli jego e-mail istnieje w LDAP</label> <label class="radio"><input type="checkbox" wire:model="ldapConfig.autoProvisionGuests" style="position:static;opacity:1;width:auto;height:auto">Automatycznie zakładaj konto dla gościa zgłaszającego, jeśli jego e-mail istnieje w LDAP</label>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictUserCreationToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na ręczne zapraszanie użytkowników tylko, jeśli ich e-mail istnieje w LDAP</label> <label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictUserCreationToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na ręczne zapraszanie użytkowników tylko, jeśli ich e-mail istnieje w LDAP</label>
<label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictTicketsToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na tworzenie zgłoszeń bez logowania tylko dla adresów e-mail istniejących w LDAP</label> <label class="radio"><input type="checkbox" wire:model="ldapConfig.restrictTicketsToLdap" style="position:static;opacity:1;width:auto;height:auto">Zezwalaj na tworzenie zgłoszeń bez logowania tylko dla adresów e-mail istniejących w LDAP</label>
@@ -695,12 +749,35 @@ $tabGroups = [
<div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div> <div class="field"><label>Token Secret</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="bookstackConfig.tokenSecret"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil &rarr; Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p> <p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Token API generuje się w BookStack: Profil &rarr; Ustawienia API. Użytkownik/rola właściciela tokenu musi mieć uprawnienie „Access System API”.</p>
<div class="field"><label>Przeszukuj</label> <div class="field">
<select class="input" style="width:auto" wire:model="bookstackConfig.searchTypes"> <label>Przeszukuj</label>
<option value="both">Strony i książki</option> <div style="display:flex;gap:16px;flex-wrap:wrap">
<option value="page">Tylko strony</option> <label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
<option value="book">Tylko książki</option> <input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('book', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('book')">
Książki
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('page', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('page')">
Strony
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;font-weight:400">
<input type="checkbox" style="position:static;opacity:1;width:auto;height:auto" @checked(in_array('chapter', $bookstackConfig['searchTypes'])) wire:click="toggleBookstackSearchType('chapter')">
Rozdziały
</label>
</div>
@if (empty($bookstackConfig['searchTypes']))
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Nic nie zaznaczono przy zapisie zostaną użyte wszystkie typy.</p>
@endif
</div>
<div class="field">
<label>Szukaj po</label>
<select class="input" style="width:auto" wire:model="bookstackConfig.searchBy">
<option value="both">Słowa kluczowe w nazwie lub tagi</option>
<option value="name">Tylko słowa kluczowe w nazwie</option>
<option value="tags">Tylko tagi</option>
</select> </select>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">„Tagi” dopasowują artykuły oznaczone w BookStack tagiem o nazwie zgodnej z kategorią/podkategorią zgłoszenia (np. tag „Drukarki” dla podkategorii „Drukarki”).</p>
</div> </div>
<div style="display:flex;justify-content:flex-end"> <div style="display:flex;justify-content:flex-end">
@@ -750,6 +827,26 @@ $tabGroups = [
<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> <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> <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 class="field">
<label>Automatyczne tagowanie treści (AI)</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Używa integracji AI (skonfigurowanej w karcie „Integracja AI” obok) do otagowania książek/stron/rozdziałów nazwami pasujących podkategorii helpdesku bez tego wyszukiwanie „po tagach” nic nie znajdzie. „Otaguj nową treść” pomija już otagowane pozycje; „Otaguj wszystko ponownie” klasyfikuje od nowa całą wiki (dłużej, więcej zapytań do AI).</p>
<div style="display:flex;gap:10px;margin-top:6px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="runBookstackTagging" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTagging">sell</span>
Otaguj nową treść
</button>
<button type="button" class="btn btn-secondary" wire:click="runBookstackTaggingForce" wire:loading.attr="disabled" wire:target="runBookstackTagging,runBookstackTaggingForce">
<span class="material-symbols-outlined" style="font-size:16px;vertical-align:middle" wire:loading.class="spin" wire:target="runBookstackTaggingForce">refresh</span>
Otaguj wszystko ponownie (force)
</button>
</div>
@if ($bookstackTagResult !== null)
<div class="text-muted" style="font-size:11.5px;margin-top:6px">Przeskanowano {{ $bookstackTagResult['scanned'] }}, otagowano {{ $bookstackTagResult['tagged'] }}, pominięto {{ $bookstackTagResult['skipped'] }}, nieudanych paczek {{ $bookstackTagResult['failed_batches'] }}.</div>
@elseif ($bookstackTagError)
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger);font-size:11.5px;margin-top:6px"><span class="material-symbols-outlined" style="font-size:16px">error</span>{{ $bookstackTagError }}</div>
@endif
</div>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap"> <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="button" class="btn btn-secondary" wire:click="testBookstackConnection">Testuj połączenie</button>
<button type="submit" class="btn btn-primary">Zapisz</button> <button type="submit" class="btn btn-primary">Zapisz</button>
@@ -764,6 +861,139 @@ $tabGroups = [
@endif @endif
</form> </form>
<form wire:submit="saveSnipeitConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Snipe-IT (ewidencja sprzętu)</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Pokazuje sprzęt przypisany do zgłaszającego przy tworzeniu i przeglądaniu zgłoszenia oraz pozwala powiązać zgłoszenie z konkretnym urządzeniem z ewidencji Snipe-IT.</span>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację z Snipe-IT</strong></label>
@if ($snipeitConfig['enabled'])
<div class="field"><label>Adres API</label><input class="input" placeholder="https://assets.firma.pl" wire:model="snipeitConfig.baseUrl"></div>
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="snipeitConfig.apiToken"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Osobisty token API generuje się w Snipe-IT: profil użytkownika &rarr; „Create New Token”.</p>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.skipSslVerification" style="position:static;opacity:1;width:auto;height:auto">Nie sprawdzaj SSL</label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Zaznacz tylko, jeśli instancja Snipe-IT korzysta z certyfikatu self-signed / z prywatnego CA.</span>
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<div class="field">
<label>Klient</label>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.clientCanSelectAsset" style="position:static;opacity:1;width:auto;height:auto">Klient może wybrać sprzęt, którego dotyczy zgłoszenie</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Przy tworzeniu zgłoszenia klient zobaczy listę swojego sprzętu z Snipe-IT (dopasowanego po adresie e-mail) i będzie mógł je powiązać ze zgłoszeniem.</p>
</div>
@if ($snipeitConfig['clientCanSelectAsset'])
<div class="field">
<label>Ogranicz do kategorii</label>
<x-multiselect
:options="$this->categoriesForSnipeitForm"
:selected-ids="$snipeitConfig['clientAssetCategoryIds']"
toggle-action="toggleSnipeitClientCategory"
placeholder="Brak wybranych kategorii"
/>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Wybór sprzętu pojawi się klientowi przy zakładaniu zgłoszenia w dowolnej podkategorii zaznaczonych tu kategorii najszybszy sposób, żeby włączyć to dla całej kategorii naraz.</p>
</div>
<div class="field">
<label>Ogranicz do podkategorii</label>
<x-multiselect
:options="$this->subcategoriesForTeamForm"
:selected-ids="$snipeitConfig['clientAssetSubcategoryIds']"
toggle-action="toggleSnipeitClientSubcategory"
placeholder="Brak wybranych podkategorii"
/>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Dodatkowo, wybór sprzętu pojawi się też w pojedynczych podkategoriach zaznaczonych tutaj, nawet jeśli ich kategoria nie jest zaznaczona wyżej. Jeśli obie listy puste, opcja nie pojawi się w żadnej podkategorii.</p>
</div>
@endif
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<div class="field">
<label>Operator</label>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorViewRequesterAssets" style="position:static;opacity:1;width:auto;height:auto">Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia</label>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorSearchInventory" style="position:static;opacity:1;width:auto;height:auto">Zezwól operatorowi na przeszukiwanie całego inwentarza (nie tylko sprzętu zgłaszającego)</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Obie funkcje pojawiają się w bocznym panelu widoku zgłoszenia operatora przeszukiwanie inwentarza jako pole wyszukiwania z przyciskiem „Szukaj”, nie osobna podstrona. Odpięcie już powiązanego urządzenia jest zawsze dostępne dla operatora, niezależnie od tych dwóch ustawień.</p>
</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="testSnipeitConnection">Testuj połączenie</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($snipeitTestResult === '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 ($snipeitTestResult === '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{{ $snipeitTestMessage ? ': '.$snipeitTestMessage : '' }}</div>
@endif
</div>
@else
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
@endif
</form>
<form wire:submit="saveAiConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Integracja AI</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Ogólne połączenie z dostawcą modelu językowego (API kompatybilne z OpenAI Groq, OpenAI, lokalny Ollama itp.), wykorzystywane m.in. do automatycznego tagowania treści w BookStack.</span>
<label class="radio"><input type="checkbox" wire:model="aiConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację AI</strong></label>
@if ($aiConfig['enabled'])
<div class="field"><label>Adres API (Base URL)</label><input class="input" placeholder="https://api.groq.com/openai/v1" wire:model="aiConfig.baseUrl"></div>
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="aiConfig.apiKey"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Zostaw puste dla lokalnych instancji bez autoryzacji (np. Ollama).</p>
<div class="field"><label>Model</label><input class="input" placeholder="np. llama-3.3-70b-versatile" wire:model="aiConfig.model"></div>
<label class="radio"><input type="checkbox" wire:model="aiConfig.verifySsl" style="position:static;opacity:1;width:auto;height:auto">Weryfikuj certyfikat SSL</label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wyłącz tylko jeśli instancja (np. lokalny Ollama) 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="testAiConnection">Testuj połączenie</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($aiTestResult === '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 ($aiTestResult === '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{{ $aiTestMessage ? ': '.$aiTestMessage : '' }}</div>
@endif
</div>
@else
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
@endif
</form>
<form wire:submit="saveAiTriageConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Automatyzacja AI dla zgłoszeń</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Wymaga włączonej integracji AI (karta obok). Zgłoszenia przetwarzane w tle, cyklicznie co kilka minut nie spowalnia to tworzenia zgłoszenia przez klienta.</span>
<div class="field">
<label>Automatyczna kategoryzacja nowych zgłoszeń</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.categoryWhenMissing" style="position:static;opacity:1;width:auto;height:auto">Przypisz kategorię/podkategorię, gdy zgłoszenie nie ma żadnej</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.subcategoryWhenCategoryOnly" style="position:static;opacity:1;width:auto;height:auto">Dobierz podkategorię, gdy zgłoszenie ma tylko kategorię</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.recheckCategorized" style="position:static;opacity:1;width:auto;height:auto">Zweryfikuj i ewentualnie popraw już przypisaną podkategorię</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.fixSubject" style="position:static;opacity:1;width:auto;height:auto">Popraw temat zgłoszenia, jeśli jest niejasny</label>
<label class="radio"><input type="checkbox" wire:model="aiTriageConfig.setPriority" style="position:static;opacity:1;width:auto;height:auto">Ustaw priorytet na podstawie treści</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Każde zgłoszenie jest sprawdzane tylko raz zastosowane zmiany trafiają do historii zgłoszenia z adnotacją „Automatyzacja: klasyfikacja AI”.</p>
</div>
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<div class="field">
<label>Podsumowanie AI dla operatora</label>
<label class="radio"><input type="checkbox" wire:model="aiSummaryEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Generuj podsumowanie i sugerowaną akcję dla każdego zgłoszenia</strong></label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Domyślnie odświeżane cyklicznie (co kilka minut, wraz z pozostałą automatyzacją AI powyżej).</p>
<label class="radio"><input type="checkbox" wire:model="aiSummaryRegenerateOnMessage" style="position:static;opacity:1;width:auto;height:auto">Regeneruj podsumowanie od razu po każdej nowej wiadomości</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Zamiast czekać na najbliższy cykl automatyzacji dotyczy odpowiedzi operatora, klienta i notatek wewnętrznych.</p>
</div>
<div class="field">
<div style="display:flex;justify-content:space-between;align-items:center">
<label>Prompt systemowy podsumowania</label>
<button type="button" class="btn btn-ghost" style="padding:2px 8px;font-size:12px" wire:click="resetAiSummaryPrompt" wire:confirm="Przywrócić domyślny prompt? Obecna treść zostanie zastąpiona.">Resetuj</button>
</div>
<textarea wire:key="ai-summary-prompt-{{ $aiSummaryPromptVersion }}" class="input" rows="6" wire:change="saveAiSummaryPrompt($event.target.value)">{{ $aiSummaryPrompt }}</textarea>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Model musi zwrócić obiekt JSON z kluczami "summary" i "suggested_action" nie zmieniaj tego wymogu, chyba że wiadomo, że nowy dostawca/model obsłuży to inaczej.</p>
</div>
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
</form>
</div> </div>
@endif @endif
@@ -773,6 +1003,11 @@ $tabGroups = [
<livewire:admin.api-keys /> <livewire:admin.api-keys />
@endif @endif
{{-- ================= LOGS ================= --}}
@if ($tab === 'logs')
<livewire:admin.logs />
@endif
@if ($tab === 'about') @if ($tab === 'about')
<h3 style="margin:0 0 14px">O aplikacji</h3> <h3 style="margin:0 0 14px">O aplikacji</h3>
<div class="card" style="padding:18px;gap:10px;max-width:420px"> <div class="card" style="padding:18px;gap:10px;max-width:420px">

View File

@@ -4,7 +4,7 @@
</x-topbar> </x-topbar>
<div class="page-pad" style="flex:1;display:flex;justify-content:center;align-items:center;padding:40px 20px"> <div class="page-pad" style="flex:1;display:flex;justify-content:center;align-items:center;padding:40px 20px">
<form wire:submit="submit" class="card elev-md" style="width:100%;max-width:380px;padding:28px;gap:16px"> <form wire:submit="submit" class="card elev-md" style="width:100%;max-width:480px;padding:28px;gap:16px">
<img src="{{ \App\Support\Settings::logoUrl() }}" alt="{{ \App\Support\Settings::get('company_name') }}" style="height:56px;width:auto;align-self:center"> <img src="{{ \App\Support\Settings::logoUrl() }}" alt="{{ \App\Support\Settings::get('company_name') }}" style="height:56px;width:auto;align-self:center">
<h2 style="margin:0;text-align:center">Zaloguj się</h2> <h2 style="margin:0;text-align:center">Zaloguj się</h2>
<x-login-notice :html="\App\Support\Settings::get('login_notice_html')" :type="\App\Support\Settings::loginNoticeType()" /> <x-login-notice :html="\App\Support\Settings::get('login_notice_html')" :type="\App\Support\Settings::loginNoticeType()" />

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box"> <div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;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"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
@@ -9,20 +9,20 @@
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<div class="seg"> <div class="seg">
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->count() }})</label> <label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'current') wire:click="setTab('current')">Aktualne ({{ $this->currentTickets->total() }})</label>
<label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->count() }})</label> <label class="seg-opt"><input type="radio" name="ctab" @checked($tab === 'archive') wire:click="setTab('archive')">Archiwalne ({{ $this->archiveTickets->total() }})</label>
</div> </div>
<input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px"> <input class="input" type="search" placeholder="Szukaj po numerze, temacie, treści…" wire:model.live.debounce.400ms="search" style="max-width:280px">
</div> </div>
<div style="display:flex;flex-direction:column;gap:10px"> <div style="display:flex;flex-direction:column;gap:10px">
@foreach (($tab === 'current' ? $this->currentTickets : $this->archiveTickets) as $ticket) @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"> <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;text-decoration:none;color:inherit">
<div> <div style="flex:1;min-width:0">
<div style="font-weight:500">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div> <div style="font-weight:500">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div>
<div class="card-meta">{{ $ticket->categoryLabel() }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}</div> <div class="card-meta">{{ $ticket->categoryLabel() }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}</div>
</div> </div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px;flex-shrink:0">
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span> <span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span> <span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</div> </div>
@@ -32,6 +32,10 @@
@if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty()) @if (($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->isEmpty())
<p class="text-muted" style="font-size:13px">Brak zgłoszeń w tej zakładce.</p> <p class="text-muted" style="font-size:13px">Brak zgłoszeń w tej zakładce.</p>
@endif @endif
<div style="margin-top:4px">
{{ ($tab === 'current' ? $this->currentTickets : $this->archiveTickets)->links() }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="client" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box"> <div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;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"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
@@ -64,6 +64,15 @@
<x-bookstack-suggestions :articles="$this->suggestedArticles" /> <x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div> </div>
<div wire:init="loadSnipeitAssets">
<x-snipeit-assets
:assets="$this->snipeitAssets"
title="Twój sprzęt (inwentarz) — powiąż, jeśli zgłoszenie go dotyczy"
:selectable="\App\Support\Settings::bool('snipeit_client_can_select_asset')"
:selected-id="$selectedSnipeitAssetId"
/>
</div>
<div class="field"> <div class="field">
<label>Temat</label> <label>Temat</label>
<input class="input" wire:model="subject"> <input class="input" wire:model="subject">
@@ -86,7 +95,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @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'))" @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="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && 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" style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -1,22 +1,40 @@
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="client" />
<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 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"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="{{ route('client.dashboard') }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a> @php $backTab = session('client_dashboard_tab', 'current'); @endphp
<a href="{{ route('client.dashboard', $backTab !== 'current' ? ['tab' => $backTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0">&larr; Wróć do listy</a>
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}} countdown so it's clear the thread is still refreshing on its own.
Also clickable, to fetch immediately and reset the countdown. --}}
@php
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
@endphp
<div <div
wire:key="ticket-autorefresh-{{ $ticket->id }}"
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:default;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: 30, total: 30 }" x-data="{
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)" remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }},
title="Zgłoszenie odświeża się automatycznie" tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshTicketData();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div> </div>
</div> </div>
@@ -84,7 +102,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @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'))" @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="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && 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" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>
@@ -114,6 +132,16 @@
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" /> <x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
</div> </div>
@if ($ticket->snipeit_asset_name)
<div class="card" style="padding:16px;gap:6px">
<div class="card-kicker">Powiązany sprzęt</div>
<div style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500">
<span class="material-symbols-outlined" style="font-size:18px;color:var(--color-accent)">devices</span>
{{ $ticket->snipeit_asset_name }}
</div>
</div>
@endif
<div class="card" style="padding:16px;gap:10px"> <div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Status i priorytet</div> <div class="card-kicker">Status i priorytet</div>
<div style="display:flex;gap:6px;flex-wrap:wrap"> <div style="display:flex;gap:6px;flex-wrap:wrap">
@@ -167,13 +195,18 @@
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Inne Twoje zgłoszenia</div> <div class="card-kicker">Inne Twoje zgłoszenia</div>
@forelse ($this->otherTickets as $ot) @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"> <a wire:key="other-ticket-{{ $ot->id }}" 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->displayNumber() }} {{ $ot->subject }}</span> <span style="font-size:13px">{{ $ot->displayNumber() }} {{ $ot->subject }}</span>
<span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span> <span style="{{ $ot->statusStyle() }};flex:none">{{ $ot->statusLabel() }}</span>
</a> </a>
@empty @empty
<p class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p> <p wire:key="other-tickets-empty" class="text-muted" style="font-size:12px;margin:0">Brak innych zgłoszeń.</p>
@endforelse @endforelse
@if (! $showAllOtherTickets && $this->otherTicketsCount > count($this->otherTickets))
<button wire:key="show-all-other-tickets" type="button" wire:click="revealAllOtherTickets" class="btn btn-secondary" style="font-size:12px;padding:6px 10px;align-self:flex-start">
Pokaż wszystkie ({{ $this->otherTicketsCount }})
</button>
@endif
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,51 @@
<div
x-data="{ open: false }"
x-on:open-global-search.window="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.cmd.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.ctrl.k.window.prevent="open = true; $nextTick(() => $refs.searchInput.focus())"
x-on:keydown.escape.window="open = false"
>
<div class="dialog-backdrop" x-show="open" x-cloak style="align-items:flex-start;padding-top:10vh">
<div class="dialog" style="max-width:720px;padding:0;gap:0;overflow:hidden" @click.outside="open = false">
<div style="display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:1px solid var(--color-divider)">
<span class="material-symbols-outlined" style="font-size:26px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">search</span>
<input
x-ref="searchInput"
type="text"
wire:model.live.debounce.200ms="search"
placeholder="Szukaj zgłoszenia… (np. od:kacper temat:drukarka)"
class="input"
style="border:none;padding:10px 12px;box-shadow:none;font-size:18px"
x-on:keydown.enter="$refs.resultsList?.querySelector('a')?.click()"
>
<kbd style="font-size:11px;color:color-mix(in srgb, var(--color-text) 45%, transparent);border:1px solid var(--color-divider);border-radius:4px;padding:2px 7px;flex:none">Esc</kbd>
</div>
<div x-ref="resultsList" style="max-height:460px;overflow-y:auto">
@forelse ($this->results as $ticket)
<a
href="{{ $this->urlFor($ticket) }}"
wire:navigate
@click="open = false"
style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 22px;text-decoration:none;color:inherit;border-bottom:1px solid var(--color-divider)"
>
<div style="min-width:0">
<div style="font-weight:500;font-size:14.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</div>
<div class="card-meta" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
{{ $ticket->categoryLabel() }} &middot; {{ $ticket->name }} &middot; {{ \App\Support\Rel::format($ticket->updated_at) }}
</div>
</div>
<div style="display:flex;gap:6px;flex:none">
<span style="{{ $ticket->priorityStyle() }}">{{ $ticket->priorityLabel() }}</span>
<span style="{{ $ticket->statusStyle() }}">{{ $ticket->statusLabel() }}</span>
</div>
</a>
@empty
<div style="padding:28px 22px;text-align:center;font-size:13.5px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">
{{ trim($search) === '' ? 'Zacznij pisać, aby wyszukać zgłoszenie… (obsługuje też od:, temat:, treść:, numer:)' : 'Brak wyników.' }}
</div>
@endforelse
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.30s="$refresh"> <div x-data="{ open: false }" @click.outside="open = false" class="nav-dropdown-wrap" style="position:relative;display:inline-block" wire:poll.{{ max(1, (int) \App\Support\Settings::get('refresh_notifications_seconds')) }}s="$refresh">
<button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px"> <button type="button" class="btn btn-secondary" @click="open = !open" style="position:relative;display:flex;align-items:center;gap:0;padding:8px">
<span class="material-symbols-outlined" style="font-size:18px">notifications</span> <span class="material-symbols-outlined" style="font-size:18px">notifications</span>
@if ($this->unreadCount) @if ($this->unreadCount)

View File

@@ -0,0 +1,53 @@
<div style="flex:1;display:flex;flex-direction:column">
<x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px;max-width:820px;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">
<h2 style="margin:0">Klienci</h2>
<a href="{{ route('operator.queue') }}" wire:navigate class="btn btn-ghost">&larr; Wróć do listy</a>
</div>
<input
class="input"
type="search"
placeholder="Szukaj po imieniu, nazwisku lub adresie e-mail…"
wire:model.live.debounce.400ms="search"
autofocus
>
<div style="display:flex;flex-direction:column;gap:10px">
@if (trim($search) === '')
<p class="text-muted" style="font-size:13px">Zacznij pisać, aby wyszukać klienta.</p>
@else
@forelse ($this->results as $u)
<a
wire:key="client-{{ $u->id }}"
href="{{ route('operator.queue', ['filterCustomerId' => $u->id]) }}"
wire:navigate
class="card elev-sm"
style="padding:14px 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">{{ $u->name }}</div>
<div class="card-meta">{{ $u->email }}</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end">
@if ($u->hasRole('client'))
<span class="tag tag-outline">Klient</span>
@endif
@if ($u->hasRole('operator'))
<span class="tag tag-outline">Operator</span>
@endif
@if ($u->hasRole('admin'))
<span class="tag tag-outline">Administrator</span>
@endif
<span class="tag tag-neutral">{{ $u->tickets_as_customer_count }} {{ $u->tickets_as_customer_count === 1 ? 'zgłoszenie' : 'zgłoszeń' }}</span>
</div>
</a>
@empty
<p class="text-muted" style="font-size:13px">Brak wyników dla {{ $search }}.</p>
@endforelse
@endif
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;width:100%;margin:0 auto;box-sizing:border-box"> <div class="page-pad" style="flex:1;padding:28px;display:flex;flex-direction:column;gap:20px;max-width:920px;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"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
@@ -92,7 +92,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @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'))" @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="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && 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" style="border:1px dashed var(--color-divider);border-radius:8px;padding:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label> <label class="btn btn-secondary" style="cursor:pointer">Wybierz pliki<input type="file" multiple style="display:none" wire:model="attachments"></label>

View File

@@ -1,17 +1,22 @@
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="operator" />
<div class="app-split" style="flex:1;display:flex;min-height:0"> <div class="app-split" style="flex:1;display:flex;min-height:0">
<div <div
class="side-rail" class="side-rail"
style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))" style="padding:10px;gap:6px;background:color-mix(in srgb, var(--color-text) 5%, var(--color-bg))"
x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }" x-data="{ collapsed: localStorage.getItem('operatorSidebarCollapsed') === '1' }"
x-effect="localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')" x-effect="typeof collapsed !== 'undefined' && localStorage.setItem('operatorSidebarCollapsed', collapsed ? '1' : '0')"
:class="collapsed ? 'side-rail-collapsed' : ''" :class="(typeof collapsed !== 'undefined' && collapsed) ? 'side-rail-collapsed' : ''"
> >
<button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="collapsed ? 'Rozwiń panel' : 'Zwiń panel'"> <button type="button" class="side-rail-toggle" @click="collapsed = ! collapsed" :title="(typeof collapsed !== 'undefined' && collapsed) ? 'Rozwiń panel' : 'Zwiń panel'">
<span class="material-symbols-outlined" style="font-size:18px" x-text="collapsed ? 'left_panel_open' : 'left_panel_close'"></span> <span class="material-symbols-outlined" style="font-size:18px" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'left_panel_open' : 'left_panel_close'"></span>
<span class="side-rail-toggle-label" x-text="collapsed ? 'Przegląd' : 'Zwiń panel'"></span> <span class="side-rail-toggle-label" x-text="(typeof collapsed !== 'undefined' && collapsed) ? 'Przegląd' : 'Zwiń panel'"></span>
</button>
<button type="button" class="side-rail-toggle" title="Szukaj" @click="window.dispatchEvent(new CustomEvent('open-global-search'))">
<span class="material-symbols-outlined" style="font-size:18px">search</span>
<span class="side-rail-toggle-label">Szukaj</span>
</button> </button>
<div class="side-rail-scroll"> <div class="side-rail-scroll">
@@ -27,6 +32,18 @@
@endforeach @endforeach
</div> </div>
@endforeach @endforeach
@if ($this->recentlyViewed->isNotEmpty())
<div style="display:flex;flex-direction:column;gap:1px">
<div class="side-rail-group-label" style="font-size:10px;letter-spacing:0.09em;text-transform:uppercase;color:color-mix(in srgb, var(--color-text) 45%, transparent);padding:4px 12px 6px;white-space:nowrap">Ostatnio przeglądane</div>
@foreach ($this->recentlyViewed as $ticket)
<a href="{{ route('operator.ticket', $ticket) }}" wire:navigate class="side-rail-item" title="{{ $ticket->displayNumber() }} — {{ $ticket->subject }}" style="display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;border-radius:6px;font-size:13.5px;text-align:left;white-space:nowrap;text-decoration:none;color:var(--color-text);overflow:hidden">
<span class="material-symbols-outlined" style="font-size:18px;flex:none">history</span>
<span class="side-rail-label" style="overflow:hidden;text-overflow:ellipsis">{{ $ticket->displayNumber() }} {{ $ticket->subject }}</span>
</a>
@endforeach
</div>
@endif
</div> </div>
</div> </div>
@@ -40,6 +57,10 @@
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 2) wire:click="mergeSelected">Scal zgłoszenia ({{ count($selectedIds) }})</button> <button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 2) wire:click="mergeSelected">Scal zgłoszenia ({{ count($selectedIds) }})</button>
<button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 1) wire:click="requestDeleteSelected" style="color:var(--color-danger)">Usuń zgłoszenia ({{ count($selectedIds) }})</button> <button type="button" class="btn btn-secondary" @disabled(count($selectedIds) < 1) wire:click="requestDeleteSelected" style="color:var(--color-danger)">Usuń zgłoszenia ({{ count($selectedIds) }})</button>
</div> </div>
<a href="{{ route('operator.clients') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">person_search</span>
Klienci
</a>
<a href="{{ route('operator.stats') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px"> <a href="{{ route('operator.stats') }}" wire:navigate class="btn btn-secondary" style="display:flex;align-items:center;gap:6px">
<span class="material-symbols-outlined" style="font-size:18px">bar_chart</span> <span class="material-symbols-outlined" style="font-size:18px">bar_chart</span>
Statystyki Statystyki
@@ -69,6 +90,7 @@
</select> </select>
<select class="input" style="width:auto" wire:model.live="filterCategory"> <select class="input" style="width:auto" wire:model.live="filterCategory">
<option value="all">Wszystkie kategorie</option> <option value="all">Wszystkie kategorie</option>
<option value="none">Bez kategorii</option>
@foreach ($this->categories as $c) @foreach ($this->categories as $c)
<option value="{{ $c->id }}">{{ $c->name }}</option> <option value="{{ $c->id }}">{{ $c->name }}</option>
@endforeach @endforeach
@@ -79,7 +101,7 @@
<span class="material-symbols-outlined" style="font-size:18px">bookmark</span> <span class="material-symbols-outlined" style="font-size:18px">bookmark</span>
Zapisane widoki Zapisane widoki
</button> </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"> <div x-show="typeof open !== 'undefined' && 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) @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)' : '' }}"> <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> <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>
@@ -95,7 +117,7 @@
<template x-if="! adding"> <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> <button type="button" class="btn btn-secondary btn-block" @click="adding = true" style="font-size:12.5px">+ Zapisz bieżące filtry…</button>
</template> </template>
<div x-show="adding" style="display:flex;gap:6px;padding:4px 2px"> <div x-show="typeof adding !== 'undefined' && 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"> <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> <button type="button" class="btn btn-primary" style="flex:none;padding:6px 10px" @click="$wire.saveCurrentView(); adding = false">Zapisz</button>
</div> </div>
@@ -107,29 +129,61 @@
<span class="material-symbols-outlined" style="font-size:18px">view_column</span> <span class="material-symbols-outlined" style="font-size:18px">view_column</span>
Kolumny Kolumny
</button> </button>
<div x-show="open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right: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:180px"> <div x-show="typeof open !== 'undefined' && open" x-cloak @click.outside="open = false" style="position:absolute;top:100%;right: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">
@foreach ($columnDefs as $key => $label) @foreach ($visibleColumns as $i => $key)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer"> <div style="display:flex;align-items:center;gap:4px;padding:2px 2px 2px 8px;border-radius:5px">
<input type="checkbox" @checked(in_array($key, $visibleColumns)) wire:click="toggleColumn('{{ $key }}')"> <label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;flex:1;min-width:0;cursor:pointer">
{{ $label }} <input type="checkbox" checked wire:click="toggleColumn('{{ $key }}')">
</label> <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ $columnDefs[$key] ?? $key }}</span>
</label>
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === 0 ? 'default' : 'pointer' }};opacity:{{ $i === 0 ? '0.25' : '0.7' }}" title="Przesuń wcześniej (w lewo w tabeli)" wire:click="moveColumnUp('{{ $key }}')">arrow_upward</span>
<span class="material-symbols-outlined" style="font-size:16px;flex:none;cursor:{{ $i === count($visibleColumns) - 1 ? 'default' : 'pointer' }};opacity:{{ $i === count($visibleColumns) - 1 ? '0.25' : '0.7' }}" title="Przesuń później (w prawo w tabeli)" wire:click="moveColumnDown('{{ $key }}')">arrow_downward</span>
</div>
@endforeach @endforeach
@php $hiddenColumnDefs = array_diff_key($columnDefs, array_flip($visibleColumns)); @endphp
@if (! empty($hiddenColumnDefs))
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
@foreach ($hiddenColumnDefs as $key => $label)
<label style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:400;padding:6px 8px;border-radius:5px;cursor:pointer;opacity:0.75">
<input type="checkbox" wire:click="toggleColumn('{{ $key }}')">
{{ $label }}
</label>
@endforeach
@endif
</div> </div>
</div> </div>
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently (backgrounded tab, network blip) this is a periodic drop silently (backgrounded tab, network blip) this is a periodic
fallback refresh, with a visible countdown so it's clear the queue fallback refresh, with a visible countdown so it's clear the queue
is still refreshing itself rather than just stuck. --}} is still refreshing itself rather than just stuck. Also clickable,
to fetch immediately and reset the countdown. --}}
@php
$refreshQueueSeconds = max(1, (int) \App\Support\Settings::get('refresh_queue_seconds'));
@endphp
<div <div
wire:key="queue-autorefresh"
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:default;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: 60, total: 60 }" x-data="{
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshQueue(); }, 1000)" remaining: {{ $refreshQueueSeconds }}, total: {{ $refreshQueueSeconds }},
title="Kolejka odświeża się automatycznie co minutę" tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshQueue();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshQueue()"
title="Kolejka odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div> </div>
</div> </div>
@@ -138,8 +192,8 @@
<thead> <thead>
<tr> <tr>
<th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th> <th><input type="checkbox" @checked($this->filteredTickets->isNotEmpty() && empty($this->filteredTickets->pluck('id')->diff($selectedIds)->all())) wire:click="toggleSelectAll" title="Zaznacz wszystkie"></th>
@foreach ($columnDefs as $key => $label) @foreach ($visibleColumns as $key)
@continue(! in_array($key, $visibleColumns)) @php $label = $columnDefs[$key] ?? $key; @endphp
<th> <th>
@if (in_array($key, $sortableColumns)) @if (in_array($key, $sortableColumns))
<button type="button" wire:click="sortByColumn('{{ $key }}')" style="display:inline-flex;align-items:center;gap:2px;background:none;border:none;cursor:pointer;padding:0;font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit"> <button type="button" wire:click="sortByColumn('{{ $key }}')" style="display:inline-flex;align-items:center;gap:2px;background:none;border:none;cursor:pointer;padding:0;font:inherit;color:inherit;text-transform:inherit;letter-spacing:inherit">
@@ -160,49 +214,68 @@
@php $sla = $t->slaInfo(); @endphp @php $sla = $t->slaInfo(); @endphp
<tr wire:key="ticket-{{ $t->id }}"> <tr wire:key="ticket-{{ $t->id }}">
<td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td> <td class="td-select"><input type="checkbox" @checked(in_array($t->id, $selectedIds)) wire:click="toggleSelect({{ $t->id }})"></td>
@if (in_array('number', $visibleColumns)) @foreach ($visibleColumns as $key)
<td data-label="Numer" class="td-title"> @switch($key)
<a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a> @case('id')
@if ($t->source === 'email') <td data-label="ID">{{ $t->id }}</td>
<span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span> @break
@endif @case('number')
</td> <td data-label="Numer" class="td-title">
@endif <a href="{{ route('operator.ticket', $t) }}" wire:navigate style="color:inherit;text-decoration:none;cursor:pointer">{{ $t->displayNumber() }}</a>
@if (in_array('subject', $visibleColumns)) @if ($t->source === 'email')
<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> <span class="material-symbols-outlined" style="font-size:15px;vertical-align:-3px;opacity:0.7" title="Utworzone przez e-mail">mail</span>
@endif @endif
@if (in_array('customer', $visibleColumns)) </td>
<td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td> @break
@endif @case('subject')
@if (in_array('category', $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>
<td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td> @break
@endif @case('customer')
@if (in_array('subcategory', $visibleColumns)) <td data-label="Klient" style="white-space:nowrap">{{ $t->name }}</td>
<td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td> @break
@endif @case('email')
@if (in_array('priority', $visibleColumns)) <td data-label="E-mail" style="white-space:nowrap">{{ $t->email }}</td>
<td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td> @break
@endif @case('category')
@if (in_array('status', $visibleColumns)) <td data-label="Kategoria" style="white-space:nowrap">{{ $t->categoryLabel() }}</td>
<td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td> @break
@endif @case('subcategory')
@if (in_array('sla', $visibleColumns)) <td data-label="Podkategoria" style="white-space:nowrap">{{ $t->subcategory?->name ?? '—' }}</td>
<td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td> @break
@endif @case('priority')
@if (in_array('assignee', $visibleColumns)) <td data-label="Priorytet"><span style="{{ $t->priorityStyle() }}">{{ $t->priorityLabel() }}</span></td>
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td> @break
@endif @case('status')
@if (in_array('team', $visibleColumns)) <td data-label="Status"><span style="{{ $t->statusStyle() }}">{{ $t->statusLabel() }}</span></td>
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td> @break
@endif @case('sla')
@if (in_array('created', $visibleColumns)) <td data-label="SLA"><span class="{{ $sla['cls'] }}">{{ $sla['short'] }}</span></td>
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td> @break
@endif @case('assignee')
<td data-label="Przypisany" style="white-space:nowrap">{{ $t->assignee?->name ?? 'Nieprzypisane' }}</td>
@break
@case('team')
<td data-label="Zespół" style="white-space:nowrap">{{ $t->team?->name ?? '—' }}</td>
@break
@case('source')
<td data-label="Źródło" style="white-space:nowrap">{{ match ($t->source) { 'email' => 'E-mail', 'hesk_import' => 'Import HESK', default => 'WWW' } }}</td>
@break
@case('created')
<td data-label="Utworzono" style="white-space:nowrap">{{ \App\Support\Rel::format($t->created_at) }}</td>
@break
@case('updated')
<td data-label="Zaktualizowano" style="white-space:nowrap">{{ \App\Support\Rel::format($t->updated_at) }}</td>
@break
@endswitch
@endforeach
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
</table> </table>
</div> </div>
<div style="margin-top:12px">
{{ $this->filteredTickets->links() }}
</div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px"> <div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto;display:flex;flex-direction:column;gap:20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"> <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">

View File

@@ -1,10 +1,11 @@
<div style="flex:1;display:flex;flex-direction:column"> <div style="flex:1;display:flex;flex-direction:column">
<x-topbar /> <x-topbar area="operator" />
<div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto"> <div class="page-pad" style="flex:1;padding:20px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto"> <div style="display:flex;flex-direction:column;gap:16px;max-width:1180px;margin:0 auto">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"> <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">&larr; Wróć do listy</a> @php $backQueueTab = session('operator_queue_tab', 'all'); @endphp
<a href="{{ route('operator.queue', $backQueueTab !== 'all' ? ['queue' => $backQueueTab] : []) }}" wire:navigate class="btn btn-ghost" style="padding:0;margin-right:auto">&larr; Wróć do listy</a>
<button <button
type="button" type="button"
@@ -19,16 +20,33 @@
{{-- Live updates arrive via broadcasting, but websocket connections can {{-- Live updates arrive via broadcasting, but websocket connections can
drop silently this is a periodic fallback refresh, with a visible drop silently this is a periodic fallback refresh, with a visible
countdown so it's clear the thread is still refreshing on its own. --}} countdown so it's clear the thread is still refreshing on its own.
Also clickable, to fetch immediately and reset the countdown. --}}
@php
$refreshTicketSeconds = max(1, (int) \App\Support\Settings::get('refresh_ticket_view_seconds'));
@endphp
<div <div
wire:key="ticket-autorefresh-{{ $ticket->id }}"
class="btn btn-secondary" class="btn btn-secondary"
style="cursor:default;gap:6px" style="cursor:pointer;gap:6px"
x-data="{ remaining: 30, total: 30 }" x-data="{
x-init="setInterval(() => { remaining = remaining <= 1 ? total : remaining - 1; if (remaining === total) $wire.refreshTicketData(); }, 1000)" remaining: {{ $refreshTicketSeconds }}, total: {{ $refreshTicketSeconds }},
title="Zgłoszenie odświeża się automatycznie" tick: null,
navigatingHandler: null,
init() {
this.tick = setInterval(() => {
this.remaining = this.remaining <= 1 ? this.total : this.remaining - 1;
if (this.remaining === this.total) $wire.refreshTicketData();
}, 1000);
this.navigatingHandler = () => clearInterval(this.tick);
document.addEventListener('livewire:navigating', this.navigatingHandler);
},
}"
x-on:click="remaining = total; $wire.refreshTicketData()"
title="Zgłoszenie odświeża się automatycznie — kliknij, aby odświeżyć teraz"
> >
<span class="material-symbols-outlined" style="font-size:18px">schedule</span> <span class="material-symbols-outlined" style="font-size:18px">schedule</span>
<span x-text="remaining + 's'"></span> <span x-text="typeof remaining !== 'undefined' ? (remaining + 's') : ''"></span>
</div> </div>
</div> </div>
@@ -135,7 +153,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @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'))" @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="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && 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" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="noteAttachments"></label>
@@ -211,7 +229,7 @@
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @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'))" @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="{ borderColor: (typeof dragging !== 'undefined' && dragging) ? 'var(--color-accent)' : undefined, background: (typeof dragging !== 'undefined' && 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" style="flex:1;min-width:0;border:1px dashed var(--color-divider);border-radius:8px;padding:10px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px"
> >
<label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label> <label class="btn btn-secondary" style="cursor:pointer;flex:none">Załącz pliki<input type="file" multiple style="display:none" wire:model="replyAttachments"></label>
@@ -230,7 +248,7 @@
<button class="btn btn-primary" type="button" style="border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid var(--color-bg);padding:0 6px" @click="open = !open"> <button class="btn btn-primary" type="button" style="border-top-left-radius:0;border-bottom-left-radius:0;border-left:1px solid var(--color-bg);padding:0 6px" @click="open = !open">
<span class="material-symbols-outlined" style="font-size:16px">expand_more</span> <span class="material-symbols-outlined" style="font-size:16px">expand_more</span>
</button> </button>
<div x-show="open" x-cloak style="position:absolute;bottom:100%;right:0;margin-bottom:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:10"> <div x-show="typeof open !== 'undefined' && open" x-cloak style="position:absolute;bottom:100%;right:0;margin-bottom:6px;background:var(--color-surface);border:1px solid var(--color-divider);border-radius:8px;box-shadow:var(--shadow-md);min-width:220px;overflow:hidden;z-index:10">
@forelse ($this->replyQuickActions as $qa) @forelse ($this->replyQuickActions as $qa)
<button type="button" class="theme-toggle-option" @click="open = false" wire:click="sendAndTransition({{ $qa->id }})">{{ $qa->label }}</button> <button type="button" class="theme-toggle-option" @click="open = false" wire:click="sendAndTransition({{ $qa->id }})">{{ $qa->label }}</button>
@empty @empty
@@ -286,7 +304,7 @@
<div class="card-kicker">Status i przypisanie</div> <div class="card-kicker">Status i przypisanie</div>
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
<select class="input" wire:change="setStatus($event.target.value)"> <select class="input" wire:key="ticket-status-select-{{ $ticket->status_key }}" wire:change="setStatus($event.target.value)">
@foreach ($this->statuses as $s) @foreach ($this->statuses as $s)
<option value="{{ $s->key }}" @selected($ticket->status_key === $s->key)>{{ $s->label }}</option> <option value="{{ $s->key }}" @selected($ticket->status_key === $s->key)>{{ $s->label }}</option>
@endforeach @endforeach
@@ -325,10 +343,93 @@
</div> </div>
</div> </div>
@if (\App\Support\Settings::bool('snipeit_enabled'))
@if ($ticket->snipeit_asset_id)
@php $linkedAsset = $this->snipeitLinkedAsset; @endphp
<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">Powiązany sprzęt</div>
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:2px 6px" wire:click="unlinkSnipeitAsset">Odepnij</button>
</div>
<a href="{{ $linkedAsset['url'] ?? '#' }}" target="_blank" rel="noopener noreferrer" style="display:flex;gap:8px;align-items:flex-start;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)">devices</span>
<span style="min-width:0">
<span style="display:block;font-size:13px;font-weight:500;color:var(--color-accent)">{{ $linkedAsset['label'] ?? $ticket->snipeit_asset_name }}</span>
@if ($linkedAsset)
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">{{ collect([$linkedAsset['category'] ?? null, $linkedAsset['status'] ?? null, $linkedAsset['assignedTo'] ?? null])->filter()->implode(' · ') }}</span>
@else
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">Niedostępne w Snipe-IT (brak połączenia lub usunięto)</span>
@endif
</span>
</a>
</div>
@endif
@if (\App\Support\Settings::bool('snipeit_operator_view_requester_assets'))
<div wire:init="loadSnipeitAssets">
<x-snipeit-assets
:assets="$this->snipeitRequesterAssets"
variant="sidebar"
title="Sprzęt zgłaszającego"
:selectable="true"
select-action="linkSnipeitAsset"
:selected-id="$ticket->snipeit_asset_id"
/>
</div>
@endif
@if (\App\Support\Settings::bool('snipeit_operator_search_inventory'))
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Przeszukaj inwentarz</div>
<div style="display:flex;gap:6px">
<input class="input" style="flex:1" placeholder="Nr inwentarzowy, model, nazwa..." wire:model="snipeitSearchQuery" wire:keydown.enter.prevent="searchSnipeitAssets">
<button type="button" class="btn btn-secondary" style="flex:none" wire:click="searchSnipeitAssets">Szukaj</button>
</div>
<x-snipeit-assets
:assets="$snipeitSearchResults"
variant="sidebar"
:card="false"
:selectable="true"
select-action="linkSnipeitAsset"
:selected-id="$ticket->snipeit_asset_id"
/>
</div>
@endif
@endif
<div wire:init="loadSuggestedArticles"> <div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" /> <x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</div> </div>
@if (\App\Support\Settings::bool('ai_summary_enabled'))
<div wire:init="loadAiSummary" class="card" style="padding:16px;gap:8px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<div class="card-kicker">Podsumowanie AI</div>
<button type="button" class="btn btn-ghost" style="padding:2px 8px;font-size:12px;flex:none" wire:click="regenerateAiSummary" wire:loading.attr="disabled" wire:target="regenerateAiSummary">
<span wire:loading.remove wire:target="regenerateAiSummary">Wygeneruj teraz</span>
<span wire:loading wire:target="regenerateAiSummary">Generowanie…</span>
</button>
</div>
@if ($aiSummaryRegenerateError)
<div style="font-size:12px;color:var(--color-danger)">{{ $aiSummaryRegenerateError }}</div>
@endif
@if ($aiSummaryLoaded)
@if ($ticket->ai_summary)
<div style="font-size:13px;line-height:1.5">{{ $ticket->ai_summary }}</div>
@if ($ticket->ai_suggested_action)
<div style="margin-top:6px;padding-top:8px;border-top:1px solid var(--color-divider);font-size:13px">
<strong>Sugerowana akcja:</strong> {{ $ticket->ai_suggested_action }}
</div>
@endif
<div class="text-muted" style="font-size:11px;margin-top:4px">Zaktualizowano: {{ $ticket->ai_summary_generated_at?->diffForHumans() }}</div>
@else
<p class="text-muted" style="font-size:12.5px;margin:0">Podsumowanie pojawi się po najbliższym cyklu automatyzacji AI, albo od razu po kliknięciu „Wygeneruj teraz”.</p>
@endif
@endif
</div>
@endif
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">SLA</div> <div class="card-kicker">SLA</div>
<div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div> <div style="font-size:12.5px">{{ $ticket->slaInfo()['text'] }}</div>
@@ -351,6 +452,7 @@
<div class="card" style="padding:16px;gap:8px"> <div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">MONITOR CZASU PRACY</div> <div class="card-kicker">MONITOR CZASU PRACY</div>
<div <div
wire:key="ticket-timer-{{ $ticket->id }}"
style="display:flex;flex-direction:column;gap:6px" style="display:flex;flex-direction:column;gap:6px"
x-data="{ x-data="{
seconds: {{ $ticket->timerElapsedSeconds() }}, seconds: {{ $ticket->timerElapsedSeconds() }},
@@ -376,16 +478,43 @@
// The timer only tracks time this ticket is actually open in // The timer only tracks time this ticket is actually open in
// the browser: stop it server-side the moment the operator // the browser: stop it server-side the moment the operator
// navigates elsewhere in the app (wire:navigate — a normal // navigates elsewhere in the app (wire:navigate — a normal
// Livewire call, since the document itself isn't unloading)… // SPA transition, since the document itself isn't unloading)…
//
// Alpine has no automatic on-element-removed hook (a
// `destroy(){}` method here — as this used to have — is never
// called by anything), so wire:navigate leaving this ticket
// page previously left `tick`'s setInterval running forever in
// the background against a stale scope, eventually throwing
// 'format is not defined' or similar once Alpine's reactivity
// for it was in a half-torn-down state. Explicitly tearing
// everything down here (the one hook that's actually invoked,
// on navigation away) fixes that at the source.
//
// This deliberately does NOT use $wire.call('stopTimer') —
// that used to be here, but firing a Livewire action from
// inside the livewire:navigating handler makes Livewire process
// that action's response (and morph the DOM for it) while the
// SPA navigation to the next page is already underway, which
// corrupts unrelated Alpine components elsewhere on the page
// (confirmed via a browser console capture: 'Illegal invocation'
// errors on the shared topbar's dropdowns, traced back to this
// exact handler in the stack). sendBeacon — the same mechanism
// pagehideHandler already uses below, for the same underlying
// reason — is a plain HTTP POST that never touches Livewire's
// component/morph pipeline, so it can't race with navigation.
this.navigatingHandler = () => { this.navigatingHandler = () => {
if (this.running) $wire.call('stopTimer'); if (this.running) {
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
}
this.teardown();
}; };
document.addEventListener('livewire:navigating', this.navigatingHandler); document.addEventListener('livewire:navigating', this.navigatingHandler);
// …or closes/reloads the tab. Regular fetch/Livewire calls // …or closes/reloads the tab. Regular fetch/Livewire calls
// can get cut off mid-flight during an actual page unload, so // can get cut off mid-flight during an actual page unload, so
// this uses sendBeacon against a plain endpoint instead, which // this uses sendBeacon against a plain endpoint instead, which
// browsers guarantee to deliver. // browsers guarantee to deliver. No teardown() needed here —
// the whole JS realm is going away with the page.
this.pagehideHandler = () => { this.pagehideHandler = () => {
if (this.running) { if (this.running) {
navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket))); navigator.sendBeacon(@js(route('operator.ticket.stop-timer', $ticket)));
@@ -393,7 +522,7 @@
}; };
window.addEventListener('pagehide', this.pagehideHandler); window.addEventListener('pagehide', this.pagehideHandler);
}, },
destroy() { teardown() {
clearInterval(this.tick); clearInterval(this.tick);
document.removeEventListener('livewire:navigating', this.navigatingHandler); document.removeEventListener('livewire:navigating', this.navigatingHandler);
window.removeEventListener('pagehide', this.pagehideHandler); window.removeEventListener('pagehide', this.pagehideHandler);
@@ -412,7 +541,12 @@
</div> </div>
@else @else
<div style="font-size:12px;display:flex;align-items:center;gap:6px"> <div style="font-size:12px;display:flex;align-items:center;gap:6px">
<span>Czas w zgłoszeniu: <strong x-text="format()"></strong></span> {{-- Guarded call: even with the interval/listener cleanup above, a
refreshTicketData() response already in flight when the operator
navigates away can still land afterward and get morphed against
a scope that's mid-teardown typeof-guarding here means that
race can no longer throw, whatever exactly triggers it. --}}
<span>Czas w zgłoszeniu: <strong x-text="typeof format === 'function' ? format() : ''"></strong></span>
<span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span> <span class="material-symbols-outlined" style="font-size:16px;cursor:pointer;opacity:0.7" wire:click="startEditTimer">edit</span>
</div> </div>
@if ($ticket->isClosed()) @if ($ticket->isClosed())

View File

@@ -0,0 +1,70 @@
@php
if (! isset($scrollTo)) {
$scrollTo = 'body';
}
$scrollIntoViewJsSnippet = ($scrollTo !== false)
? <<<JS
(\$el.closest('{$scrollTo}') || document.querySelector('{$scrollTo}')).scrollIntoView()
JS
: '';
@endphp
<div>
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="pagination-wrap">
<p class="pagination-summary">
{!! __('Showing') !!}
<strong>{{ $paginator->firstItem() }}</strong> {!! __('to') !!} <strong>{{ $paginator->lastItem() }}</strong>
{!! __('of') !!} <strong>{{ $paginator->total() }}</strong> {!! __('results') !!}
</p>
<span class="pagination-links">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</span>
@else
<button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</button>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<span aria-disabled="true" class="pagination-dots">{{ $element }}</span>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
<span wire:key="paginator-{{ $paginator->getPageName() }}-page{{ $page }}">
@if ($page == $paginator->currentPage())
<span aria-current="page"><span>{{ $page }}</span></span>
@else
<button type="button" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">
{{ $page }}
</button>
@endif
</span>
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.' . $paginator->getPageName() }}.before" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</button>
@else
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</span>
@endif
</span>
</nav>
@endif
</div>

View File

@@ -0,0 +1,56 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="pagination-wrap">
<p class="pagination-summary">
{!! __('Showing') !!}
@if ($paginator->firstItem())
<strong>{{ $paginator->firstItem() }}</strong> {!! __('to') !!} <strong>{{ $paginator->lastItem() }}</strong>
@else
{{ $paginator->count() }}
@endif
{!! __('of') !!} <strong>{{ $paginator->total() }}</strong> {!! __('results') !!}
</p>
<span class="pagination-links">
{{-- Previous Page Link --}}
@if ($paginator->onFirstPage())
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="{{ __('pagination.previous') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" /></svg>
</a>
@endif
{{-- Pagination Elements --}}
@foreach ($elements as $element)
{{-- "Three Dots" Separator --}}
@if (is_string($element))
<span aria-disabled="true" class="pagination-dots">{{ $element }}</span>
@endif
{{-- Array Of Links --}}
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page"><span>{{ $page }}</span></span>
@else
<a href="{{ $url }}" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
@endif
@endforeach
@endif
@endforeach
{{-- Next Page Link --}}
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</a>
@else
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" /></svg>
</span>
@endif
</span>
</nav>
@endif

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Support\Settings;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Schedule;
@@ -8,6 +9,16 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('tickets:check-sla-breaches')->everyFifteenMinutes(); // Intervals are admin-configurable (Admin > Konfiguracja). Each command is
Schedule::command('automation:run-rules')->everyFifteenMinutes(); // considered every minute but the ->when() closure (evaluated lazily by
Schedule::command('emails:fetch-imap')->everyFiveMinutes()->withoutOverlapping(); // schedule:run, never at boot) decides whether the configured interval has
// actually elapsed — see Settings::dueEveryMinutes() for why this can't be
// an eagerly-built cron string instead.
Schedule::command('tickets:check-sla-breaches')->everyMinute()
->when(fn () => Settings::dueEveryMinutes('schedule_sla_check_minutes', 15));
Schedule::command('automation:run-rules')->everyMinute()
->when(fn () => Settings::dueEveryMinutes('schedule_automation_rules_minutes', 15));
Schedule::command('emails:fetch-imap')->everyMinute()->withoutOverlapping()
->when(fn () => Settings::dueEveryMinutes('schedule_imap_fetch_minutes', 5));
Schedule::command('ai:run-ticket-automation')->everyMinute()->withoutOverlapping()
->when(fn () => Settings::dueEveryMinutes('schedule_ai_automation_minutes', 5));

View File

@@ -6,6 +6,7 @@ use App\Livewire\Client\Dashboard as ClientDashboard;
use App\Livewire\Client\NewTicket as ClientNewTicket; use App\Livewire\Client\NewTicket as ClientNewTicket;
use App\Livewire\Client\TicketShow as ClientTicketShow; use App\Livewire\Client\TicketShow as ClientTicketShow;
use App\Livewire\Landing; use App\Livewire\Landing;
use App\Livewire\Operator\ClientSearch as OperatorClientSearch;
use App\Livewire\Operator\NewTicket as OperatorNewTicket; use App\Livewire\Operator\NewTicket as OperatorNewTicket;
use App\Livewire\Operator\Queue as OperatorQueue; use App\Livewire\Operator\Queue as OperatorQueue;
use App\Livewire\Operator\Stats as OperatorStats; use App\Livewire\Operator\Stats as OperatorStats;
@@ -56,6 +57,7 @@ Route::middleware(['auth', 'role:operator'])->prefix('operator')->name('operator
Route::get('/', OperatorQueue::class)->name('queue'); Route::get('/', OperatorQueue::class)->name('queue');
Route::get('/new', OperatorNewTicket::class)->name('new'); Route::get('/new', OperatorNewTicket::class)->name('new');
Route::get('/stats', OperatorStats::class)->name('stats'); Route::get('/stats', OperatorStats::class)->name('stats');
Route::get('/clients', OperatorClientSearch::class)->name('clients');
Route::get('/tickets/{ticket}', OperatorTicketShow::class)->name('ticket'); Route::get('/tickets/{ticket}', OperatorTicketShow::class)->name('ticket');
// Fired via navigator.sendBeacon on tab/browser close (see the // Fired via navigator.sendBeacon on tab/browser close (see the

View File

@@ -0,0 +1,61 @@
<?php
use App\Livewire\Admin\Panel;
use App\Models\Setting;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
test('the Integracje tab shows the Integracja AI card', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->assertSee('Integracja AI');
});
test('saving the AI config persists settings and encrypts the api key at rest', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiConfig.enabled', true)
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
->set('aiConfig.apiKey', 'super-secret-key')
->set('aiConfig.model', 'llama-3.3-70b-versatile')
->set('aiConfig.verifySsl', true)
->call('saveAiConfig')
->assertOk();
expect(Settings::get('ai_base_url'))->toBe('https://api.groq.test/openai/v1');
expect(Settings::get('ai_model'))->toBe('llama-3.3-70b-versatile');
expect(Settings::get('ai_api_key'))->toBe('super-secret-key');
$stored = Setting::query()->where('key', 'ai_api_key')->value('value');
expect($stored)->not->toBe('super-secret-key');
});
test('leaving the api key field blank on save keeps the previously stored key', function () {
Settings::set('ai_api_key', 'already-stored-key');
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiConfig.enabled', true)
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
->set('aiConfig.model', 'llama-3.3-70b-versatile')
->call('saveAiConfig')
->assertOk();
expect(Settings::get('ai_api_key'))->toBe('already-stored-key');
});
test('testAiConnection reports the result of a live probe using unsaved form values', function () {
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiConfig.enabled', true)
->set('aiConfig.baseUrl', 'https://api.groq.test/openai/v1')
->set('aiConfig.apiKey', 'key')
->set('aiConfig.model', 'llama-3.3-70b-versatile')
->call('testAiConnection')
->assertSet('aiTestResult', 'ok');
});

View File

@@ -0,0 +1,85 @@
<?php
use App\Livewire\Admin\Panel;
use App\Support\Settings;
use Livewire\Livewire;
test('the Integracje tab shows the Automatyzacja AI dla zgłoszeń card', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->assertSee('Automatyzacja AI dla zgłoszeń');
});
test('saving the triage config persists all 5 toggles plus the summary toggle', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiTriageConfig.categoryWhenMissing', true)
->set('aiTriageConfig.subcategoryWhenCategoryOnly', true)
->set('aiTriageConfig.recheckCategorized', true)
->set('aiTriageConfig.fixSubject', true)
->set('aiTriageConfig.setPriority', true)
->set('aiSummaryEnabled', true)
->call('saveAiTriageConfig')
->assertOk();
expect(Settings::bool('ai_triage_category_when_missing'))->toBeTrue();
expect(Settings::bool('ai_triage_subcategory_when_category_only'))->toBeTrue();
expect(Settings::bool('ai_triage_recheck_categorized'))->toBeTrue();
expect(Settings::bool('ai_triage_fix_subject'))->toBeTrue();
expect(Settings::bool('ai_triage_set_priority'))->toBeTrue();
expect(Settings::bool('ai_summary_enabled'))->toBeTrue();
});
test('unchecking every toggle and saving turns them all back off', function () {
Settings::set('ai_triage_category_when_missing', '1');
Settings::set('ai_summary_enabled', '1');
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiTriageConfig.categoryWhenMissing', false)
->set('aiSummaryEnabled', false)
->call('saveAiTriageConfig')
->assertOk();
expect(Settings::bool('ai_triage_category_when_missing'))->toBeFalse();
expect(Settings::bool('ai_summary_enabled'))->toBeFalse();
});
test('saving the triage config also persists the regenerate-on-message toggle', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('aiSummaryRegenerateOnMessage', true)
->call('saveAiTriageConfig')
->assertOk();
expect(Settings::bool('ai_summary_regenerate_on_message'))->toBeTrue();
});
test('saveAiSummaryPrompt persists custom prompt text', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('saveAiSummaryPrompt', 'Mój niestandardowy prompt.')
->assertOk();
expect(Settings::get('ai_summary_prompt'))->toBe('Mój niestandardowy prompt.');
});
test('resetAiSummaryPrompt restores the default prompt after it was customized', function () {
$default = Settings::default('ai_summary_prompt');
$admin = adminUser();
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('saveAiSummaryPrompt', 'Coś zupełnie innego.')
->assertSet('aiSummaryPrompt', 'Coś zupełnie innego.');
expect(Settings::get('ai_summary_prompt'))->toBe('Coś zupełnie innego.');
$component->call('resetAiSummaryPrompt')
->assertSet('aiSummaryPrompt', $default);
expect(Settings::get('ai_summary_prompt'))->toBe($default);
});

View File

@@ -0,0 +1,54 @@
<?php
use App\Livewire\Admin\Panel;
use App\Support\Settings;
use Livewire\Livewire;
test('the tagging buttons are visible once BookStack is enabled', function () {
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('bookstackConfig.enabled', true)
->assertSee('Otaguj nową treść')
->assertSee('Otaguj wszystko ponownie');
});
test('clicking the normal button runs a non-force tagging pass and shows the summary', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
);
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('runBookstackTagging')
->assertSet('bookstackTagResult', ['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0])
->assertSee('Przeskanowano 2, otagowano 1, pominięto 1');
});
test('clicking the force button reclassifies already-tagged content too', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"11": ["Drukarki i skanery"]}',
);
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('runBookstackTaggingForce')
->assertSet('bookstackTagResult', ['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
});
test('running tagging without a configured AI integration shows a helpful error instead of a silent no-op', function () {
enableBookstackAndAiForTagging();
Settings::set('ai_enabled', '0');
$admin = adminUser();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->call('runBookstackTagging')
->assertSet('bookstackTagResult', null)
->assertSee('Włącz i skonfiguruj obie integracje');
});

View File

@@ -45,3 +45,59 @@ test('admin can add a description to a subcategory via the edit dialog', functio
expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.'); expect($sub->fresh()->description)->toBe('Problemy z połączeniem VPN.');
}); });
test('admin can reorder subcategories with up/down arrows', function () {
$admin = adminUser();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
$drukarki = $category->subcategories()->create(['name' => 'Drukarki', 'sort_order' => 2]);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->call('moveSubcategoryUp', $drukarki->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['VPN', 'Drukarki', 'WiFi']);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->call('moveSubcategoryDown', $vpn->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['Drukarki', 'VPN', 'WiFi']);
});
test('moving the top subcategory up or the bottom one down is a no-op', function () {
$admin = adminUser();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$vpn = $category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
$wifi = $category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->call('moveSubcategoryUp', $vpn->id)
->call('moveSubcategoryDown', $wifi->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['VPN', 'WiFi']);
});
test('a new subcategory is appended to the end of the display order', function () {
$admin = adminUser();
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$category->subcategories()->create(['name' => 'VPN', 'sort_order' => 0]);
$category->subcategories()->create(['name' => 'WiFi', 'sort_order' => 1]);
Livewire::actingAs($admin)->test(Panel::class)
->call('setTab', 'categories')
->set('newSubNames.'.$category->id, 'Drukarki')
->call('addSubcategory', $category->id)
->assertOk();
expect($category->fresh()->subcategories->pluck('name')->all())
->toBe(['VPN', 'WiFi', 'Drukarki']);
});

View File

@@ -0,0 +1,78 @@
<?php
use App\Livewire\Admin\Logs;
use App\Models\User;
use Livewire\Livewire;
function adminUserForLogsTest(): User
{
return User::query()->create(['name' => 'Admin', 'email' => 'admin-logs@example.com', 'roles' => ['admin']]);
}
beforeEach(function () {
$this->logPath = storage_path('logs/pest-admin-logs-test.log');
file_put_contents($this->logPath, implode("\n", [
'[2026-08-05 08:00:00] production.INFO: normalny wpis o połączeniu',
'[2026-08-05 08:01:00] production.ERROR: coś się nie udało',
'#0 stack trace line belonging to the ERROR entry above',
'[2026-08-05 08:02:00] production.WARNING: uwaga na przyszłość',
])."\n");
});
afterEach(function () {
@unlink($this->logPath);
});
test('admin sees the log file list and its tail content', function () {
$admin = adminUserForLogsTest();
Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->assertSee('pest-admin-logs-test.log')
->assertSee('normalny wpis o połączeniu')
->assertSee('coś się nie udało')
->assertSee('stack trace line belonging to the ERROR entry above');
});
test('multi-line entries stay grouped and level filter narrows to matching entries', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log');
expect($component->get('entries'))->toHaveCount(3);
$component->set('levelFilter', 'ERROR');
$entries = $component->get('entries');
expect($entries)->toHaveCount(1)
->and($entries[0]['text'])->toContain('coś się nie udało')
->and($entries[0]['text'])->toContain('stack trace line belonging to the ERROR entry above');
});
test('search filters entries by substring', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->set('search', 'uwaga');
expect($component->get('entries'))->toHaveCount(1)
->and($component->get('entries')[0]['text'])->toContain('uwaga na przyszłość');
});
test('selecting an unknown file name is ignored, preventing path traversal via the public property', function () {
$admin = adminUserForLogsTest();
$component = Livewire::actingAs($admin)->test(Logs::class)
->set('selectedFile', 'pest-admin-logs-test.log')
->call('selectFile', '../../.env');
expect($component->get('selectedFile'))->toBe('pest-admin-logs-test.log');
});
test('non-admin cannot open the admin panel logs tab', function () {
$operator = User::query()->create(['name' => 'Op', 'email' => 'op-logs@example.com', 'roles' => ['operator']]);
$this->actingAs($operator)->get('/admin')->assertForbidden();
});

View File

@@ -0,0 +1,129 @@
<?php
use App\Livewire\Admin\Panel;
use App\Models\Category;
use App\Models\Setting;
use App\Models\User;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
function adminUserForSnipeitTest(): User
{
return User::query()->create(['name' => 'Admin', 'email' => 'admin-snipeit@example.com', 'roles' => ['admin']]);
}
test('the Integracje tab shows the Snipe-IT card with the requested fields', function () {
$admin = adminUserForSnipeitTest();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->assertSee('Snipe-IT')
->assertSee('Adres API')
->assertSee('Klucz API')
->assertSee('Nie sprawdzaj SSL')
->assertSee('Klient może wybrać sprzęt, którego dotyczy zgłoszenie')
->assertSee('Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia')
->assertSee('Zezwól operatorowi na przeszukiwanie całego inwentarza');
});
test('the subcategory scope picker only appears once "klient może wybrać sprzęt" is checked', function () {
$admin = adminUserForSnipeitTest();
$category = Category::query()->create(['name' => 'Sprzęt']);
$category->subcategories()->create(['name' => 'Laptop']);
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->assertDontSee('Ogranicz do podkategorii')
->assertDontSee('Ogranicz do kategorii')
->set('snipeitConfig.clientCanSelectAsset', true)
->assertSee('Ogranicz do podkategorii')
->assertSee('Sprzęt / Laptop')
->assertSee('Ogranicz do kategorii')
->assertSee('Sprzęt');
});
test('an admin can allow-list a whole category for the Snipe-IT client picker', function () {
$admin = adminUserForSnipeitTest();
$itHelp = Category::query()->create(['name' => 'IT-Pomoc']);
$orders = Category::query()->create(['name' => 'Zamówienia']);
$component = Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->set('snipeitConfig.clientCanSelectAsset', true)
->call('toggleSnipeitClientCategory', $itHelp->id)
->call('toggleSnipeitClientCategory', $orders->id);
expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toEqualCanonicalizing([$itHelp->id, $orders->id]);
// Toggling again removes it.
$component->call('toggleSnipeitClientCategory', $orders->id);
expect($component->get('snipeitConfig.clientAssetCategoryIds'))->toBe([$itHelp->id]);
});
test('saving the Snipe-IT config persists settings, encrypts the token at rest, and inverts the SSL checkbox', function () {
$admin = adminUserForSnipeitTest();
$category = Category::query()->create(['name' => 'Sprzęt']);
$sub = $category->subcategories()->create(['name' => 'Laptop']);
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->set('snipeitConfig.baseUrl', 'https://assets.firma.test')
->set('snipeitConfig.apiToken', 'super-secret-token')
->set('snipeitConfig.skipSslVerification', true)
->set('snipeitConfig.clientCanSelectAsset', true)
->call('toggleSnipeitClientSubcategory', $sub->id)
->call('toggleSnipeitClientCategory', $category->id)
->set('snipeitConfig.operatorViewRequesterAssets', true)
->set('snipeitConfig.operatorSearchInventory', false)
->call('saveSnipeitConfig')
->assertOk();
expect(Settings::get('snipeit_enabled'))->toBe('1');
expect(Settings::get('snipeit_base_url'))->toBe('https://assets.firma.test');
expect(Settings::get('snipeit_api_token'))->toBe('super-secret-token');
// "Nie sprawdzaj SSL" checked means verify_ssl is stored as off.
expect(Settings::bool('snipeit_verify_ssl'))->toBeFalse();
expect(Settings::bool('snipeit_client_can_select_asset'))->toBeTrue();
expect(Settings::get('snipeit_client_asset_subcategory_ids'))->toBe((string) $sub->id);
expect(Settings::get('snipeit_client_asset_category_ids'))->toBe((string) $category->id);
expect(Settings::bool('snipeit_operator_view_requester_assets'))->toBeTrue();
expect(Settings::bool('snipeit_operator_search_inventory'))->toBeFalse();
$stored = Setting::query()->where('key', 'snipeit_api_token')->value('value');
expect($stored)->not->toBe('super-secret-token');
});
test('leaving the api token field blank on save keeps the previously stored token', function () {
Settings::set('snipeit_api_token', 'already-stored-token');
$admin = adminUserForSnipeitTest();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->set('snipeitConfig.baseUrl', 'https://assets.firma.test')
->call('saveSnipeitConfig')
->assertOk();
expect(Settings::get('snipeit_api_token'))->toBe('already-stored-token');
});
test('testSnipeitConnection reports the result of a live probe using unsaved form values', function () {
Http::fake(['assets.firma.test/*' => Http::response(['rows' => []])]);
$admin = adminUserForSnipeitTest();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->set('snipeitConfig.baseUrl', 'https://assets.firma.test')
->set('snipeitConfig.apiToken', 'tok')
->call('testSnipeitConnection')
->assertSet('snipeitTestResult', 'ok');
});
test('testSnipeitConnection requires a base URL before probing', function () {
$admin = adminUserForSnipeitTest();
Livewire::actingAs($admin)->test(Panel::class, ['tab' => 'integrations'])
->set('snipeitConfig.enabled', true)
->call('testSnipeitConnection')
->assertSet('snipeitTestResult', 'error');
});

View File

@@ -0,0 +1,66 @@
<?php
use App\Services\AiClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
test('enabled is false unless ai_enabled, ai_base_url and ai_model are all set — api key is optional', function () {
expect(app(AiClient::class)->enabled())->toBeFalse();
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
expect(app(AiClient::class)->enabled())->toBeTrue();
});
test('chat returns the assistant message content on success', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Http::fake([
'api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'hello']]]]),
]);
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBe('hello');
});
test('chat returns null on a non-successful response instead of throwing', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://api.groq.test/openai/v1');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Http::fake(['api.groq.test/*' => Http::response(['error' => 'nope'], 500)]);
expect(app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]))->toBeNull();
});
test('chat sends no Authorization header when no api key is configured (self-hosted Ollama style)', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'http://ollama.test/v1');
Settings::set('ai_model', 'llama3');
Http::fake(['ollama.test/*' => Http::response(['choices' => [['message' => ['content' => 'ok']]]])]);
app(AiClient::class)->chat([['role' => 'user', 'content' => 'hi']]);
Http::assertSent(fn ($request) => ! $request->hasHeader('Authorization'));
});
test('testConnection reports ok on a successful response', function () {
Http::fake(['api.groq.test/*' => Http::response(['choices' => [['message' => ['content' => 'pong']]]])]);
$ok = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'llama3', true);
expect($ok)->toBe(['ok' => true, 'message' => null]);
});
test('testConnection reports the provider error message on failure', function () {
Http::fake(['api.groq.test/*' => Http::response(['error' => ['message' => 'bad model']], 400)]);
$error = app(AiClient::class)->testConnection('https://api.groq.test/openai/v1', 'key', 'bad-model', true);
expect($error['ok'])->toBeFalse();
expect($error['message'])->toBe('bad model');
});

View File

@@ -0,0 +1,68 @@
<?php
use App\Jobs\GenerateTicketAiSummaryJob;
use App\Models\User;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Bus;
function enableAiSummaryRegenerateOnMessage(bool $enabled): void
{
Settings::set('ai_enabled', '1');
Settings::set('ai_summary_enabled', '1');
Settings::set('ai_summary_regenerate_on_message', $enabled ? '1' : '0');
}
test('an operator reply dispatches an immediate summary regeneration when the setting is enabled', function () {
seedStatusesAndPriorities();
enableAiSummaryRegenerateOnMessage(true);
Bus::fake();
$ticket = makeTicket();
$operator = operatorUser();
app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora.');
Bus::assertDispatchedAfterResponse(GenerateTicketAiSummaryJob::class);
});
test('a client reply does not dispatch regeneration when the setting is disabled', function () {
seedStatusesAndPriorities();
enableAiSummaryRegenerateOnMessage(false);
Bus::fake();
$ticket = makeTicket();
$client = User::factory()->create();
app(TicketService::class)->clientReply($ticket, $client, 'Odpowiedź klienta.');
Bus::assertNotDispatched(GenerateTicketAiSummaryJob::class);
});
test('an internal operator note also triggers regeneration, matching the transcript including internal notes', function () {
seedStatusesAndPriorities();
enableAiSummaryRegenerateOnMessage(true);
Bus::fake();
$ticket = makeTicket();
$operator = operatorUser();
app(TicketService::class)->operatorNote($ticket, $operator, 'Notatka wewnętrzna.');
Bus::assertDispatchedAfterResponse(GenerateTicketAiSummaryJob::class);
});
test('no regeneration is dispatched when the AI summary feature itself is off, even with the toggle on', function () {
seedStatusesAndPriorities();
Settings::set('ai_enabled', '1');
Settings::set('ai_summary_enabled', '0');
Settings::set('ai_summary_regenerate_on_message', '1');
Bus::fake();
$ticket = makeTicket();
$operator = operatorUser();
app(TicketService::class)->operatorReply($ticket, $operator, 'Odpowiedź operatora.');
Bus::assertNotDispatched(GenerateTicketAiSummaryJob::class);
});

View File

@@ -0,0 +1,154 @@
<?php
use App\Models\Category;
use App\Services\BookStackContentTagger;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
function seedTaggerSubcategory(): void
{
$category = Category::query()->create(['name' => 'IT-Pomoc']);
$category->subcategories()->create(['name' => 'Drukarki i skanery']);
}
function enableBookstackAndAiForTagging(): void
{
Settings::set('bookstack_enabled', '1');
Settings::set('bookstack_base_url', 'https://wiki.test');
Settings::set('bookstack_token_id', 'id');
Settings::set('bookstack_token_secret', 'secret');
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
}
/**
* @param array<int, array> $pageTags page id => existing tags array
*/
function fakeTaggerBookstackAndAi(array $pageTags, string $aiContent): void
{
Http::fake([
'wiki.test/api/books*' => Http::response(['data' => [], 'total' => 0]),
'wiki.test/api/chapters*' => Http::response(['data' => [], 'total' => 0]),
'wiki.test/api/pages*' => function ($request) use ($pageTags) {
$path = parse_url($request->url(), PHP_URL_PATH);
if ($request->method() === 'GET' && Str::endsWith($path, '/api/pages')) {
return Http::response([
'data' => collect($pageTags)->keys()->map(fn ($id) => ['id' => $id, 'name' => "Page {$id}"])->values()->all(),
'total' => count($pageTags),
]);
}
$id = (int) basename($path);
if ($request->method() === 'GET') {
return Http::response([
'id' => $id,
'name' => "Page {$id}",
'tags' => $pageTags[$id] ?? [],
'markdown' => 'Treść o drukarkach i skanerach.',
]);
}
if ($request->method() === 'PUT') {
return Http::response(['id' => $id]);
}
return Http::response([], 404);
},
'ai.test/*' => Http::response(['choices' => [['message' => ['content' => $aiContent]]]]),
]);
}
test('normal run tags untagged content and skips already-tagged content', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => [], 11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"10": ["Drukarki i skanery"], "11": ["Drukarki i skanery"]}',
);
$totals = app(BookStackContentTagger::class)->run();
expect($totals)->toBe(['scanned' => 2, 'tagged' => 1, 'skipped' => 1, 'failed_batches' => 0]);
Http::assertSent(fn ($r) => $r->method() === 'PUT'
&& str_contains((string) $r->url(), '/api/pages/10')
&& collect($r->data()['tags'])->contains(fn ($t) => $t['name'] === 'Drukarki i skanery'));
Http::assertNotSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
// the already-tagged page never even makes it into the AI prompt
Http::assertSent(fn ($r) => str_contains((string) $r->url(), 'ai.test')
? (str_contains($r['messages'][1]['content'], 'id=10') && ! str_contains($r['messages'][1]['content'], 'id=11'))
: true);
});
test('--dry-run classifies but never calls PUT', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => []],
aiContent: '{"10": ["Drukarki i skanery"]}',
);
$totals = app(BookStackContentTagger::class)->run(dryRun: true);
expect($totals['tagged'])->toBe(1);
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
});
test('--force re-classifies and re-writes already-tagged content', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [11 => [['name' => 'Drukarki i skanery', 'value' => '']]],
aiContent: '{"11": ["Drukarki i skanery"]}',
);
$totals = app(BookStackContentTagger::class)->run(force: true);
expect($totals)->toBe(['scanned' => 1, 'tagged' => 1, 'skipped' => 0, 'failed_batches' => 0]);
Http::assertSent(fn ($r) => $r->method() === 'PUT' && str_contains((string) $r->url(), '/api/pages/11'));
});
test('tag matching is case-insensitive when deciding whether content is already tagged', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [11 => [['name' => 'drukarki i skanery', 'value' => '']]],
aiContent: '{"11": []}',
);
$totals = app(BookStackContentTagger::class)->run();
expect($totals['skipped'])->toBe(1);
expect($totals['scanned'])->toBe(1);
});
test('a malformed AI response fails only that batch, without writing any tags', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(
pageTags: [10 => []],
aiContent: 'this is not json at all',
);
$totals = app(BookStackContentTagger::class)->run();
expect($totals)->toBe(['scanned' => 1, 'tagged' => 0, 'skipped' => 0, 'failed_batches' => 1]);
Http::assertNotSent(fn ($r) => $r->method() === 'PUT');
});
test('the tagger never touches bookshelves', function () {
seedTaggerSubcategory();
enableBookstackAndAiForTagging();
fakeTaggerBookstackAndAi(pageTags: [], aiContent: '{}');
app(BookStackContentTagger::class)->run();
Http::assertNotSent(fn ($r) => str_contains((string) $r->url(), '/api/shelves'));
});

View File

@@ -0,0 +1,54 @@
<?php
use App\Services\BookStackClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
function enableBookstackForSearch(string $searchBy = 'both'): void
{
Settings::set('bookstack_enabled', '1');
Settings::set('bookstack_base_url', 'https://wiki.test');
Settings::set('bookstack_token_id', 'id');
Settings::set('bookstack_token_secret', 'secret');
Settings::set('bookstack_search_by', $searchBy);
Settings::set('bookstack_allowed_shelf_ids_creation', '1');
Http::fake([
'wiki.test/api/shelves/1' => Http::response(['books' => [['id' => 5]]]),
'wiki.test/api/shelves*' => Http::response(['data' => [['id' => 1, 'name' => 'IT']]]),
'wiki.test/api/search*' => Http::response(['data' => []]),
]);
}
test('with search_by=both, the tags-variant query uses tagQuery while the name-variant keeps the full query', function () {
enableBookstackForSearch('both');
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '{in_name:IT-Pomoc Drukarki i skanery}');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
});
test('omitting tagQuery falls back to the full query for backward compatibility', function () {
enableBookstackForSearch('tags');
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '[IT-Pomoc Drukarki i skanery]');
});
test('search_by=tags alone sends only the tag-form query built from tagQuery, never the plain category+subcategory text', function () {
enableBookstackForSearch('tags');
app(BookStackClient::class)->search('IT-Pomoc Drukarki i skanery', 5, BookStackClient::CONTEXT_CREATION, 'Drukarki i skanery');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& ($request['query'] ?? '') === '[Drukarki i skanery]');
Http::assertNotSent(fn ($request) => str_contains((string) $request->url(), '/api/search')
&& str_contains($request['query'] ?? '', 'IT-Pomoc'));
});

View File

@@ -0,0 +1,147 @@
<?php
use App\Livewire\GlobalSearch;
use App\Livewire\Operator\Queue;
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Models\User;
use Livewire\Livewire;
test('opening a ticket records a view that surfaces in the operator sidebar, most recent first', function () {
seedStatusesAndPriorities();
$operator = operatorUser('recent-view@example.com');
$ticketA = makeTicket(['number' => '1001', 'subject' => 'First one']);
$ticketB = makeTicket(['number' => '1002', 'subject' => 'Second one']);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
$queue = Livewire::actingAs($operator)->test(Queue::class);
$recent = $queue->instance()->recentlyViewed;
expect($recent->pluck('id')->all())->toBe([$ticketB->id, $ticketA->id]);
});
test('re-opening the same ticket bumps it to the top instead of duplicating it', function () {
seedStatusesAndPriorities();
$operator = operatorUser('recent-view-bump@example.com');
$ticketA = makeTicket(['number' => '1001']);
$ticketB = makeTicket(['number' => '1002']);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketB]);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticketA]);
$recent = Livewire::actingAs($operator)->test(Queue::class)->instance()->recentlyViewed;
expect($recent->pluck('id')->all())->toBe([$ticketA->id, $ticketB->id]);
});
test('global search only returns tickets the searching user is allowed to see', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-operator@example.com');
$clientA = User::query()->create(['name' => 'Client A', 'email' => 'client-a@example.com', 'roles' => ['client']]);
$clientB = User::query()->create(['name' => 'Client B', 'email' => 'client-b@example.com', 'roles' => ['client']]);
$ticketA = makeTicket(['number' => '2001', 'subject' => 'Unikalny temat A', 'customer_id' => $clientA->id, 'email' => $clientA->email]);
makeTicket(['number' => '2002', 'subject' => 'Unikalny temat B', 'customer_id' => $clientB->id, 'email' => $clientB->email]);
// Operator sees both, scoped only by the search term.
$operatorResults = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'Unikalny')
->instance()->results;
expect($operatorResults)->toHaveCount(2);
// Client A only ever sees their own ticket, even when the term matches both.
$clientResults = Livewire::actingAs($clientA)->test(GlobalSearch::class)
->set('search', 'Unikalny')
->instance()->results;
expect($clientResults->pluck('id')->all())->toBe([$ticketA->id]);
});
test('global search returns nothing for a blank query', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-blank@example.com');
makeTicket(['number' => '3001']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)->instance()->results;
expect($results)->toBeEmpty();
});
test('the od: operator matches the reporter, not ticket content', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-od@example.com');
$byKacper = makeTicket(['number' => '4001', 'name' => 'Kacper Zbikowski', 'email' => 'kacper@example.com', 'subject' => 'Nieistotny temat']);
makeTicket(['number' => '4002', 'name' => 'Ktos Inny', 'email' => 'ktos@example.com', 'subject' => 'Kacper w temacie']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'od:Kacper')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$byKacper->id]);
});
test('the temat: operator matches only the subject', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-temat@example.com');
$matching = makeTicket(['number' => '4101', 'subject' => 'Awaria drukarki', 'body' => 'coś innego']);
makeTicket(['number' => '4102', 'subject' => 'Coś innego', 'body' => 'Awaria drukarki w opisie']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'temat:drukarki')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('the treść: operator matches only ticket/message body, not the subject', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-tresc@example.com');
$matching = makeTicket(['number' => '4201', 'subject' => 'Coś innego', 'body' => 'Drukarka nie działa od rana']);
makeTicket(['number' => '4202', 'subject' => 'Drukarka znów nie działa', 'body' => 'coś innego']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'treść:drukarka')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('the numer: operator matches the ticket number', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-numer@example.com');
$matching = makeTicket(['number' => '9999']);
makeTicket(['number' => '1111']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'numer:9999')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('operators combine with AND, matching Gmail-style multi-field search', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-combo@example.com');
$matching = makeTicket(['number' => '4301', 'name' => 'Kacper Zbikowski', 'subject' => 'Awaria drukarki']);
makeTicket(['number' => '4302', 'name' => 'Kacper Zbikowski', 'subject' => 'Inny temat']);
makeTicket(['number' => '4303', 'name' => 'Ktos Inny', 'subject' => 'Awaria drukarki']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'od:Kacper temat:drukarki')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});
test('an unrecognized "key:value" token is treated as free text instead of being silently dropped', function () {
seedStatusesAndPriorities();
$operator = operatorUser('search-unrecognized@example.com');
$matching = makeTicket(['number' => '4401', 'subject' => 'status:pilne coś']);
$results = Livewire::actingAs($operator)->test(GlobalSearch::class)
->set('search', 'status:pilne')
->instance()->results;
expect($results->pluck('id')->all())->toBe([$matching->id]);
});

View File

@@ -0,0 +1,43 @@
<?php
use App\Livewire\Operator\TicketShow;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
test('operator can manually trigger AI summary regeneration from the ticket sidebar', function () {
seedStatusesAndPriorities();
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');
Settings::set('ai_model', 'llama-3.3-70b-versatile');
Settings::set('ai_summary_enabled', '1');
Http::fake(['ai.test/*' => Http::response([
'choices' => [['message' => ['content' => '{"summary": "Ręcznie wygenerowane.", "suggested_action": null}']]],
])]);
$operator = operatorUser();
$ticket = makeTicket();
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->call('regenerateAiSummary')
->call('loadAiSummary')
->assertOk()
->assertSee('Ręcznie wygenerowane.');
expect($ticket->refresh()->ai_summary)->toBe('Ręcznie wygenerowane.');
});
test('a failed manual regeneration shows an error and leaves the previous summary intact', function () {
seedStatusesAndPriorities();
Settings::set('ai_enabled', '0');
$operator = operatorUser();
$ticket = makeTicket();
$ticket->update(['ai_summary' => 'stare podsumowanie']);
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->call('regenerateAiSummary')
->assertSet('aiSummaryRegenerateError', 'Nie udało się wygenerować podsumowania. Sprawdź konfigurację integracji AI.');
expect($ticket->refresh()->ai_summary)->toBe('stare podsumowanie');
});

Some files were not shown because too many files have changed in this diff Show More