Compare commits
1 Commits
1df697afce
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a8cf2037c |
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