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 */ 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 */ 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'); } }