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>
This commit is contained in:
110
ARCHITECTURE.md
110
ARCHITECTURE.md
@@ -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
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -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
|
||||
|
||||
27
README.md
27
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
30
src/app/Jobs/GenerateTicketAiSummaryJob.php
Normal file
30
src/app/Jobs/GenerateTicketAiSummaryJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
212
src/app/Services/SnipeItClient.php
Normal file
212
src/app/Services/SnipeItClient.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
64
src/resources/views/components/snipeit-assets.blade.php
Normal file
64
src/resources/views/components/snipeit-assets.blade.php
Normal 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
|
||||
@@ -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 → „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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 class="card-kicker">Podsumowanie AI</div>
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
106
src/tests/Feature/AdminSnipeitIntegrationConfigTest.php
Normal file
106
src/tests/Feature/AdminSnipeitIntegrationConfigTest.php
Normal 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');
|
||||
});
|
||||
68
src/tests/Feature/AiSummaryRegenerateOnMessageTest.php
Normal file
68
src/tests/Feature/AiSummaryRegenerateOnMessageTest.php
Normal 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);
|
||||
});
|
||||
43
src/tests/Feature/OperatorAiSummaryRegenerateTest.php
Normal file
43
src/tests/Feature/OperatorAiSummaryRegenerateTest.php
Normal 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');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
146
src/tests/Feature/SnipeItClientTest.php
Normal file
146
src/tests/Feature/SnipeItClientTest.php
Normal 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.');
|
||||
});
|
||||
241
src/tests/Feature/SnipeitAssetLinkingTest.php
Normal file
241
src/tests/Feature/SnipeitAssetLinkingTest.php
Normal 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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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”**.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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ę
|
||||
|
||||
Reference in New Issue
Block a user