- 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>
This commit is contained in:
2026-07-27 21:11:21 +02:00
parent 1df697afce
commit 7a8cf2037c
32 changed files with 1735 additions and 31 deletions

View File

@@ -470,9 +470,10 @@ layered on top rather than baked into the client itself.
## BookStack integration
`App\Services\BookStackClient` is one of two outbound HTTP clients in the
codebase (Laravel's `Http` facade), alongside `AiClient` above — everything
else here only ever receives requests. It's entirely `Settings`-driven, no
`App\Services\BookStackClient` is one of three outbound HTTP clients in the
codebase (Laravel's `Http` facade), alongside `AiClient` above and
`SnipeItClient` below — everything else here only ever receives requests.
It's entirely `Settings`-driven, no
`.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`,
`bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the
LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent**
@@ -527,6 +528,70 @@ 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`,
a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker.
Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means
the picker never shows for any subcategory, not "every subcategory" —
`NewTicket::snipeitAssets()` checks both the toggle and that the currently
selected `subcategoryId` is in the list before calling
`assetsForEmail()`. `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 `tickets.snipeit_asset_id` + a cached
`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also
writes a ticket-history line) — 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
@@ -565,17 +630,20 @@ live customer submitting a ticket:
toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/
`ai_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 — never live-called from the ticket
page itself, only ever displaying whatever the scheduled command last
computed). 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
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). 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
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
@@ -583,6 +651,22 @@ live customer submitting a ticket:
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

View File

@@ -3,6 +3,54 @@
All notable changes to this project are documented in this file. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.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

View File

@@ -164,8 +164,29 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
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, refreshed as the thread grows, with
an admin-editable prompt (reset-to-default button included).
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
@@ -226,7 +247,7 @@ src/ Laravel application
app/Services/ TicketService (ticket lifecycle + notifications), BookStackClient,
ImapMailboxFetcher (I/O) + ImapMessageClassifier (pure logic),
AiClient (generic LLM client), BookStackContentTagger,
TicketAiTriageService, TicketAiSummaryService
TicketAiTriageService, TicketAiSummaryService, SnipeItClient
app/Ldap/ LDAP user model + sync handlers
database/migrations/ Schema (one file per table group, final shape)
database/seeders/ DatabaseSeeder — reference data, no ticket data

View File

@@ -74,7 +74,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl # widoczne w Admin > O aplikacji
VERSION=1.2.1 # widoczne w Admin > O aplikacji
VERSION=1.3.0 # widoczne w Admin > O aplikacji
DB_CONNECTION=mysql
DB_HOST=mariadb # nazwa serwisu z compose.yaml, NIE 127.0.0.1
@@ -369,7 +369,7 @@ APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl
AUTHOR_CONTACT=helpdesk@twoja-domena.pl
VERSION=1.2.1
VERSION=1.3.0
DB_CONNECTION=mysql
DB_HOST=127.0.0.1 # albo adres IP/hostname prawdziwego serwera DB

View File

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

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

View File

@@ -21,6 +21,7 @@ use App\Services\AiClient;
use App\Services\BookStackClient;
use App\Services\BookStackContentTagger;
use App\Services\LdapUserProvisioner;
use App\Services\SnipeItClient;
use App\Support\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
@@ -158,6 +159,12 @@ class Panel extends Component
public ?string $bookstackTagError = null;
public array $snipeitConfig = [];
public ?string $snipeitTestResult = null;
public ?string $snipeitTestMessage = null;
public array $aiConfig = [];
public ?string $aiTestResult = null;
@@ -168,6 +175,8 @@ class Panel extends Component
public bool $aiSummaryEnabled = false;
public bool $aiSummaryRegenerateOnMessage = false;
public string $aiSummaryPrompt = '';
public int $aiSummaryPromptVersion = 0;
@@ -235,6 +244,17 @@ class Panel extends Component
'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', '')),
'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'),
@@ -251,6 +271,7 @@ class Panel extends Component
'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');
}
@@ -1645,6 +1666,54 @@ class Panel extends Component
$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_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];
}
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
@@ -1689,6 +1758,7 @@ class Panel extends Component
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

View File

@@ -5,6 +5,7 @@ namespace App\Livewire\Client;
use App\Models\Category;
use App\Models\Subcategory;
use App\Services\BookStackClient;
use App\Services\SnipeItClient;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
@@ -40,6 +41,61 @@ class NewTicket extends Component
$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 the
* currently selected subcategory for it (see
* snipeit_client_asset_subcategory_ids) an empty allow-list means
* "no subcategory", not "every subcategory", mirroring how BookStack's
* shelf allow-lists work.
*
* @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 [];
}
if (! in_array($this->subcategoryId, $this->snipeitAllowedSubcategoryIds(), true)) {
return [];
}
return app(SnipeItClient::class)->assetsForEmail(Auth::user()->email);
}
/**
* @return int[]
*/
protected function snipeitAllowedSubcategoryIds(): array
{
return collect(explode(',', Settings::get('snipeit_client_asset_subcategory_ids', '')))
->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]
public function categories()
{
@@ -62,12 +118,14 @@ class NewTicket extends Component
{
$this->categoryId = $id;
$this->subcategoryId = null;
$this->selectedSnipeitAssetId = null;
$this->step = 2;
}
public function selectSubcategory(int $id): void
{
$this->subcategoryId = $id;
$this->selectedSnipeitAssetId = null;
$this->step = 3;
}
@@ -130,12 +188,18 @@ class NewTicket extends Component
$user = Auth::user();
$selectedAsset = $this->selectedSnipeitAssetId
? collect($this->snipeitAssets)->firstWhere('id', $this->selectedSnipeitAssetId)
: null;
$ticket = app(TicketService::class)->create([
'email' => $user->email,
'subcategory_id' => $this->subcategoryId,
'subject' => $this->subject,
'body' => $this->body,
'custom_values' => $this->customValues,
'snipeit_asset_id' => $selectedAsset['id'] ?? null,
'snipeit_asset_name' => $selectedAsset['label'] ?? null,
], $user);
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);

View File

@@ -14,6 +14,8 @@ use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\User;
use App\Services\BookStackClient;
use App\Services\SnipeItClient;
use App\Services\TicketAiSummaryService;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@@ -91,6 +93,26 @@ class TicketShow extends Component
$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
{
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
@@ -335,6 +357,105 @@ class TicketShow extends Component
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);
}
/**
* Live detail for the ticket's linked asset (if any) always fetched
* 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

View File

@@ -18,6 +18,7 @@ use Illuminate\Support\Facades\DB;
'sla_notified_at', 'last_customer_activity_at', 'time_spent_seconds', 'timer_started_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
{
@@ -47,6 +48,7 @@ class Ticket extends Model
'csat_rated_at' => 'datetime',
'ai_triaged_at' => 'datetime',
'ai_summary_generated_at' => 'datetime',
'snipeit_asset_id' => 'integer',
];
}

View File

@@ -3,6 +3,8 @@
namespace App\Providers;
use App\Events\NotificationCreated;
use App\Events\TicketMessagePosted;
use App\Jobs\GenerateTicketAiSummaryJob;
use App\Models\ApiClient;
use App\Models\User;
use App\Notifications\TicketNotification;
@@ -40,6 +42,7 @@ class AppServiceProvider extends ServiceProvider
$this->applyTimezoneSettingsOverride();
$this->configureApiRateLimiting();
$this->broadcastBellNotifications();
$this->regenerateAiSummaryOnNewMessage();
// 'user' backs the polymorphic notifiable_type column on the
// 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
* only ever hit the guard before rejecting with 401) get a much smaller

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

@@ -22,6 +22,8 @@ class TicketAiSummaryService
protected const MESSAGE_EXCERPT_CHARS = 1500;
protected const BODY_EXCERPT_CHARS = 4000;
protected const TRANSCRIPT_MESSAGE_LIMIT = 30;
public function __construct(protected AiClient $ai) {}
@@ -70,6 +72,20 @@ class TicketAiSummaryService
})->orderBy('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([
@@ -95,9 +111,20 @@ class TicketAiSummaryService
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}"];
$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')

View File

@@ -55,6 +55,8 @@ class TicketService
'custom_fields' => $data['custom_values'] ?? [],
'last_customer_activity_at' => now(),
'source' => $data['source'] ?? 'web',
'snipeit_asset_id' => $data['snipeit_asset_id'] ?? null,
'snipeit_asset_name' => $data['snipeit_asset_name'] ?? null,
]);
$message = $ticket->messages()->create([
@@ -213,6 +215,27 @@ class TicketService
$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
{
$categoryChanged = ($data['subcategory_id'] ?? null) !== $ticket->subcategory_id;

View File

@@ -59,6 +59,14 @@ class Settings
'bookstack_search_by' => 'both',
'bookstack_allowed_shelf_ids_creation' => '',
'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_operator_view_requester_assets' => '1',
'snipeit_operator_search_inventory' => '1',
'ai_enabled' => '0',
'ai_base_url' => '',
'ai_api_key' => '',
@@ -70,6 +78,7 @@ class Settings
'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ą, '
@@ -89,7 +98,7 @@ class Settings
.'</div>',
];
protected static array $encrypted = ['ldap_bind_password', 'mail_smtp_password', 'bookstack_token_secret', 'ai_api_key'];
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
{

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

@@ -834,6 +834,63 @@ $tabGroups = [
@endif
</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 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">Wybór sprzętu pojawi się klientowi tylko przy tworzeniu zgłoszenia w zaznaczonych tu podkategoriach. Jeśli nic nie jest zaznaczone, 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>
@@ -881,7 +938,10 @@ $tabGroups = [
<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. Odświeżane automatycznie, gdy w wątku pojawi się nowa wiadomość.</p>
<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">

View File

@@ -64,6 +64,15 @@
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</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">
<label>Temat</label>
<input class="input" wire:model="subject">

View File

@@ -119,6 +119,16 @@
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
</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-kicker">Status i priorytet</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">

View File

@@ -291,7 +291,7 @@
<div class="card-kicker">Status i przypisanie</div>
<div class="field">
<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)
<option value="{{ $s->key }}" @selected($ticket->status_key === $s->key)>{{ $s->label }}</option>
@endforeach
@@ -330,13 +330,77 @@
</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">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</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>
@@ -347,7 +411,7 @@
@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.</p>
<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>

View File

@@ -47,6 +47,17 @@ test('unchecking every toggle and saving turns them all back off', function () {
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();

View File

@@ -0,0 +1,106 @@
<?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')
->set('snipeitConfig.clientCanSelectAsset', true)
->assertSee('Ogranicz do podkategorii')
->assertSee('Sprzęt / Laptop');
});
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)
->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::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,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,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');
});

View File

@@ -98,6 +98,24 @@ test('sending via a status-changing quick action updates the ticket status', fun
->and($ticket->fresh()->messages()->where('body', 'Naprawione, proszę potwierdzić.')->exists())->toBeTrue();
});
test('sending via a status-changing quick action changes the status select\'s wire:key so the browser is forced to redraw it', function () {
// The <select> is bound via wire:change, not wire:model, so Livewire's
// morph step otherwise preserves whatever the browser already has
// selected instead of applying the freshly rendered "selected" option —
// a documented Livewire/Alpine-morph quirk for uncontrolled form
// elements. Keying the element to status_key forces a real replace.
$this->seed();
$operator = operatorUser('quickaction-wirekey@example.com');
$ticket = makeTicket(['number' => '1002', 'status_key' => 'new']);
$action = ReplyQuickAction::query()->where('label', 'Wyślij i oznacz jako rozwiązane')->firstOrFail();
Livewire::actingAs($operator)->test(TicketShow::class, ['ticket' => $ticket])
->assertSeeHtml('wire:key="ticket-status-select-new"')
->set('reply', 'Naprawione, proszę potwierdzić.')
->call('sendAndTransition', $action->id)
->assertSeeHtml('wire:key="ticket-status-select-closed"');
});
test('sending via a "nie zmieniaj" quick action posts the reply without changing the status', function () {
seedStatusesAndPriorities();
$operator = operatorUser('quickaction-nochange@example.com');

View File

@@ -0,0 +1,146 @@
<?php
use App\Services\SnipeItClient;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
function enableSnipeit(): void
{
Settings::set('snipeit_enabled', '1');
Settings::set('snipeit_base_url', 'https://assets.test');
Settings::set('snipeit_api_token', 'tok');
}
test('enabled requires enabled flag, base url and api token', function () {
expect(app(SnipeItClient::class)->enabled())->toBeFalse();
enableSnipeit();
expect(app(SnipeItClient::class)->enabled())->toBeTrue();
});
test('assetsForEmail looks up the Snipe-IT user by e-mail, then lists what is checked out to them', function () {
enableSnipeit();
Http::fake([
'assets.test/api/v1/users?*' => Http::response(['rows' => [
['id' => 7, 'email' => 'jan@example.com', 'name' => 'Jan Kowalski'],
]]),
'assets.test/api/v1/users/7/assets*' => Http::response(['rows' => [
[
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop Jana', 'serial' => 'SN12345',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
],
]]),
]);
$assets = app(SnipeItClient::class)->assetsForEmail('jan@example.com');
expect($assets)->toHaveCount(1);
expect($assets[0]['id'])->toBe(100);
expect($assets[0]['label'])->toBe('SI-001 - SN12345 - Dell Latitude 5420');
expect($assets[0]['category'])->toBe('Laptopy');
expect($assets[0]['status'])->toBe('Deployed');
expect($assets[0]['url'])->toBe('https://assets.test/hardware/100');
});
test('normalizeAsset joins only whichever of asset tag / serial / manufacturer+model are present, falling back to the asset id', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware/1' => Http::response([
'id' => 1, 'asset_tag' => 'SI-100', 'serial' => null, 'manufacturer' => null, 'model' => null,
])]);
expect(app(SnipeItClient::class)->asset(1)['label'])->toBe('SI-100');
Http::fake(['assets.test/api/v1/hardware/2' => Http::response([
'id' => 2, 'asset_tag' => null, 'serial' => null, 'manufacturer' => null, 'model' => null,
])]);
expect(app(SnipeItClient::class)->asset(2)['label'])->toBe('Zasób #2');
});
test('assetsForEmail returns nothing when no Snipe-IT user matches the e-mail', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/users?*' => Http::response(['rows' => []])]);
expect(app(SnipeItClient::class)->assetsForEmail('nobody@example.com'))->toBe([]);
});
test('assetsForEmail returns an empty list without an HTTP call when the integration is disabled', function () {
Http::fake();
expect(app(SnipeItClient::class)->assetsForEmail('jan@example.com'))->toBe([]);
Http::assertNothingSent();
});
test('searchAssets hits the hardware list endpoint with the search query', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['rows' => [
[
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
],
]])]);
$results = app(SnipeItClient::class)->searchAssets('drukarka');
expect($results)->toHaveCount(1);
expect($results[0]['label'])->toBe('SI-055 - HP LaserJet Pro');
expect($results[0]['category'])->toBe('Drukarki');
Http::assertSent(fn ($request) => str_contains((string) $request->url(), 'search=drukarka'));
});
test('searchAssets returns nothing for a blank query without calling out', function () {
enableSnipeit();
Http::fake();
expect(app(SnipeItClient::class)->searchAssets(' '))->toBe([]);
Http::assertNothingSent();
});
test('asset fetches live detail including serial, category and current assignment', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware/100' => Http::response([
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop Jana', 'serial' => 'ABC123',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
'assigned_to' => ['name' => 'Jan Kowalski'],
])]);
$asset = app(SnipeItClient::class)->asset(100);
expect($asset['label'])->toBe('SI-001 - ABC123 - Dell Latitude 5420');
expect($asset['serial'])->toBe('ABC123');
expect($asset['category'])->toBe('Laptopy');
expect($asset['assignedTo'])->toBe('Jan Kowalski');
});
test('asset returns null when the asset no longer exists in Snipe-IT', function () {
enableSnipeit();
Http::fake(['assets.test/api/v1/hardware/999' => Http::response(['status' => 'error'], 404)]);
expect(app(SnipeItClient::class)->asset(999))->toBeNull();
});
test('testConnection reports ok on a successful response', function () {
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['rows' => []])]);
$result = app(SnipeItClient::class)->testConnection('https://assets.test', 'tok', true);
expect($result)->toBe(['ok' => true, 'message' => null]);
});
test('testConnection reports the API error message on failure', function () {
Http::fake(['assets.test/api/v1/hardware?*' => Http::response(['status' => 'error', 'messages' => 'Unauthenticated.'], 401)]);
$result = app(SnipeItClient::class)->testConnection('https://assets.test', 'bad-tok', true);
expect($result['ok'])->toBeFalse();
expect($result['message'])->toBe('Unauthenticated.');
});

View File

@@ -0,0 +1,241 @@
<?php
use App\Livewire\Client\NewTicket as ClientNewTicket;
use App\Livewire\Operator\TicketShow as OperatorTicketShow;
use App\Models\Category;
use App\Models\Ticket;
use App\Models\User;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
function enableSnipeitForLinkingTest(array $overrides = []): void
{
Settings::set('snipeit_enabled', '1');
Settings::set('snipeit_base_url', 'https://assets.test');
Settings::set('snipeit_api_token', 'tok');
Settings::set('snipeit_client_can_select_asset', $overrides['client_can_select_asset'] ?? '1');
Settings::set('snipeit_operator_view_requester_assets', $overrides['operator_view_requester_assets'] ?? '1');
Settings::set('snipeit_operator_search_inventory', $overrides['operator_search_inventory'] ?? '1');
}
function fakeSnipeitUserAsset(string $email = 'client-snipeit@example.com'): void
{
Http::fake([
'assets.test/api/v1/users?*' => Http::response(['rows' => [
['id' => 7, 'email' => $email, 'name' => 'Test Client'],
]]),
'assets.test/api/v1/users/7/assets*' => Http::response(['rows' => [
[
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop klienta', 'serial' => 'SN123',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
],
]]),
'assets.test/api/v1/hardware/100' => Http::response([
'id' => 100, 'asset_tag' => 'SI-001', 'name' => 'Laptop klienta', 'serial' => 'SN123',
'manufacturer' => ['name' => 'Dell'], 'model' => ['name' => 'Latitude 5420'],
'category' => ['name' => 'Laptopy'], 'status_label' => ['name' => 'Deployed'],
'assigned_to' => ['name' => 'Test Client'],
]),
]);
}
test('a client can link one of their own Snipe-IT assets while creating a ticket, shown as serial - manufacturer model / category', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$sub = $category->subcategories()->create(['name' => 'Laptop']);
enableSnipeitForLinkingTest();
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $sub->id)
->call('loadSnipeitAssets')
->assertSee('SI-001 - SN123 - Dell Latitude 5420')
->assertSee('Laptopy')
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', 100)
->set('subject', 'Nie działa laptop')
->set('body', 'Opis problemu')
->call('submit');
$ticket = Ticket::query()->where('subject', 'Nie działa laptop')->firstOrFail();
expect($ticket->snipeit_asset_id)->toBe(100);
expect($ticket->snipeit_asset_name)->toBe('SI-001 - SN123 - Dell Latitude 5420');
});
test('selecting the same asset twice deselects it', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest();
$email = 'client-snipeit2@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('loadSnipeitAssets')
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', 100)
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', null);
});
test('the client asset picker is empty when "klient może wybrać sprzęt" is off, even for an allow-listed subcategory', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit3@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$sub = $category->subcategories()->create(['name' => 'Laptop']);
enableSnipeitForLinkingTest(['client_can_select_asset' => '0']);
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $sub->id)
->call('loadSnipeitAssets')
->assertDontSee('SN123');
});
test('the client asset picker only shows for subcategories the admin allow-listed', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit4@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$allowedSub = $category->subcategories()->create(['name' => 'Laptop']);
$otherSub = $category->subcategories()->create(['name' => 'Telefon']);
enableSnipeitForLinkingTest();
Settings::set('snipeit_client_asset_subcategory_ids', (string) $allowedSub->id);
// Allow-listed subcategory: the picker shows.
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $allowedSub->id)
->call('loadSnipeitAssets')
->assertSee('SI-001 - SN123 - Dell Latitude 5420');
// A subcategory not in the allow-list: the picker stays hidden.
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $otherSub->id)
->call('loadSnipeitAssets')
->assertDontSee('SN123');
});
test('changing subcategory clears a previously selected asset', function () {
seedStatusesAndPriorities();
$email = 'client-snipeit5@example.com';
fakeSnipeitUserAsset($email);
$client = User::query()->create(['name' => 'Test Client', 'email' => $email, 'roles' => ['client']]);
$category = Category::query()->create(['name' => 'Sprzęt']);
$sub = $category->subcategories()->create(['name' => 'Laptop']);
enableSnipeitForLinkingTest();
Settings::set('snipeit_client_asset_subcategory_ids', (string) $sub->id);
Livewire::actingAs($client)->test(ClientNewTicket::class)
->call('selectCategory', $category->id)
->call('selectSubcategory', $sub->id)
->call('selectSnipeitAsset', 100)
->assertSet('selectedSnipeitAssetId', 100)
->call('selectSubcategory', $sub->id)
->assertSet('selectedSnipeitAssetId', null);
});
test('an operator can link a Snipe-IT asset found via inventory search to an existing ticket', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest();
Http::fake([
'assets.test/api/v1/users?*' => Http::response(['rows' => []]),
'assets.test/api/v1/hardware?*' => Http::response(['rows' => [
[
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
],
]]),
'assets.test/api/v1/hardware/55' => Http::response([
'id' => 55, 'asset_tag' => 'SI-055', 'name' => null, 'serial' => null,
'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
'category' => ['name' => 'Drukarki'], 'status_label' => ['name' => 'Ready to Deploy'],
]),
]);
$operator = operatorUser();
$ticket = makeTicket();
$component = Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->set('snipeitSearchQuery', 'drukarka')
->call('searchSnipeitAssets')
->assertSee('SI-055 - HP LaserJet Pro')
->call('linkSnipeitAsset', 55);
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBe(55);
expect($ticket->snipeit_asset_name)->toBe('SI-055 - HP LaserJet Pro');
expect($ticket->histories()->latest()->first()->text)->toContain('Powiązano sprzęt');
// Unlinking works regardless of the two view/search toggles (see next test).
$component->call('unlinkSnipeitAsset');
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});
test('linking is a no-op when neither requester-assets view nor inventory search is enabled for the operator', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest(['operator_view_requester_assets' => '0', 'operator_search_inventory' => '0']);
Http::fake(['assets.test/*' => Http::response(['rows' => []])]);
$operator = operatorUser();
$ticket = makeTicket();
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->call('linkSnipeitAsset', 55);
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});
test('unlinking stays available even when both operator view/search toggles are off', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest(['operator_view_requester_assets' => '0', 'operator_search_inventory' => '0']);
$operator = operatorUser();
$ticket = makeTicket(['snipeit_asset_id' => 100, 'snipeit_asset_name' => 'SI-001 - SN123 - Dell Latitude 5420']);
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->call('unlinkSnipeitAsset');
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});
test('an operator cannot link an asset found via search when inventory search is disabled, even if the id is valid', function () {
seedStatusesAndPriorities();
enableSnipeitForLinkingTest(['operator_search_inventory' => '0']);
Http::fake(['assets.test/api/v1/hardware/55' => Http::response([
'id' => 55, 'asset_tag' => 'SI-055', 'serial' => null, 'manufacturer' => ['name' => 'HP'], 'model' => ['name' => 'LaserJet Pro'],
])]);
$operator = operatorUser();
$ticket = makeTicket();
Livewire::actingAs($operator)->test(OperatorTicketShow::class, ['ticket' => $ticket])
->set('snipeitSearchResults', [['id' => 55, 'label' => 'HP LaserJet Pro']])
->call('linkSnipeitAsset', 55);
$ticket->refresh();
expect($ticket->snipeit_asset_id)->toBeNull();
});

View File

@@ -110,6 +110,50 @@ test('a malformed AI response leaves the previous summary untouched and keeps th
expect($ticket->ai_summary_generated_at)->toBeNull();
});
test('the transcript sent to the AI includes the ticket subject, its own body, and every message tagged by role', function () {
enableAiSummary();
$ticket = summaryTicket(['subject' => 'Problem z drukarką', 'body' => 'Drukarka nie działa od rana.']);
$ticket->messages()->create(['author_name' => 'Test Client', 'body' => 'Dodatkowy szczegół.'])->attachAuthor(null, 'client');
$ticket->messages()->create(['author_name' => 'Operator', 'body' => 'Sprawdzam sprawę.'])->attachAuthor(null, 'operator');
fakeAiSummaryChat('{"summary": "ok", "suggested_action": null}');
app(TicketAiSummaryService::class)->run();
Http::assertSent(function ($request) {
$userMessage = collect($request->data()['messages'])->firstWhere('role', 'user')['content'];
return str_contains($userMessage, 'Temat: Problem z drukarką')
&& str_contains($userMessage, 'Treść:')
&& str_contains($userMessage, 'Drukarka nie działa od rana.')
&& str_contains($userMessage, '[klient] Test Client: Dodatkowy szczegół.')
&& str_contains($userMessage, '[operator] Operator: Sprawdzam sprawę.');
});
});
test('generateFor() regenerates a single ticket immediately, ignoring the staleness check', function () {
enableAiSummary();
$ticket = summaryTicket();
$ticket->update(['ai_summary' => 'aktualne podsumowanie', 'ai_summary_generated_at' => now()]);
fakeAiSummaryChat('{"summary": "Świeże podsumowanie.", "suggested_action": null}');
$result = app(TicketAiSummaryService::class)->generateFor($ticket);
expect($result)->toBeTrue();
expect($ticket->refresh()->ai_summary)->toBe('Świeże podsumowanie.');
});
test('generateFor() returns false without calling the AI when the integration is disabled', function () {
Settings::set('ai_enabled', '0');
$ticket = summaryTicket();
Http::fake();
expect(app(TicketAiSummaryService::class)->generateFor($ticket))->toBeFalse();
Http::assertNothingSent();
});
test('ai_summary_enabled=0 makes no AI calls', function () {
Settings::set('ai_enabled', '1');
Settings::set('ai_base_url', 'https://ai.test');

View File

@@ -285,6 +285,37 @@ razem, zamiast być rozrzucone po różnych zakładkach.
z linii poleceń: `php artisan bookstack:tag-content` (`--dry-run`,
`--force`, `--limit=N`).
- **Snipe-IT (ewidencja sprzętu)** — opcjonalna integracja, **domyślnie
wyłączona**. Po włączeniu:
- **Adres API, Klucz API** — osobisty token API generuje się w Snipe-IT:
profil użytkownika → „Create New Token”.
- **Nie sprawdzaj SSL** — zaznacz tylko, jeśli instancja Snipe-IT korzysta
z certyfikatu self-signed/prywatnego CA.
- **Klient może wybrać sprzęt, którego dotyczy zgłoszenie** — przy
tworzeniu zgłoszenia klient widzi listę swojego sprzętu z Snipe-IT
(dopasowanego po adresie e-mail) i może je powiązać ze zgłoszeniem. Po
zaznaczeniu pojawia się dodatkowa lista wielokrotnego wyboru **„Ogranicz
do podkategorii”** — wybór sprzętu pokaże się klientowi **tylko** dla
zaznaczonych tam podkategorii; jeśli nic nie jest zaznaczone, opcja nie
pojawi się w żadnej podkategorii (tak samo jak dozwolone półki BookStack
wyżej — trzeba świadomie wskazać zakres).
- **Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia** — ta
sama lista sprzętu zgłaszającego, tym razem w panelu bocznym operatora
na widoku zgłoszenia, z przyciskiem „Powiąż” przy każdej pozycji.
- **Zezwól operatorowi na przeszukiwanie całego inwentarza** — pole
wyszukiwania z przyciskiem „Szukaj” w tym samym panelu bocznym (nie
osobna podstrona), pozwalające powiązać dowolny sprzęt z Snipe-IT, nie
tylko sprzęt zgłaszającego — przydatne dla współdzielonego sprzętu, np.
drukarek.
- Powiązany sprzęt pokazuje się na widoku zgłoszenia jako „numer środka -
numer seryjny - producent model” oraz kategoria, z bieżącym statusem
pobieranym na żywo z Snipe-IT. Przycisk „Odepnij” jest dostępny dla
operatora zawsze, niezależnie od dwóch powyższych przełączników —
odpięcie już powiązanego sprzętu to korekta, nie nowy dostęp do
Snipe-IT.
- Przycisk **„Testuj połączenie”** działa tak samo jak przy pozostałych
integracjach.
- **Integracja AI** — opcjonalna, **domyślnie wyłączona**, ogólne połączenie z
dostawcą modelu językowego (nie tylko dla BookStacka — patrz
„Automatyzacja AI dla zgłoszeń” niżej). Pola: **adres API** (dowolny
@@ -309,8 +340,13 @@ razem, zamiast być rozrzucone po różnych zakładkach.
- **Podsumowanie AI dla operatora** — osobny przełącznik generuje krótkie
podsumowanie + sugerowaną kolejną akcję dla **każdego** zgłoszenia,
widoczne tylko operatorowi (panel boczny „Podsumowanie AI” w widoku
zgłoszenia), odświeżane automatycznie, gdy w wątku pojawi się nowa
wiadomość.
zgłoszenia). Domyślnie odświeża się cyklicznie, wraz z pozostałą
automatyzacją AI powyżej (interwał w Konfiguracji) — operator może też
w każdej chwili kliknąć **„Wygeneruj teraz”** przy podsumowaniu, żeby
odświeżyć je natychmiast. Osobny przełącznik **„Regeneruj podsumowanie
od razu po każdej nowej wiadomości”** (domyślnie wyłączony) sprawia, że
podsumowanie odświeża się samo zaraz po każdej odpowiedzi/notatce, bez
czekania na najbliższy cykl automatyzacji.
- **Prompt systemowy podsumowania** — edytowalne pole tekstowe z gotową
wartością domyślną i przyciskiem **„Resetuj”**.

View File

@@ -18,10 +18,15 @@ tylko **nieprzeczytane** powiadomienia — kliknięcie usuwa je z listy.
4. Opcjonalnie dodaj **załączniki** — przeciągnij pliki na pole załączników albo
kliknij, żeby wybrać je z dysku (limit rozmiaru/liczby/plików ustala admin —
komunikat o błędzie poinformuje, jeśli coś przekracza limit).
5. Jeśli administrator włączył podpowiedzi z bazy wiedzy, przy wyborze
5. Jeśli administrator włączył wybór sprzętu dla wybranej podkategorii,
zobaczysz listę Twojego sprzętu z ewidencji (Snipe-IT/inwentarza),
dopasowaną po Twoim adresie e-mail — kliknij „Powiąż” przy urządzeniu,
którego dotyczy zgłoszenie (opcjonalne, ponowne kliknięcie odznacza wybór).
Dostępne tylko dla podkategorii, które administrator do tego dopuścił.
6. Jeśli administrator włączył podpowiedzi z bazy wiedzy, przy wyborze
kategorii/podkategorii może pojawić się lista pasujących artykułów — warto
je sprawdzić, zanim wyślesz zgłoszenie.
6. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są
7. Wyślij zgłoszenie. Otrzymasz e-mail potwierdzający (jeśli powiadomienia są
włączone) z linkiem do podglądu, oraz — jeśli jesteś zalogowany — powiadomienie
w dzwoneczku w górnym pasku.
@@ -48,6 +53,9 @@ Otwórz dowolne zgłoszenie, by zobaczyć:
nie są widoczne dla klienta); załączone obrazy pokazują się jako miniatury,
- **historię zmian** — log statusu/priorytetu/zespołu/przypisania z datą,
- **SLA** — orientacyjny czas do rozwiązania wg priorytetu sprawy,
- jeśli zgłoszenie jest powiązane z konkretnym sprzętem (przez Ciebie przy
tworzeniu zgłoszenia albo przez operatora później) — jego nazwa w panelu
bocznym „Powiązany sprzęt”,
- jeśli administrator włączył integrację z bazą wiedzy — panel z artykułami
dopasowanymi do kategorii/podkategorii sprawy (te same podpowiedzi, co przy
tworzeniu zgłoszenia).

View File

@@ -102,10 +102,24 @@ W widoku pojedynczego zgłoszenia:
boczny podpowiada artykuły pasujące do kategorii/podkategorii zgłoszenia;
kliknięcie otwiera artykuł, przycisk „Kopiuj link" kopiuje adres bez
wychodzenia ze zgłoszenia (np. do wklejenia w odpowiedzi).
- **Sprzęt (jeśli administrator włączył integrację z Snipe-IT)** — panel
boczny może pokazywać do trzech rzeczy, zależnie od tego, co administrator
włączył: sprzęt już powiązany ze zgłoszeniem (nazwa jako „numer środka -
numer seryjny - producent model” + kategoria, status na żywo z Snipe-IT,
przycisk „Odepnij”), listę sprzętu przypisanego zgłaszającemu z przyciskiem
„Powiąż” przy każdej pozycji, oraz pole wyszukiwania „Przeszukaj inwentarz”
z przyciskiem „Szukaj”, pozwalające powiązać dowolny sprzęt z Snipe-IT (nie
tylko sprzęt zgłaszającego) — przydatne np. dla drukarki współdzielonej
przez kilka osób. „Odepnij” działa zawsze, nawet gdy administrator wyłączył
obie powyższe listy.
- **Podsumowanie AI** (jeśli administrator włączył automatyzację AI
zgłoszeń) — panel boczny widoczny tylko w panelu operatora, pokazuje krótkie
podsumowanie sprawy i sugerowaną kolejną akcję; odświeża się samo, gdy w
wątku pojawi się nowa wiadomość — nie trzeba go ręcznie odświeżać.
podsumowanie sprawy i sugerowaną kolejną akcję. Domyślnie odświeża się samo
cyklicznie (razem z pozostałą automatyzacją AI); przycisk **„Wygeneruj
teraz”** przy podsumowaniu odświeża je natychmiast na żądanie, a jeśli
administrator włączył „Regeneruj podsumowanie od razu po każdej nowej
wiadomości”, odświeży się samo zaraz po każdej nowej odpowiedzi/notatce,
bez czekania na cykl automatyzacji.
- **Ocena obsługi** — jeśli klient już ocenił zgłoszenie, ocena (gwiazdki +
ewentualny komentarz) pokazuje się w panelu bocznym, tylko do odczytu.
- **Licznik czasu pracy** — start/stop/reset przy zgłoszeniu; czas zapisuje się