This commit is contained in:
2026-07-21 23:39:19 +02:00
commit b33b217bdb
217 changed files with 32076 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
<?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;
#[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 === 'all'
? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed')
: $this->statuses;
}
#[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())],
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')],
'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),
];
}
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) !== '') {
$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));
}
$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 = [];
if ($key === 'all' && $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');
}
}