'', 'body' => '', 'categoryId' => '', 'subcategoryId' => '', 'customValues' => []]; public bool $editingReporter = false; public string $editReporterCustomerId = ''; public bool $editingTimer = false; public string $editTimerHours = '0'; public string $editTimerMinutes = '0'; public string $editTimerSeconds = '0'; public function mount(Ticket $ticket): void { abort_unless($ticket->isVisibleToOperator(Auth::user()), 403); $this->ticket = $ticket; // The timer tracks only actual time spent with the ticket open in // the browser (see stopTimer() calls wired to leaving the page in // ticket-show.blade.php) — so every open resumes it, not just the // very first one. $this->ticket->resumeTimer(); } // -------- time tracking -------- public function stopTimer(): void { $this->ticket->stopTimer(); } public function resumeTimer(): void { $this->ticket->resumeTimer(); } public function resetTimer(): void { $this->ticket->resetTimer(); } public function startEditTimer(): void { $seconds = $this->ticket->timerElapsedSeconds(); $this->editTimerHours = (string) intdiv($seconds, 3600); $this->editTimerMinutes = (string) intdiv($seconds % 3600, 60); $this->editTimerSeconds = (string) ($seconds % 60); $this->editingTimer = true; } public function cancelEditTimer(): void { $this->editingTimer = false; } public function saveEditTimer(): void { $seconds = ((int) $this->editTimerHours) * 3600 + ((int) $this->editTimerMinutes) * 60 + ((int) $this->editTimerSeconds); $this->ticket->setTimeSpent($seconds); $this->editingTimer = false; } public function updatedReplyAttachments(): void { if (! $this->replyAttachments) { return; } if ($error = Settings::validateAttachments($this->replyAttachments)) { $this->replyAttachments = []; $this->addError('replyAttachments', $error); } } public function removeReplyAttachment(int $index): void { unset($this->replyAttachments[$index]); $this->replyAttachments = array_values($this->replyAttachments); } public function updatedNoteAttachments(): void { if (! $this->noteAttachments) { return; } if ($error = Settings::validateAttachments($this->noteAttachments)) { $this->noteAttachments = []; $this->addError('noteAttachments', $error); } } public function removeNoteAttachment(int $index): void { unset($this->noteAttachments[$index]); $this->noteAttachments = array_values($this->noteAttachments); } #[Computed] public function publicMessages() { return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get(); } #[Computed] public function internalMessages() { return $this->ticket->internalMessages()->with(['author', 'authorLink.role', 'attachments'])->get(); } #[Computed] public function statuses() { return Status::query()->orderBy('sort_order')->get(); } #[Computed] public function priorities() { return Priority::query()->orderBy('sort_order')->get(); } #[Computed] public function categories() { return Category::query()->with('subcategories')->get(); } /** * @return array */ #[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). */ #[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 operators() { return User::query()->withRole('operator')->orderBy('name')->get(); } #[Computed] public function clients() { return User::query()->withRole('client')->orderBy('name')->get(); } #[Computed] public function responseTemplates() { return ResponseTemplate::query()->get(); } #[Computed] public function replyQuickActions() { return ReplyQuickAction::query()->orderBy('sort_order')->get(); } #[Computed] public function editDetailsSubcategoryOptions() { if (! $this->editDetailsForm['categoryId']) { return collect(); } return Subcategory::query()->where('category_id', $this->editDetailsForm['categoryId'])->get(); } #[Computed] public function editDetailsSelectedSubcategory(): ?Subcategory { return $this->editDetailsForm['subcategoryId'] ? Subcategory::query()->with('customFields')->find($this->editDetailsForm['subcategoryId']) : null; } // -------- status / priority / assignee / team -------- public function setStatus(string $key): void { app(TicketService::class)->setStatus($this->ticket, $key); $this->ticket->refresh(); } public function setPriority(string $key): void { app(TicketService::class)->setPriority($this->ticket, $key); $this->ticket->refresh(); } public function setAssignee(string $id): void { app(TicketService::class)->setAssignee($this->ticket, $id ? User::query()->find($id) : null); $this->ticket->refresh(); } public function assignToMe(): void { app(TicketService::class)->setAssignee($this->ticket, Auth::user()); $this->ticket->refresh(); } public function setTeam(string $id): void { app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null); $this->ticket->refresh(); } // -------- reporter -------- public function startEditReporter(): void { $this->editingReporter = true; $this->editReporterCustomerId = (string) $this->ticket->customer_id; } public function cancelEditReporter(): void { $this->editingReporter = false; } public function saveReporter(): void { $customer = User::query()->find($this->editReporterCustomerId); if ($customer) { app(TicketService::class)->setReporter($this->ticket, $customer); $this->ticket->refresh(); } $this->editingReporter = false; } // -------- details -------- public function toggleEditDetails(): void { $this->editingDetails = true; $this->editDetailsForm = [ 'subject' => $this->ticket->subject, 'body' => $this->ticket->body, 'categoryId' => $this->ticket->subcategory?->category_id, 'subcategoryId' => $this->ticket->subcategory_id, 'customValues' => $this->ticket->custom_fields ?? [], ]; } public function cancelEditDetails(): void { $this->editingDetails = false; } public function updatedEditDetailsForm($value, $key): void { if ($key === 'categoryId') { $this->editDetailsForm['subcategoryId'] = ''; } } public function saveDetails(): void { app(TicketService::class)->updateDetails($this->ticket, [ 'subject' => $this->editDetailsForm['subject'], 'body' => $this->editDetailsForm['body'], 'subcategory_id' => $this->editDetailsForm['subcategoryId'] ?: null, 'custom_values' => $this->editDetailsForm['customValues'], ]); $this->ticket->refresh(); $this->editingDetails = false; } // -------- internal notes -------- public function startAddNote(): void { $this->addingNote = true; } public function cancelAddNote(): void { $this->addingNote = false; $this->reset(['noteDraft', 'noteAttachments']); } public function addNote(): void { if (! trim($this->noteDraft)) { return; } app(TicketService::class)->operatorNote($this->ticket, Auth::user(), $this->noteDraft, $this->noteAttachments); $this->reset(['noteDraft', 'noteAttachments']); $this->addingNote = false; unset($this->internalMessages); } public function startEditNote(int $id, string $body): void { $this->editingNoteId = $id; $this->editingNoteDraft = $body; } public function cancelEditNote(): void { $this->editingNoteId = null; $this->editingNoteDraft = ''; } public function saveEditNote(): void { $message = TicketMessage::query()->findOrFail($this->editingNoteId); $message->update(['body' => $this->editingNoteDraft, 'edited' => true]); $this->cancelEditNote(); unset($this->internalMessages); } // -------- public replies -------- public function setTemplate(string $id): void { $this->templateId = $id; $template = ResponseTemplate::query()->find($id); if ($template) { $this->reply = $template->body; } } protected function resetReply(): void { $this->reply = ''; $this->replyAttachments = []; $this->templateId = ''; unset($this->publicMessages); $this->ticket->refresh(); } public function sendReply(): void { if (! trim($this->reply)) { return; } app(TicketService::class)->operatorReply($this->ticket, Auth::user(), $this->reply, attachments: $this->replyAttachments); $this->resetReply(); } public function sendAndTransition(int $quickActionId): void { if (! trim($this->reply)) { return; } $status = ReplyQuickAction::query()->find($quickActionId)?->status_key; app(TicketService::class)->operatorReply($this->ticket, Auth::user(), $this->reply, $status, $this->replyAttachments); $this->resetReply(); } // -------- message edit/delete (shared by public + internal bubbles) -------- public function startEditMessage(int $id, string $body): void { $message = TicketMessage::query()->findOrFail($id); abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403); $this->editingMessageId = $id; $this->editingMessageDraft = $body; } public function cancelEditMessage(): void { $this->editingMessageId = null; $this->editingMessageDraft = ''; } public function saveEditMessage(): void { $message = TicketMessage::query()->findOrFail($this->editingMessageId); abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403); $message->update(['body' => $this->editingMessageDraft, 'edited' => true]); $this->cancelEditMessage(); unset($this->publicMessages, $this->internalMessages); } public function requestDeleteMessage(int $id): void { $this->pendingDeleteMessageId = $id; } public function cancelDeleteMessage(): void { $this->pendingDeleteMessageId = null; } public function confirmDeleteMessage(): void { $message = TicketMessage::query()->findOrFail($this->pendingDeleteMessageId); abort_unless($message->ticket_id === $this->ticket->id && $message->role === 'operator', 403); $message->delete(); $this->pendingDeleteMessageId = null; unset($this->publicMessages, $this->internalMessages); } // -------- delete ticket -------- public function requestDeleteTicket(): void { $this->pendingDeleteTicket = true; } public function cancelDeleteTicket(): void { $this->pendingDeleteTicket = false; } public function confirmDeleteTicket(): void { $this->ticket->delete(); $this->redirect(route('operator.queue'), navigate: true); } public function render() { // Checkpoints the running timer into the stored total on every // action (any wire:click/change re-renders the component), so // progress is saved incrementally rather than only on explicit stop. $this->ticket->flushTimer(); return view('livewire.operator.ticket-show'); } }