- In-app notifications: a bell in the top bar backed by Laravel's database
  notification channel, alongside existing e-mail notifications (same
  per-trigger toggle drives both; ticket links now correctly point into the
  recipient's own area instead of always linking to the client view).
- Drag-and-drop attachments on every upload form, plus inline image
  thumbnails in the message thread instead of a plain download link.
- Customer satisfaction (CSAT) rating: clients rate a closed ticket 1-5 stars
  with an optional comment; shown read-only to operators, surfaced as a KPI
  on the stats dashboard, and linked from the "ticket closed" e-mail.
- Saved queue views: operators can save/apply/delete named filter+sort+
  column presets in the ticket queue and mark one as their default.
- Full-text search (MySQL FULLTEXT, portable LIKE fallback) across ticket
  subject/body and reply message bodies, now also on the client's own ticket
  list.
- Stats CSV export for the currently filtered ticket set.
- Optional BookStack knowledge-base integration (off by default): suggests
  articles by category/subcategory while creating a ticket and in a separate
  sidebar for operators on an existing ticket (with a copy-link button).
  Configurable connection/SSL bypass/search-type filter, plus two
  independent per-shelf allow-lists so nothing is ever searched until an
  admin opts specific shelves in.
- Closed tickets no longer show in "Moje zgłoszenia"/"Nieprzypisane"/team
  queue tabs, only under "Zamknięte" (matching how "Otwarte" already worked).
- Wired up the Admin > About "Wersja" field to config('app.version')/VERSION
  in .env instead of a stale hardcoded string.
- Fixed: TicketService::setStatus() now checks a status's stage rather than
  the literal key 'closed' to decide whether to fire the "ticket closed"
  notification/stop the timer.
- Updated README/ARCHITECTURE/CHANGELOG/install/SECURITY docs and all three
  wiki/ role guides for the above; documented a root-vs-www-data file
  ownership gotcha in CLAUDE.md (running artisan commands via a plain
  `docker exec` can leave root-owned Blade cache files that later break
  recompilation for the www-data Apache process).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 15:18:09 +02:00
parent 4e8f17189a
commit 90fae0a4de
49 changed files with 1649 additions and 79 deletions

View File

@@ -15,8 +15,10 @@ use App\Models\Subcategory;
use App\Models\Team;
use App\Models\User;
use App\Models\UserField;
use App\Services\BookStackClient;
use App\Services\LdapUserProvisioner;
use App\Support\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
@@ -137,6 +139,12 @@ class Panel extends Component
public ?string $mailTestResult = null;
public array $bookstackConfig = [];
public ?string $bookstackTestResult = null;
public ?string $bookstackTestMessage = null;
// ---- generic pending-delete confirm ----
public ?string $pendingDeleteType = null;
@@ -187,6 +195,18 @@ class Panel extends Component
'fromAddress' => Settings::get('mail_from_address'),
'fromName' => Settings::get('mail_from_name'),
];
$this->bookstackConfig = [
'enabled' => Settings::bool('bookstack_enabled'),
'baseUrl' => Settings::get('bookstack_base_url'),
'tokenId' => Settings::get('bookstack_token_id'),
'tokenSecret' => Settings::get('bookstack_token_secret'),
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
'searchTypes' => Settings::get('bookstack_search_types', 'both'),
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
];
}
public function setTab(string $tab): void
@@ -965,7 +985,7 @@ class Panel extends Component
* key-primary-keyed, sort_order-ordered lists edited the same way: swap
* this row's sort_order with its immediate neighbor in the given direction.
*
* @param \Illuminate\Support\Collection<int, Status|Priority> $ordered
* @param Collection<int, Status|Priority> $ordered
*/
protected function swapAdjacentSortOrder($ordered, string $key, int $direction): void
{
@@ -1288,6 +1308,90 @@ class Panel extends Component
}
}
// ===================== BOOKSTACK CONFIG =====================
public function saveBookstackConfig(): void
{
Settings::set('bookstack_enabled', $this->bookstackConfig['enabled'] ? '1' : '0');
Settings::set('bookstack_base_url', $this->bookstackConfig['baseUrl']);
Settings::set('bookstack_token_id', $this->bookstackConfig['tokenId']);
if ($this->bookstackConfig['tokenSecret']) {
Settings::set('bookstack_token_secret', $this->bookstackConfig['tokenSecret']);
}
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0');
if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) {
Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']);
}
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
Settings::set('bookstack_allowed_shelf_ids_ticket_view', implode(',', $this->bookstackConfig['allowedShelfIdsTicketView']));
$this->bookstackTestResult = null;
$this->bookstackTestMessage = null;
}
#[Computed]
public function bookstackShelves(): array
{
return app(BookStackClient::class)->shelves();
}
/**
* Drops the cached shelf list (and its dependent shelf>book map) so a
* shelf added/renamed/removed in BookStack shows up in both checklists
* right away, then re-fetches shared by both "Dozwolone półki"
* checklists since they list the exact same shelves.
*/
public function refreshBookstackShelves(): void
{
app(BookStackClient::class)->clearShelfCache();
unset($this->bookstackShelves);
}
/**
* @return int[]
*/
protected function parseShelfIds(string $raw): array
{
return collect(explode(',', $raw))->map(fn ($v) => (int) trim($v))->filter()->values()->all();
}
/**
* $field is 'allowedShelfIdsCreation' or 'allowedShelfIdsTicketView'
* the two independent allow-lists (ticket-creation suggestions vs. the
* operator ticket-view sidebar) toggled by their own checklist.
*/
public function toggleBookstackAllowedShelf(string $field, int $id): void
{
$ids = $this->bookstackConfig[$field];
$this->bookstackConfig[$field] = in_array($id, $ids, true)
? array_values(array_diff($ids, [$id]))
: [...$ids, $id];
}
public function testBookstackConnection(): void
{
$cfg = $this->bookstackConfig;
if (empty($cfg['baseUrl']) || empty($cfg['tokenId'])) {
$this->bookstackTestResult = 'error';
$this->bookstackTestMessage = 'Uzupełnij adres instancji i Token ID.';
return;
}
$tokenSecret = $cfg['tokenSecret'] ?: Settings::get('bookstack_token_secret');
$result = app(BookStackClient::class)->testConnection($cfg['baseUrl'], $cfg['tokenId'], $tokenSecret ?? '', (bool) $cfg['verifySsl']);
$this->bookstackTestResult = $result['ok'] ? 'ok' : 'error';
$this->bookstackTestMessage = $result['message'];
}
// ===================== MAIL / SMTP CONFIG =====================
public function saveMailConfig(): void

