- 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>
440 lines
14 KiB
PHP
440 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Operator;
|
|
|
|
use App\Models\Category;
|
|
use App\Models\Priority;
|
|
use App\Models\Status;
|
|
use App\Models\Team;
|
|
use App\Models\Ticket;
|
|
use App\Models\User;
|
|
use App\Services\TicketService;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Livewire\Attributes\Computed;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
|
|
class Queue extends Component
|
|
{
|
|
public string $queue = 'all';
|
|
|
|
public string $filterStatus = 'all';
|
|
|
|
public string $filterPriority = 'all';
|
|
|
|
public string $filterCategory = 'all';
|
|
|
|
#[Url]
|
|
public ?int $filterCustomerId = null;
|
|
|
|
public string $search = '';
|
|
|
|
public string $sortBy = 'updated_at';
|
|
|
|
public string $sortDir = 'desc';
|
|
|
|
/** @var string[] */
|
|
public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
|
|
|
|
/** @var int[] */
|
|
public array $selectedIds = [];
|
|
|
|
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()
|
|
{
|
|
return Status::query()->orderBy('sort_order')->get();
|
|
}
|
|
|
|
/**
|
|
* The status filter's option list — "Otwarte" excludes closed tickets
|
|
* entirely (they live in their own "Zamknięte" tab), so offering it as a
|
|
* filter there would only ever produce an empty result.
|
|
*/
|
|
#[Computed]
|
|
public function filterableStatuses()
|
|
{
|
|
return $this->queue === 'closed'
|
|
? $this->statuses
|
|
: $this->statuses->reject(fn (Status $s) => $s->stage === 'closed');
|
|
}
|
|
|
|
#[Computed]
|
|
public function priorities()
|
|
{
|
|
return Priority::query()->orderBy('sort_order')->get();
|
|
}
|
|
|
|
#[Computed]
|
|
public function categories()
|
|
{
|
|
return Category::query()->with('subcategories')->get();
|
|
}
|
|
|
|
/**
|
|
* A non-admin operator only ever sees the teams they're actually a
|
|
* member of — both here (sidebar tabs) and via Ticket::visibleToOperator
|
|
* (which scopes the ticket lists/counts to match).
|
|
*/
|
|
#[Computed]
|
|
public function teams()
|
|
{
|
|
$query = Team::query();
|
|
|
|
if (! Auth::user()->isAdmin()) {
|
|
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
|
}
|
|
|
|
return $query->get();
|
|
}
|
|
|
|
#[Computed]
|
|
public function filteredCustomer(): ?User
|
|
{
|
|
return $this->filterCustomerId ? User::query()->find($this->filterCustomerId) : null;
|
|
}
|
|
|
|
protected function queueDefs(): array
|
|
{
|
|
$closedKeys = Status::closedKeys();
|
|
|
|
$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())->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)->whereNotIn('status_key', $closedKeys),
|
|
];
|
|
}
|
|
|
|
return $defs;
|
|
}
|
|
|
|
#[Computed]
|
|
public function queueGroups(): array
|
|
{
|
|
$defs = $this->queueDefs();
|
|
$groups = [];
|
|
|
|
foreach ($defs as $key => $def) {
|
|
$groups[$def['group']] ??= [];
|
|
$groups[$def['group']][] = [
|
|
'key' => $key,
|
|
'label' => $def['label'],
|
|
'icon' => $def['icon'],
|
|
'count' => ($def['filter'])(Ticket::query()->visibleToOperator(Auth::user()))->count(),
|
|
'active' => $this->queue === $key,
|
|
];
|
|
}
|
|
|
|
return $groups;
|
|
}
|
|
|
|
#[Computed]
|
|
public function filteredTickets()
|
|
{
|
|
$defs = $this->queueDefs();
|
|
$query = ($defs[$this->queue]['filter'] ?? fn ($q) => $q)(Ticket::query()->visibleToOperator(Auth::user()));
|
|
|
|
if ($this->filterStatus !== 'all') {
|
|
$query->where('status_key', $this->filterStatus);
|
|
}
|
|
if ($this->filterPriority !== 'all') {
|
|
$query->where('priority_key', $this->filterPriority);
|
|
}
|
|
if ($this->filterCategory !== 'all') {
|
|
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
|
}
|
|
if ($this->filterCustomerId) {
|
|
$query->where('customer_id', $this->filterCustomerId);
|
|
}
|
|
if (trim($this->search) !== '') {
|
|
$query->search($this->search);
|
|
}
|
|
|
|
$tickets = $query->with(['subcategory.category', 'assignee', 'priority', 'status'])->get();
|
|
|
|
return $this->sortTickets($tickets);
|
|
}
|
|
|
|
/**
|
|
* Column headers are clickable rather than relying on a SQL orderBy,
|
|
* since two of the sortable columns (kategoria, przypisany) are derived
|
|
* from relations/labels rather than a plain ticket column — sorting the
|
|
* already-fetched (and typically small) collection in PHP keeps every
|
|
* column's sort using the same display value the operator actually sees.
|
|
*/
|
|
protected function sortTickets($tickets)
|
|
{
|
|
$desc = $this->sortDir === 'desc';
|
|
|
|
$sorted = match ($this->sortBy) {
|
|
'number' => $tickets->sortBy(fn (Ticket $t) => (int) $t->number, SORT_REGULAR, $desc),
|
|
'subject' => $tickets->sortBy('subject', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
|
'customer' => $tickets->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
|
'category' => $tickets->sortBy(fn (Ticket $t) => $t->categoryLabel(), SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
|
'priority' => $tickets->sortBy(fn (Ticket $t) => $t->priority?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
|
'status' => $tickets->sortBy(fn (Ticket $t) => $t->status?->sort_order ?? PHP_INT_MAX, SORT_REGULAR, $desc),
|
|
'assignee' => $tickets->sortBy(fn (Ticket $t) => $t->assignee?->name ?? '', SORT_NATURAL | SORT_FLAG_CASE, $desc),
|
|
default => $tickets->sortBy('updated_at', SORT_REGULAR, $desc),
|
|
};
|
|
|
|
return $sorted->values();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function columnDefs(): array
|
|
{
|
|
return [
|
|
'number' => 'Numer',
|
|
'subject' => 'Temat',
|
|
'customer' => 'Klient',
|
|
'category' => 'Kategoria',
|
|
'priority' => 'Priorytet',
|
|
'status' => 'Status',
|
|
'sla' => 'SLA',
|
|
'assignee' => 'Przypisany',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* SLA isn't a stored/stable value (it's computed from "now" at render
|
|
* time), so it's shown/hidden like any other column but excluded from
|
|
* click-to-sort.
|
|
*/
|
|
public function sortableColumns(): array
|
|
{
|
|
return ['number', 'subject', 'customer', 'category', 'priority', 'status', 'assignee'];
|
|
}
|
|
|
|
public function sortByColumn(string $column): void
|
|
{
|
|
if (! in_array($column, $this->sortableColumns(), true)) {
|
|
return;
|
|
}
|
|
|
|
if ($this->sortBy === $column) {
|
|
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
$this->sortBy = $column;
|
|
$this->sortDir = 'asc';
|
|
}
|
|
}
|
|
|
|
public function toggleColumn(string $column): void
|
|
{
|
|
if (in_array($column, $this->visibleColumns, true)) {
|
|
// Always leave at least one column visible.
|
|
if (count($this->visibleColumns) <= 1) {
|
|
return;
|
|
}
|
|
$this->visibleColumns = array_values(array_diff($this->visibleColumns, [$column]));
|
|
} else {
|
|
$this->visibleColumns[] = $column;
|
|
}
|
|
}
|
|
|
|
public function setQueue(string $key): void
|
|
{
|
|
$this->queue = $key;
|
|
$this->selectedIds = [];
|
|
|
|
// 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';
|
|
}
|
|
}
|
|
|
|
public function clearCustomerFilter(): void
|
|
{
|
|
$this->filterCustomerId = null;
|
|
}
|
|
|
|
public function toggleSelect(int $id): void
|
|
{
|
|
if (in_array($id, $this->selectedIds, true)) {
|
|
$this->selectedIds = array_values(array_diff($this->selectedIds, [$id]));
|
|
} else {
|
|
$this->selectedIds[] = $id;
|
|
}
|
|
}
|
|
|
|
public function mergeSelected(): void
|
|
{
|
|
$ids = $this->selectedIdsInScope();
|
|
|
|
if (count($ids) < 2) {
|
|
return;
|
|
}
|
|
|
|
app(TicketService::class)->merge($ids);
|
|
$this->selectedIds = [];
|
|
}
|
|
|
|
public function requestDeleteSelected(): void
|
|
{
|
|
if (count($this->selectedIds) < 1) {
|
|
return;
|
|
}
|
|
|
|
$this->pendingDeleteSelected = true;
|
|
}
|
|
|
|
public function cancelDeleteSelected(): void
|
|
{
|
|
$this->pendingDeleteSelected = false;
|
|
}
|
|
|
|
public function confirmDeleteSelected(): void
|
|
{
|
|
Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete();
|
|
$this->selectedIds = [];
|
|
$this->pendingDeleteSelected = false;
|
|
}
|
|
|
|
/**
|
|
* Guards against a crafted client-side call selecting ticket ids the
|
|
* operator wouldn't otherwise see, since selectedIds is just a public
|
|
* Livewire property.
|
|
*/
|
|
protected function selectedIdsInScope(): array
|
|
{
|
|
return Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id')->all();
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.operator.queue');
|
|
}
|
|
}
|