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