View File

@@ -11,10 +11,13 @@ class Dashboard extends Component
{
public string $tab = 'current';
public string $search = '';
#[Computed]
public function tickets()
{
return Auth::user()->ticketsAsCustomer()
->search($this->search)
->with('subcategory.category')
->orderByDesc('updated_at')
->get();

View File

@@ -4,6 +4,7 @@ namespace App\Livewire\Client;
use App\Models\Category;
use App\Models\Subcategory;
use App\Services\BookStackClient;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
@@ -60,6 +61,17 @@ class NewTicket extends Component
$this->step = 3;
}
/**
* @return array<int, array{name: string, url: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function backToCategory(): void
{
$this->step = 1;

View File

@@ -27,6 +27,10 @@ class TicketShow extends Component
public ?int $pendingDeleteMessageId = null;
public ?int $csatRating = null;
public string $csatComment = '';
public function mount(Ticket $ticket): void
{
abort_unless($ticket->customer_id === Auth::id(), 403);
@@ -86,6 +90,14 @@ class TicketShow extends Component
$this->ticket->refresh();
}
public function submitCsat(): void
{
$this->validate(['csatRating' => 'required|integer|between:1,5']);
app(TicketService::class)->submitCsat($this->ticket, $this->csatRating, $this->csatComment ?: null);
$this->ticket->refresh();
}
public function startEdit(int $messageId, string $body): void
{
$message = TicketMessage::query()->findOrFail($messageId);

View File

@@ -6,6 +6,7 @@ use App\Models\Category;
use App\Models\Subcategory;
use App\Models\Ticket;
use App\Models\User;
use App\Services\BookStackClient;
use App\Services\LdapUserProvisioner;
use App\Services\TicketService;
use App\Support\Settings;
@@ -72,6 +73,27 @@ class Landing extends Component
$this->step = 3;
}
/**
* Unlike the logged-in Client/Operator wizards, this guest-facing form
* only shows suggestions when the admin has explicitly opted into
* exposing them to anonymous visitors (bookstack_show_to_guests)
* suggested KB article titles/links could otherwise leak internal
* content to the public.
*
* @return array<int, array{name: string, url: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
if (! Settings::bool('bookstack_show_to_guests')) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function backToCategory(): void
{
$this->step = 1;

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Component;
class NotificationBell extends Component
{
#[Computed]
public function notifications()
{
return Auth::user()->notifications()->latest()->limit(20)->get();
}
#[Computed]
public function unreadCount(): int
{
return Auth::user()->unreadNotifications()->count();
}
public function markAsRead(string $id): void
{
Auth::user()->notifications()->where('id', $id)->first()?->markAsRead();
unset($this->notifications, $this->unreadCount);
}
public function markAllAsRead(): void
{
Auth::user()->unreadNotifications->each->markAsRead();
unset($this->notifications, $this->unreadCount);
}
public function render()
{
return view('livewire.notification-bell');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Livewire\Operator;
use App\Models\Category;
use App\Models\Subcategory;
use App\Models\User;
use App\Services\BookStackClient;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
@@ -69,6 +70,17 @@ class NewTicket extends Component
$this->step = 3;
}
/**
* @return array<int, array{name: string, url: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function backToCategory(): void
{
$this->step = 1;

View File

@@ -41,6 +41,125 @@ class Queue extends Component
public bool $pendingDeleteSelected = false;
#[Url]
public ?int $savedViewId = null;
public string $newViewName = '';
/**
* A bare visit (no explicit ?savedViewId=... in the URL, i.e. Livewire
* never bound one) auto-applies the operator's default saved view, if
* they have one an explicit savedViewId in the URL always wins.
*/
public function mount(): void
{
if ($this->savedViewId !== null) {
return;
}
$default = Auth::user()->savedQueueViews()->where('is_default', true)->first();
if ($default) {
$this->applyViewFilters($default->filters);
$this->savedViewId = $default->id;
}
}
#[Computed]
public function savedViews()
{
return Auth::user()->savedQueueViews()->orderBy('name')->get();
}
/**
* @return array<string, mixed>
*/
protected function snapshotFilters(): array
{
return [
'queue' => $this->queue,
'filterStatus' => $this->filterStatus,
'filterPriority' => $this->filterPriority,
'filterCategory' => $this->filterCategory,
'search' => $this->search,
'sortBy' => $this->sortBy,
'sortDir' => $this->sortDir,
'visibleColumns' => $this->visibleColumns,
];
}
protected function applyViewFilters(array $filters): void
{
$this->queue = $filters['queue'] ?? $this->queue;
$this->filterStatus = $filters['filterStatus'] ?? $this->filterStatus;
$this->filterPriority = $filters['filterPriority'] ?? $this->filterPriority;
$this->filterCategory = $filters['filterCategory'] ?? $this->filterCategory;
$this->search = $filters['search'] ?? $this->search;
$this->sortBy = $filters['sortBy'] ?? $this->sortBy;
$this->sortDir = $filters['sortDir'] ?? $this->sortDir;
$this->visibleColumns = $filters['visibleColumns'] ?? $this->visibleColumns;
$this->selectedIds = [];
}
public function saveCurrentView(): void
{
$name = trim($this->newViewName);
if ($name === '') {
return;
}
$view = Auth::user()->savedQueueViews()->create([
'name' => $name,
'filters' => $this->snapshotFilters(),
]);
$this->savedViewId = $view->id;
$this->newViewName = '';
unset($this->savedViews);
}
/**
* Always scoped to the current user (never a bare SavedQueueView::find())
* savedViewId/id args here are client-controllable, same defensive
* pattern as selectedIdsInScope() for bulk ticket actions.
*/
public function applySavedView(int $id): void
{
$view = Auth::user()->savedQueueViews()->find($id);
if (! $view) {
return;
}
$this->applyViewFilters($view->filters);
$this->savedViewId = $view->id;
}
public function deleteSavedView(int $id): void
{
Auth::user()->savedQueueViews()->where('id', $id)->delete();
if ($this->savedViewId === $id) {
$this->savedViewId = null;
}
unset($this->savedViews);
}
public function setDefaultView(int $id): void
{
$view = Auth::user()->savedQueueViews()->find($id);
if (! $view) {
return;
}
Auth::user()->savedQueueViews()->where('id', '!=', $id)->update(['is_default' => false]);
$view->update(['is_default' => true]);
unset($this->savedViews);
}
#[Computed]
public function statuses()
{
@@ -55,9 +174,9 @@ class Queue extends Component
#[Computed]
public function filterableStatuses()
{
return $this->queue === 'all'
? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed')
: $this->statuses;
return $this->queue === 'closed'
? $this->statuses
: $this->statuses->reject(fn (Status $s) => $s->stage === 'closed');
}
#[Computed]
@@ -101,15 +220,15 @@ class Queue extends Component
$defs = [
'all' => ['label' => 'Otwarte', 'icon' => 'inbox', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNotIn('status_key', $closedKeys)],
'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())],
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')],
'mine' => ['label' => 'Moje zgłoszenia', 'icon' => 'assignment_ind', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->where('assignee_id', Auth::id())->whereNotIn('status_key', $closedKeys)],
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')->whereNotIn('status_key', $closedKeys)],
'closed' => ['label' => 'Zamknięte', 'icon' => 'archive', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereIn('status_key', $closedKeys)],
];
foreach ($this->teams as $team) {
$defs['team:'.$team->id] = [
'label' => $team->name, 'icon' => 'groups', 'group' => 'Zespoły',
'filter' => fn ($q) => $q->where('team_id', $team->id),
'filter' => fn ($q) => $q->where('team_id', $team->id)->whereNotIn('status_key', $closedKeys),
];
}
@@ -155,11 +274,7 @@ class Queue extends Component
$query->where('customer_id', $this->filterCustomerId);
}
if (trim($this->search) !== '') {
$term = '%'.trim($this->search).'%';
$query->where(fn ($q) => $q->where('number', 'like', $term)
->orWhere('subject', 'like', $term)
->orWhere('name', 'like', $term)
->orWhere('email', 'like', $term));
$query->search($this->search);
}
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
@@ -251,7 +366,11 @@ class Queue extends Component
$this->queue = $key;
$this->selectedIds = [];
if ($key === 'all' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
// Every tab except "closed" now excludes closed-stage tickets (see
// queueDefs()), so a stale closed-stage status filter would silently
// zero out the list on any other tab — clear it on every tab switch
// away from "closed", not just when landing on "all".
if ($key !== 'closed' && $this->filterStatus !== 'all' && Status::stageFor($this->filterStatus) === 'closed') {
$this->filterStatus = 'all';
}
}

View File

@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Symfony\Component\HttpFoundation\StreamedResponse;
class Stats extends Component
{
@@ -152,6 +153,23 @@ class Stats extends Component
'avgFirstResponseHours' => $this->avgFirstResponseHours(),
'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
'sla' => $this->slaBreachStats($closedKeys),
'csat' => $this->csatStats($closedKeys),
];
}
/**
* Response rate is against closed tickets (the only ones that can ever
* be rated see Ticket::csatSubmittable()), not the whole filtered set.
*/
protected function csatStats(array $closedKeys): array
{
$closedTotal = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count();
$rated = (clone $this->baseQuery)->whereNotNull('csat_rating')->get(['csat_rating']);
return [
'avg' => $rated->isEmpty() ? null : round($rated->avg('csat_rating'), 2),
'count' => $rated->count(),
'responseRate' => $closedTotal > 0 ? round($rated->count() / $closedTotal * 100, 1) : null,
];
}
@@ -387,6 +405,40 @@ class Stats extends Component
$this->range = $range;
}
/**
* Row-per-ticket CSV of everything the active filters/date range
* currently show streamed directly, no temp file, no new dependency.
*/
public function export(): StreamedResponse
{
$tickets = (clone $this->baseQuery)
->with(['subcategory.category', 'assignee', 'team', 'status', 'priority'])
->orderBy('tickets.created_at')
->get();
return response()->streamDownload(function () use ($tickets) {
$out = fopen('php://output', 'w');
fputcsv($out, ['Numer', 'Temat', 'Status', 'Priorytet', 'Kategoria', 'Zespół', 'Operator', 'Utworzono', 'Zaktualizowano', 'Ocena CSAT'], escape: '\\');
foreach ($tickets as $ticket) {
fputcsv($out, [
$ticket->number,
$ticket->subject,
$ticket->statusLabel(),
$ticket->priorityLabel(),
$ticket->categoryLabel(),
$ticket->team?->name,
$ticket->assignee?->name,
$ticket->created_at,
$ticket->updated_at,
$ticket->csat_rating,
], escape: '\\');
}
fclose($out);
}, 'statystyki-'.now()->format('Y-m-d').'.csv');
}
public function render()
{
return view('livewire.operator.stats');

View File

@@ -12,6 +12,7 @@ use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Models\User;
use App\Services\BookStackClient;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
@@ -185,6 +186,18 @@ class TicketShow extends Component
return Category::query()->with('subcategories')->get();
}
/**
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
$subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW);
}
/**
* A non-admin operator can only reassign a ticket to one of their own
* teams (mirrors the visibility scoping in Operator\Queue).