- Real-time updates (Laravel Reverb): live operator queue, live ticket
  chat/detail updates for operator and client, periodic fallback refresh
  with a visible countdown as a backstop for dropped websocket connections.
- SLA automation rules (Admin > Automatyzacja SLA): act on a ticket after
  N minutes of customer silence (change priority/status/team/assignee),
  evaluated every 15 minutes, reusing TicketService's own setters so
  automated changes get the same history/notification/broadcast a manual
  change would.
- New notification: every operator on a matching team gets notified when
  a new ticket lands in one of their subcategories.
- BookStack knowledge-base sidebar now also shown on the client's own
  ticket view (previously operator-only); suggestions everywhere now load
  in after first paint instead of blocking it.
- Client ticket view: shows assigned operator + team; page widened to
  match the operator's.
- Notification bell shows unread only; read notifications disappear
  instead of just dimming.
- Stats dashboard: sectioned layout, new breakdowns (by subcategory, CSAT
  by team/operator, top clients, client x subcategory cross-tab).
- Mobile: nav dropdowns (theme/notifications/profile) now expand full
  width instead of overflowing off-screen below 640px.
- Fixed two bugs that silently disabled all real-time updates (missing
  CSRF header on Echo's private-channel auth; a script-load-order race
  that could miss the livewire:init event) and the mariadb healthcheck
  (world-writable credentials file on this stack's NFS mount).
- Assorted test-suite fixes (roles virtual attribute needs the roles
  table seeded; a few missing seeds/wrong assertions found along the way).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:43:05 +02:00
parent def7c70887
commit 0b06687ea1
64 changed files with 3613 additions and 168 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Admin;
use App\Models\AutomationRule;
use App\Models\Category;
use App\Models\CustomField;
use App\Models\EmailTemplate;
@@ -100,6 +101,15 @@ class Panel extends Component
public array $responseTemplateForm = ['id' => null, 'label' => '', 'body' => ''];
// ---- automation rules ----
public bool $automationRuleFormOpen = false;
public array $automationRuleForm = [
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
'action_type' => 'change_priority', 'action_value' => '',
];
// ---- templates ----
public ?int $editingTemplateId = null;
@@ -854,6 +864,96 @@ class Panel extends Component
$this->requestDelete('response-template', $id, 'Szablon odpowiedzi zostanie usunięty z listy dostępnej operatorom.');
}
// ===================== AUTOMATION RULES =====================
#[Computed]
public function automationRules()
{
return AutomationRule::query()->orderBy('id')->get();
}
public function openAutomationRuleForm(): void
{
$this->automationRuleForm = [
'id' => null, 'label' => '', 'enabled' => true, 'condition_minutes' => 60,
'scope_priority_key' => '', 'scope_subcategory_id' => '', 'scope_team_id' => '',
'action_type' => 'change_priority', 'action_value' => '',
];
$this->automationRuleFormOpen = true;
}
public function editAutomationRule(int $id): void
{
$rule = AutomationRule::query()->findOrFail($id);
$this->automationRuleForm = [
'id' => $rule->id,
'label' => $rule->label,
'enabled' => $rule->enabled,
'condition_minutes' => $rule->condition_minutes,
'scope_priority_key' => $rule->scope_priority_key ?? '',
'scope_subcategory_id' => $rule->scope_subcategory_id ?? '',
'scope_team_id' => $rule->scope_team_id ?? '',
'action_type' => $rule->action_type,
'action_value' => $rule->action_value,
];
$this->automationRuleFormOpen = true;
}
public function closeAutomationRuleForm(): void
{
$this->automationRuleFormOpen = false;
}
// Switching action_type invalidates whichever action_value was picked for
// the previous type (e.g. a priority key isn't a valid team id).
public function updatedAutomationRuleFormActionType(): void
{
$this->automationRuleForm['action_value'] = '';
}
public function submitAutomationRule(): void
{
$this->validate([
'automationRuleForm.label' => 'required|string|max:255',
'automationRuleForm.condition_minutes' => 'required|integer|min:1',
'automationRuleForm.action_type' => 'required|in:'.implode(',', AutomationRule::ACTION_TYPES),
'automationRuleForm.action_value' => 'required|string',
]);
$data = [
'label' => $this->automationRuleForm['label'],
'enabled' => (bool) $this->automationRuleForm['enabled'],
'condition_minutes' => (int) $this->automationRuleForm['condition_minutes'],
'scope_priority_key' => $this->automationRuleForm['scope_priority_key'] ?: null,
'scope_subcategory_id' => $this->automationRuleForm['scope_subcategory_id'] ?: null,
'scope_team_id' => $this->automationRuleForm['scope_team_id'] ?: null,
'action_type' => $this->automationRuleForm['action_type'],
'action_value' => $this->automationRuleForm['action_value'],
];
if ($this->automationRuleForm['id']) {
AutomationRule::query()->find($this->automationRuleForm['id'])?->update($data);
} else {
AutomationRule::query()->create($data);
}
$this->automationRuleFormOpen = false;
unset($this->automationRules);
}
public function toggleAutomationRuleEnabled(int $id): void
{
$rule = AutomationRule::query()->find($id);
$rule?->update(['enabled' => ! $rule->enabled]);
unset($this->automationRules);
}
public function removeAutomationRule(int $id): void
{
$this->requestDelete('automation-rule', $id, 'Reguła automatyzacji zostanie usunięta.');
}
// ===================== STATUSES / PRIORITIES =====================
#[Computed]
@@ -1491,10 +1591,11 @@ class Panel extends Component
'user-field' => UserField::query()->find($this->pendingDeleteId)?->delete(),
'reply-quick-action' => ReplyQuickAction::query()->find($this->pendingDeleteId)?->delete(),
'response-template' => ResponseTemplate::query()->find($this->pendingDeleteId)?->delete(),
'automation-rule' => AutomationRule::query()->find($this->pendingDeleteId)?->delete(),
default => null,
};
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings);
unset($this->categories, $this->customFields, $this->statuses, $this->priorities, $this->teams, $this->users, $this->userFields, $this->replyQuickActions, $this->responseTemplates, $this->notificationSettings, $this->automationRules);
$this->cancelPendingDelete();
}

