v1.0.0
This commit is contained in:
503
src/app/Livewire/Operator/TicketShow.php
Normal file
503
src/app/Livewire/Operator/TicketShow.php
Normal file
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\ReplyQuickAction;
|
||||
use App\Models\ResponseTemplate;
|
||||
use App\Models\Status;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class TicketShow extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Ticket $ticket;
|
||||
|
||||
public string $reply = '';
|
||||
|
||||
public array $replyAttachments = [];
|
||||
|
||||
public string $templateId = '';
|
||||
|
||||
public bool $addingNote = false;
|
||||
|
||||
public string $noteDraft = '';
|
||||
|
||||
public array $noteAttachments = [];
|
||||
|
||||
public ?int $editingNoteId = null;
|
||||
|
||||
public string $editingNoteDraft = '';
|
||||
|
||||
public ?int $editingMessageId = null;
|
||||
|
||||
public string $editingMessageDraft = '';
|
||||
|
||||
public ?int $pendingDeleteMessageId = null;
|
||||
|
||||
public bool $pendingDeleteTicket = false;
|
||||
|
||||
public bool $editingDetails = false;
|
||||
|
||||
public array $editDetailsForm = ['subject' => '', '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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user