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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 21:11:21 +02:00
parent 1df697afce
commit 7a8cf2037c
32 changed files with 1735 additions and 31 deletions

View File

@@ -834,6 +834,63 @@ $tabGroups = [
@endif
</form>
<form wire:submit="saveSnipeitConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Snipe-IT (ewidencja sprzętu)</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Pokazuje sprzęt przypisany do zgłaszającego przy tworzeniu i przeglądaniu zgłoszenia oraz pozwala powiązać zgłoszenie z konkretnym urządzeniem z ewidencji Snipe-IT.</span>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.enabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Włącz integrację z Snipe-IT</strong></label>
@if ($snipeitConfig['enabled'])
<div class="field"><label>Adres API</label><input class="input" placeholder="https://assets.firma.pl" wire:model="snipeitConfig.baseUrl"></div>
<div class="field"><label>Klucz API</label><input class="input" type="password" placeholder="(bez zmian jeśli puste)" wire:model="snipeitConfig.apiToken"></div>
<p class="text-muted" style="font-size:11.5px;margin:-4px 0 0">Osobisty token API generuje się w Snipe-IT: profil użytkownika &rarr; „Create New Token”.</p>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.skipSslVerification" style="position:static;opacity:1;width:auto;height:auto">Nie sprawdzaj SSL</label>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Zaznacz tylko, jeśli instancja Snipe-IT korzysta z certyfikatu self-signed / z prywatnego CA.</span>
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<div class="field">
<label>Klient</label>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.clientCanSelectAsset" style="position:static;opacity:1;width:auto;height:auto">Klient może wybrać sprzęt, którego dotyczy zgłoszenie</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Przy tworzeniu zgłoszenia klient zobaczy listę swojego sprzętu z Snipe-IT (dopasowanego po adresie e-mail) i będzie mógł je powiązać ze zgłoszeniem.</p>
</div>
@if ($snipeitConfig['clientCanSelectAsset'])
<div class="field">
<label>Ogranicz do podkategorii</label>
<x-multiselect
:options="$this->subcategoriesForTeamForm"
:selected-ids="$snipeitConfig['clientAssetSubcategoryIds']"
toggle-action="toggleSnipeitClientSubcategory"
placeholder="Brak wybranych podkategorii"
/>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Wybór sprzętu pojawi się klientowi tylko przy tworzeniu zgłoszenia w zaznaczonych tu podkategoriach. Jeśli nic nie jest zaznaczone, opcja nie pojawi się w żadnej podkategorii.</p>
</div>
@endif
<div style="border-top:1px solid var(--color-divider);margin:4px 0"></div>
<div class="field">
<label>Operator</label>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorViewRequesterAssets" style="position:static;opacity:1;width:auto;height:auto">Operator może zobaczyć sprzęt zgłaszającego w widoku zgłoszenia</label>
<label class="radio"><input type="checkbox" wire:model="snipeitConfig.operatorSearchInventory" style="position:static;opacity:1;width:auto;height:auto">Zezwól operatorowi na przeszukiwanie całego inwentarza (nie tylko sprzętu zgłaszającego)</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Obie funkcje pojawiają się w bocznym panelu widoku zgłoszenia operatora przeszukiwanie inwentarza jako pole wyszukiwania z przyciskiem „Szukaj”, nie osobna podstrona. Odpięcie już powiązanego urządzenia jest zawsze dostępne dla operatora, niezależnie od tych dwóch ustawień.</p>
</div>
<div style="display:flex;gap:10px;margin-top:8px;align-items:center;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" wire:click="testSnipeitConnection">Testuj połączenie</button>
<button type="submit" class="btn btn-primary">Zapisz</button>
@if ($snipeitTestResult === 'ok')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-success)"><span class="material-symbols-outlined" style="font-size:18px">check_circle</span>Połączenie OK</div>
@elseif ($snipeitTestResult === 'error')
<div style="display:flex;align-items:center;gap:6px;color:var(--color-danger)"><span class="material-symbols-outlined" style="font-size:18px">error</span>Błąd połączenia{{ $snipeitTestMessage ? ': '.$snipeitTestMessage : '' }}</div>
@endif
</div>
@else
<button type="submit" class="btn btn-primary" style="align-self:flex-start">Zapisz</button>
@endif
</form>
<form wire:submit="saveAiConfig" class="card" style="padding:20px;gap:14px">
<h4 style="margin:0">Integracja AI</h4>
<span class="text-muted" style="font-size:11.5px;margin-top:-8px">Ogólne połączenie z dostawcą modelu językowego (API kompatybilne z OpenAI Groq, OpenAI, lokalny Ollama itp.), wykorzystywane m.in. do automatycznego tagowania treści w BookStack.</span>
@@ -881,7 +938,10 @@ $tabGroups = [
<div class="field">
<label>Podsumowanie AI dla operatora</label>
<label class="radio"><input type="checkbox" wire:model="aiSummaryEnabled" style="position:static;opacity:1;width:auto;height:auto"><strong>Generuj podsumowanie i sugerowaną akcję dla każdego zgłoszenia</strong></label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Odświeżane automatycznie, gdy w wątku pojawi się nowa wiadomość.</p>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Widoczne wyłącznie w panelu operatora, w bocznym panelu zgłoszenia. Domyślnie odświeżane cyklicznie (co kilka minut, wraz z pozostałą automatyzacją AI powyżej).</p>
<label class="radio"><input type="checkbox" wire:model="aiSummaryRegenerateOnMessage" style="position:static;opacity:1;width:auto;height:auto">Regeneruj podsumowanie od razu po każdej nowej wiadomości</label>
<p class="text-muted" style="font-size:11.5px;margin:2px 0 0">Zamiast czekać na najbliższy cykl automatyzacji dotyczy odpowiedzi operatora, klienta i notatek wewnętrznych.</p>
</div>
<div class="field">

View File

@@ -64,6 +64,15 @@
<x-bookstack-suggestions :articles="$this->suggestedArticles" />
</div>
<div wire:init="loadSnipeitAssets">
<x-snipeit-assets
:assets="$this->snipeitAssets"
title="Twój sprzęt (inwentarz) — powiąż, jeśli zgłoszenie go dotyczy"
:selectable="\App\Support\Settings::bool('snipeit_client_can_select_asset')"
:selected-id="$selectedSnipeitAssetId"
/>
</div>
<div class="field">
<label>Temat</label>
<input class="input" wire:model="subject">

View File

@@ -119,6 +119,16 @@
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" />
</div>
@if ($ticket->snipeit_asset_name)
<div class="card" style="padding:16px;gap:6px">
<div class="card-kicker">Powiązany sprzęt</div>
<div style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:500">
<span class="material-symbols-outlined" style="font-size:18px;color:var(--color-accent)">devices</span>
{{ $ticket->snipeit_asset_name }}
</div>
</div>
@endif
<div class="card" style="padding:16px;gap:10px">
<div class="card-kicker">Status i priorytet</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">

View File

@@ -291,7 +291,7 @@
<div class="card-kicker">Status i przypisanie</div>
<div class="field">
<label>Status</label>
<select class="input" wire:change="setStatus($event.target.value)">
<select class="input" wire:key="ticket-status-select-{{ $ticket->status_key }}" wire:change="setStatus($event.target.value)">
@foreach ($this->statuses as $s)
<option value="{{ $s->key }}" @selected($ticket->status_key === $s->key)>{{ $s->label }}</option>
@endforeach
@@ -330,13 +330,77 @@
</div>
</div>
@if (\App\Support\Settings::bool('snipeit_enabled'))
@if ($ticket->snipeit_asset_id)
@php $linkedAsset = $this->snipeitLinkedAsset; @endphp
<div class="card" style="padding:16px;gap:6px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div class="card-kicker">Powiązany sprzęt</div>
<button type="button" class="btn btn-ghost" style="font-size:11px;padding:2px 6px" wire:click="unlinkSnipeitAsset">Odepnij</button>
</div>
<a href="{{ $linkedAsset['url'] ?? '#' }}" target="_blank" rel="noopener noreferrer" style="display:flex;gap:8px;align-items:flex-start;min-width:0;text-decoration:none;color:inherit">
<span class="material-symbols-outlined" style="font-size:18px;flex:none;margin-top:1px;color:var(--color-accent)">devices</span>
<span style="min-width:0">
<span style="display:block;font-size:13px;font-weight:500;color:var(--color-accent)">{{ $linkedAsset['label'] ?? $ticket->snipeit_asset_name }}</span>
@if ($linkedAsset)
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">{{ collect([$linkedAsset['category'] ?? null, $linkedAsset['status'] ?? null, $linkedAsset['assignedTo'] ?? null])->filter()->implode(' · ') }}</span>
@else
<span style="display:block;font-size:11px;color:color-mix(in srgb, var(--color-text) 55%, transparent)">Niedostępne w Snipe-IT (brak połączenia lub usunięto)</span>
@endif
</span>
</a>
</div>
@endif
@if (\App\Support\Settings::bool('snipeit_operator_view_requester_assets'))
<div wire:init="loadSnipeitAssets">
<x-snipeit-assets
:assets="$this->snipeitRequesterAssets"
variant="sidebar"
title="Sprzęt zgłaszającego"
:selectable="true"
select-action="linkSnipeitAsset"
:selected-id="$ticket->snipeit_asset_id"
/>
</div>
@endif
@if (\App\Support\Settings::bool('snipeit_operator_search_inventory'))
<div class="card" style="padding:16px;gap:8px">
<div class="card-kicker">Przeszukaj inwentarz</div>
<div style="display:flex;gap:6px">
<input class="input" style="flex:1" placeholder="Nr inwentarzowy, model, nazwa..." wire:model="snipeitSearchQuery" wire:keydown.enter.prevent="searchSnipeitAssets">
<button type="button" class="btn btn-secondary" style="flex:none" wire:click="searchSnipeitAssets">Szukaj</button>
</div>
<x-snipeit-assets
:assets="$snipeitSearchResults"
variant="sidebar"
:card="false"
:selectable="true"
select-action="linkSnipeitAsset"
:selected-id="$ticket->snipeit_asset_id"
/>
</div>
@endif
@endif
<div wire:init="loadSuggestedArticles">
<x-bookstack-suggestions :articles="$this->suggestedArticles" variant="sidebar" title="Baza wiedzy" :show-copy="true" />
</div>
@if (\App\Support\Settings::bool('ai_summary_enabled'))
<div wire:init="loadAiSummary" class="card" style="padding:16px;gap:8px">
<div 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>