View File

@@ -30,6 +30,16 @@ class NewTicket extends Component
public array $attachments = [];
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
#[Computed]
public function categories()
{
@@ -67,6 +77,10 @@ class NewTicket extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);

View File

@@ -4,10 +4,12 @@ namespace App\Livewire\Client;
use App\Models\Ticket;
use App\Models\TicketMessage;
use App\Services\BookStackClient;
use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -31,6 +33,16 @@ class TicketShow extends Component
public string $csatComment = '';
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the ticket page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
public function mount(Ticket $ticket): void
{
abort_unless($ticket->customer_id === Auth::id(), 403);
@@ -44,12 +56,76 @@ class TicketShow extends Component
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
}
/**
* Bridged from a TicketMessagePosted broadcast on this ticket's own
* channel (see resources/js/echo.js) an operator's reply appears
* without the client refreshing, the "live chat" effect. Ignores events
* for any other ticket id since Livewire dispatches are page-wide.
*/
#[On('ticket-message-posted')]
public function onTicketMessagePosted(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
unset($this->ticketMessages);
$this->ticket->refresh();
}
/**
* Bridged from a TicketQueueChanged broadcast on this ticket's own
* channel lets the client see a status/priority/team/assignee change
* (and the resulting history entry) made by an operator, or by an
* automation rule firing in the background, without refreshing.
*/
#[On('queue-changed')]
public function onQueueChanged(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
$this->ticket->refresh();
}
/**
* Periodic fallback refresh (see the countdown badge in the blade view)
* broadcasting is best-effort, a dropped websocket connection
* shouldn't mean the thread/ticket silently stops updating.
*/
public function refreshTicketData(): void
{
unset($this->ticketMessages);
$this->ticket->refresh();
}
#[Computed]
public function otherTickets()
{
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
}
/**
* Same allow-listed shelves (CONTEXT_CREATION) and category/subcategory
* query the ticket-creation wizard used, so the client sees the same
* suggestions here as they did while writing the ticket.
*
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
*/
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);
}
public function updatedAttachments(): void
{
if (! $this->attachments) {

View File

@@ -36,6 +36,16 @@ class Landing extends Component
public ?int $submittedTicketId = null;
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
#[Computed]
public function categories()
{
@@ -85,7 +95,7 @@ class Landing extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! Settings::bool('bookstack_show_to_guests')) {
if (! $this->suggestedArticlesLoaded || ! Settings::bool('bookstack_show_to_guests')) {
return [];
}

View File

@@ -8,10 +8,16 @@ use Livewire\Component;
class NotificationBell extends Component
{
/**
* Only unread once a notification is read (clicked through, or via
* "mark all as read"), it disappears from the bell rather than staying
* listed dimmed. The full history still lives in the notifications
* table for anyone querying it directly, just not surfaced here.
*/
#[Computed]
public function notifications()
{
return Auth::user()->notifications()->latest()->limit(20)->get();
return Auth::user()->unreadNotifications()->latest()->limit(20)->get();
}
#[Computed]

View File

@@ -33,6 +33,16 @@ class NewTicket extends Component
public array $attachments = [];
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
#[Computed]
public function clients()
{
@@ -76,6 +86,10 @@ class NewTicket extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
return app(BookStackClient::class)->search($query);

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Operator;
use App\Events\TicketQueueChanged;
use App\Models\Category;
use App\Models\Priority;
use App\Models\Status;
@@ -11,6 +12,7 @@ use App\Models\User;
use App\Services\TicketService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
@@ -65,6 +67,33 @@ class Queue extends Component
}
}
/**
* Bridged from a TicketQueueChanged broadcast via resources/js/echo.js
* see that file for why this is a plain Livewire event rather than an
* `#[On('echo-private:...')]` attribute. Re-queries through the same
* visibleToOperator()-scoped computed property a manual refresh would
* use, so a ticket that just became invisible to this operator (closed,
* reassigned away, moved to another team) simply won't come back, and
* its id is pruned from the current selection so a stale checkbox
* doesn't linger for a row that's no longer on screen.
*/
#[On('queue-changed')]
public function onQueueChanged(int $ticketId = 0): void
{
$this->refreshQueue();
}
/**
* Periodic fallback refresh (see the countdown badge next to "Kolumny"
* in the blade view) broadcasting is best-effort, a dropped websocket
* connection shouldn't mean the queue silently stops updating.
*/
public function refreshQueue(): void
{
unset($this->filteredTickets);
$this->selectedIds = array_values(array_intersect($this->selectedIds, $this->filteredTickets->pluck('id')->all()));
}
#[Computed]
public function savedViews()
{
@@ -417,7 +446,13 @@ class Queue extends Component
public function confirmDeleteSelected(): void
{
Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->delete();
$ids = Ticket::query()->visibleToOperator(Auth::user())->whereIn('id', $this->selectedIds)->pluck('id');
Ticket::query()->whereIn('id', $ids)->delete();
foreach ($ids as $id) {
TicketQueueChanged::dispatch($id, 'deleted', Auth::id());
}
$this->selectedIds = [];
$this->pendingDeleteSelected = false;
}

View File

@@ -312,6 +312,25 @@ class Stats extends Component
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
}
/**
* One level deeper than byCategory() same shape, but grouped by the
* actual subcategory, labeled "Category / Subcategory" to disambiguate
* subcategories that share a name across different parent categories.
*/
#[Computed]
public function bySubcategory()
{
return (clone $this->baseQuery)
->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id')
->select('subcategories.id', 'categories.name as category_name', 'subcategories.name as sub_name', DB::raw('count(*) as count'))
->groupBy('subcategories.id', 'categories.name', 'subcategories.name')
->orderByDesc('count')
->get()
->map(fn ($row) => ['label' => $row->category_name.' / '.$row->sub_name, 'count' => (int) $row->count]);
}
#[Computed]
public function byTeam()
{
@@ -351,6 +370,170 @@ class Stats extends Component
return $rows;
}
/**
* Unlike teams/operators (small, fixed sets), the customer list is
* unbounded capped to the top 10 by ticket volume in the current
* filtered range rather than listing every client who ever wrote in.
* Guest submissions (no account) are summed into one "Goście" bucket
* rather than grouped by e-mail, since a guest has no stable identity
* to rank against registered clients.
*/
#[Computed]
public function byCustomer()
{
$rows = (clone $this->baseQuery)
->whereNotNull('tickets.customer_id')
->join('users', 'users.id', '=', 'tickets.customer_id')
->select('users.id', 'users.name as label', DB::raw('count(*) as count'))
->groupBy('users.id', 'users.name')
->orderByDesc('count')
->limit(10)
->get()
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
$guestCount = (clone $this->baseQuery)->whereNull('tickets.customer_id')->count();
if ($guestCount > 0) {
$rows->push(['label' => 'Goście (bez konta)', 'count' => $guestCount]);
}
return $rows->sortByDesc('count')->values();
}
/**
* Client × subcategory cross-tab which clients' tickets fall into
* which kind of subcategory. Both dimensions are unbounded (unlike
* teams/operators), so this caps to the top 10 clients by overall
* volume (rows, mirroring byCustomer()) and the top 5 subcategories by
* overall volume (columns, mirroring assigneeSubcategoryMatrix()'s
* "Inne" folding) otherwise the table could grow arbitrarily in both
* directions. Guest tickets (no customer_id) are excluded entirely
* rather than folded into one "guest" row, since mixing a real client's
* per-subcategory pattern with an anonymous aggregate wouldn't mean
* anything.
*
* @return array{columns: array<int, string>, hasOther: bool, rows: array<int, array{label: string, cells: array<int, int>, other: ?int, total: int}>}
*/
#[Computed]
public function customerSubcategoryMatrix(): array
{
$raw = (clone $this->baseQuery)
->whereNotNull('tickets.customer_id')
->whereNotNull('tickets.subcategory_id')
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
->join('categories', 'categories.id', '=', 'subcategories.category_id')
->join('users', 'users.id', '=', 'tickets.customer_id')
->select(
'tickets.customer_id',
'users.name as customer_name',
'subcategories.id as subcategory_id',
'categories.name as category_name',
'subcategories.name as subcategory_name',
DB::raw('count(*) as total'),
)
->groupBy('tickets.customer_id', 'users.name', 'subcategories.id', 'categories.name', 'subcategories.name')
->get();
if ($raw->isEmpty()) {
return ['columns' => [], 'hasOther' => false, 'rows' => []];
}
$subcategoryTotals = $raw->groupBy('subcategory_id')->map(fn ($g) => $g->sum('total'));
$topSubcategoryIds = $subcategoryTotals->sortDesc()->keys()->take(5);
$subcategoryLabels = $raw->unique('subcategory_id')->keyBy('subcategory_id')
->map(fn ($r) => $r->category_name.' / '.$r->subcategory_name);
$columns = $topSubcategoryIds->map(fn ($id) => $subcategoryLabels[$id])->values()->all();
$hasOther = $subcategoryTotals->keys()->diff($topSubcategoryIds)->isNotEmpty();
$byCustomer = $raw->groupBy('customer_id');
$customerNames = $raw->unique('customer_id')->keyBy('customer_id')->map(fn ($r) => $r->customer_name);
$topCustomerIds = $byCustomer->map(fn ($g) => $g->sum('total'))->sortDesc()->keys()->take(10);
$rows = $topCustomerIds
->map(function ($customerId) use ($byCustomer, $customerNames, $topSubcategoryIds, $hasOther) {
$entries = $byCustomer->get($customerId, collect());
$bySubcategory = $entries->keyBy('subcategory_id');
return [
'label' => $customerNames[$customerId],
'cells' => $topSubcategoryIds->map(fn ($id) => (int) ($bySubcategory[$id]->total ?? 0))->values()->all(),
'other' => $hasOther ? (int) $entries->whereNotIn('subcategory_id', $topSubcategoryIds->all())->sum('total') : null,
'total' => (int) $entries->sum('total'),
];
})
->values()
->all();
return ['columns' => $columns, 'hasOther' => $hasOther, 'rows' => $rows];
}
/**
* Average CSAT rating per team, only among rated tickets in the current
* filtered range mirrors byTeam()'s "Bez zespołu" bucket handling, but
* teams/buckets with zero ratings are dropped entirely (an average of
* nothing isn't a meaningful bar to draw).
*/
#[Computed]
public function csatByTeam()
{
$stats = (clone $this->baseQuery)
->whereNotNull('csat_rating')
->select('team_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
->groupBy('team_id')
->get();
$avgs = $stats->pluck('avg_rating', 'team_id');
$counts = $stats->pluck('rated_count', 'team_id');
$rows = $this->teams
->map(fn (Team $t) => [
'label' => $t->name,
'avg' => isset($avgs[$t->id]) ? round((float) $avgs[$t->id], 2) : null,
'count' => (int) ($counts[$t->id] ?? 0),
])
->filter(fn ($row) => $row['count'] > 0)
->values();
if ($counts->get(null, 0)) {
$rows->push(['label' => 'Bez zespołu', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
}
return $rows->sortByDesc('avg')->values();
}
/**
* Average CSAT rating per assignee, same shape/semantics as csatByTeam().
*/
#[Computed]
public function csatByAssignee()
{
$stats = (clone $this->baseQuery)
->whereNotNull('csat_rating')
->select('assignee_id', DB::raw('avg(csat_rating) as avg_rating'), DB::raw('count(*) as rated_count'))
->groupBy('assignee_id')
->get();
$avgs = $stats->pluck('avg_rating', 'assignee_id');
$counts = $stats->pluck('rated_count', 'assignee_id');
$rows = $this->operators
->map(fn (User $u) => [
'label' => $u->name,
'avg' => isset($avgs[$u->id]) ? round((float) $avgs[$u->id], 2) : null,
'count' => (int) ($counts[$u->id] ?? 0),
])
->filter(fn ($row) => $row['count'] > 0)
->values();
if ($counts->get(null, 0)) {
$rows->push(['label' => 'Nieprzypisane', 'avg' => round((float) $avgs->get(null), 2), 'count' => (int) $counts->get(null)]);
}
return $rows->sortByDesc('avg')->values();
}
/**
* Daily created-vs-closed volume, capped at the most recent 60 days so a
* wide range (or "Cały okres") never renders an unreadably thin column

View File

@@ -2,6 +2,7 @@
namespace App\Livewire\Operator;
use App\Events\TicketQueueChanged;
use App\Models\Category;
use App\Models\Priority;
use App\Models\ReplyQuickAction;
@@ -17,6 +18,7 @@ use App\Services\TicketService;
use App\Support\Settings;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithFileUploads;
@@ -66,6 +68,16 @@ class TicketShow extends Component
public string $editTimerSeconds = '0';
// Set via wire:init (see the blade view) rather than on the initial
// render, so the BookStack HTTP call in suggestedArticles() never
// delays the ticket page's first paint — it loads in a beat later instead.
public bool $suggestedArticlesLoaded = false;
public function loadSuggestedArticles(): void
{
$this->suggestedArticlesLoaded = true;
}
public function mount(Ticket $ticket): void
{
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
@@ -168,6 +180,50 @@ class TicketShow extends Component
return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
}
/**
* Bridged from a TicketMessagePosted broadcast on this ticket's own
* channel (see resources/js/echo.js) a new reply/note from the other
* party appears without the viewer refreshing, the "live chat" effect.
* Ignores events for any other ticket id, since Livewire dispatches are
* page-wide and this component only cares about its own ticket.
*/
#[On('ticket-message-posted')]
public function onTicketMessagePosted(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
unset($this->publicMessages, $this->internalMessages);
}
/**
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
* and Queue::onQueueChanged()) lets a status/priority/team/assignee
* change made by another operator, or by an automation rule firing in
* the background, show up live on a ticket someone currently has open.
*/
#[On('queue-changed')]
public function onQueueChanged(int $ticketId): void
{
if ($ticketId !== $this->ticket->id) {
return;
}
$this->ticket->refresh();
}
/**
* Periodic fallback refresh (see the countdown badge in the blade view)
* broadcasting is best-effort, a dropped websocket connection
* shouldn't mean the thread/ticket silently stops updating.
*/
public function refreshTicketData(): void
{
unset($this->publicMessages, $this->internalMessages);
$this->ticket->refresh();
}
#[Computed]
public function statuses()
{
@@ -192,6 +248,10 @@ class TicketShow extends Component
#[Computed]
public function suggestedArticles(): array
{
if (! $this->suggestedArticlesLoaded) {
return [];
}
$subcategory = $this->ticket->subcategory;
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
@@ -500,7 +560,9 @@ class TicketShow extends Component
public function confirmDeleteTicket(): void
{
$ticketId = $this->ticket->id;
$this->ticket->delete();
TicketQueueChanged::dispatch($ticketId, 'deleted', Auth::id());
$this->redirect(route('operator.queue'), navigate: true);
}