v1.0.0
This commit is contained in:
120
src/app/Livewire/Admin/ApiKeys.php
Normal file
120
src/app/Livewire/Admin/ApiKeys.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\ApiClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class ApiKeys extends Component
|
||||
{
|
||||
public bool $formOpen = false;
|
||||
|
||||
public array $form = ['name' => '', 'description' => '', 'abilities' => []];
|
||||
|
||||
/**
|
||||
* Only ever populated right after createToken() — the plaintext secret
|
||||
* is never stored anywhere (Sanctum only keeps the hash), so this is the
|
||||
* one and only chance to show it. Cleared on every other action so a
|
||||
* page refresh or subsequent click never brings it back.
|
||||
*/
|
||||
public ?string $newTokenPlaintext = null;
|
||||
|
||||
public ?string $newTokenClientName = null;
|
||||
|
||||
public static function availableAbilities(): array
|
||||
{
|
||||
return [
|
||||
'tickets:read' => 'Odczyt zgłoszeń',
|
||||
'tickets:write' => 'Zapis zgłoszeń (tworzenie, aktualizacja, wiadomości)',
|
||||
'dictionaries:read' => 'Odczyt słowników (kategorie, statusy, priorytety, zespoły)',
|
||||
'users:read' => 'Odczyt użytkowników',
|
||||
];
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function apiClients(): Collection
|
||||
{
|
||||
return ApiClient::query()
|
||||
->with(['tokens' => fn ($q) => $q->latest()])
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function openForm(): void
|
||||
{
|
||||
$this->reset('form');
|
||||
$this->newTokenPlaintext = null;
|
||||
$this->newTokenClientName = null;
|
||||
$this->resetErrorBag();
|
||||
$this->formOpen = true;
|
||||
}
|
||||
|
||||
public function closeForm(): void
|
||||
{
|
||||
$this->formOpen = false;
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->validate([
|
||||
'form.name' => ['required', 'string', 'max:255'],
|
||||
'form.description' => ['nullable', 'string', 'max:1000'],
|
||||
'form.abilities' => ['required', 'array', 'min:1'],
|
||||
'form.abilities.*' => ['string', 'in:'.implode(',', array_keys(static::availableAbilities()))],
|
||||
]);
|
||||
|
||||
$client = ApiClient::query()->create([
|
||||
'name' => $this->form['name'],
|
||||
'description' => $this->form['description'] ?: null,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
$token = $client->createToken($this->form['name'], $this->form['abilities']);
|
||||
|
||||
$this->newTokenPlaintext = $token->plainTextToken;
|
||||
$this->newTokenClientName = $client->name;
|
||||
$this->formOpen = false;
|
||||
unset($this->apiClients);
|
||||
}
|
||||
|
||||
public function revoke(int $apiClientId): void
|
||||
{
|
||||
$this->newTokenPlaintext = null;
|
||||
$this->newTokenClientName = null;
|
||||
|
||||
$client = ApiClient::query()->findOrFail($apiClientId);
|
||||
$client->tokens()->delete();
|
||||
$client->update(['revoked_at' => now()]);
|
||||
|
||||
unset($this->apiClients);
|
||||
}
|
||||
|
||||
public function regenerate(int $apiClientId): void
|
||||
{
|
||||
$client = ApiClient::query()->findOrFail($apiClientId);
|
||||
$abilities = $client->tokens()->latest()->first()?->abilities ?? [];
|
||||
|
||||
$client->tokens()->delete();
|
||||
$token = $client->createToken($client->name, $abilities);
|
||||
$client->update(['revoked_at' => null]);
|
||||
|
||||
$this->newTokenPlaintext = $token->plainTextToken;
|
||||
$this->newTokenClientName = $client->name;
|
||||
|
||||
unset($this->apiClients);
|
||||
}
|
||||
|
||||
public function dismissNewToken(): void
|
||||
{
|
||||
$this->newTokenPlaintext = null;
|
||||
$this->newTokenClientName = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.api-keys');
|
||||
}
|
||||
}
|
||||
1402
src/app/Livewire/Admin/Panel.php
Normal file
1402
src/app/Livewire/Admin/Panel.php
Normal file
File diff suppressed because it is too large
Load Diff
70
src/app/Livewire/Auth/Login.php
Normal file
70
src/app/Livewire/Auth/Login.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
class Login extends Component
|
||||
{
|
||||
public string $username = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
/**
|
||||
* Where to land after a successful login — e.g. set by the guest ticket
|
||||
* confirmation screen's "Zaloguj się" button so it can jump straight to
|
||||
* the just-created ticket instead of the user's default area.
|
||||
*/
|
||||
#[Url]
|
||||
public ?string $redirect = null;
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->error = null;
|
||||
|
||||
$attribute = Settings::ldapUsernameAttribute();
|
||||
|
||||
// Local accounts (created with a password from the admin panel) don't
|
||||
// necessarily exist in LDAP under this attribute, so we also let the
|
||||
// provider fall back to matching by e-mail + local password.
|
||||
$ok = Auth::attempt([
|
||||
$attribute => $this->username,
|
||||
'password' => $this->password,
|
||||
'fallback' => ['email' => $this->username],
|
||||
]);
|
||||
|
||||
if (! $ok) {
|
||||
$this->error = 'Nieprawidłowa nazwa użytkownika lub hasło.';
|
||||
$this->password = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
request()->session()->regenerate();
|
||||
|
||||
$this->redirect($this->safeRedirectTarget() ?? Auth::user()->defaultArea(), navigate: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only follow the ?redirect= target when it's a same-app relative path —
|
||||
* never an absolute/external URL, to avoid it being abused as an open redirect.
|
||||
*/
|
||||
protected function safeRedirectTarget(): ?string
|
||||
{
|
||||
if (! $this->redirect || ! str_starts_with($this->redirect, '/') || str_starts_with($this->redirect, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->redirect;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.login');
|
||||
}
|
||||
}
|
||||
44
src/app/Livewire/Client/Dashboard.php
Normal file
44
src/app/Livewire/Client/Dashboard.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Status;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public string $tab = 'current';
|
||||
|
||||
#[Computed]
|
||||
public function tickets()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()
|
||||
->with('subcategory.category')
|
||||
->orderByDesc('updated_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function currentTickets()
|
||||
{
|
||||
return $this->tickets->whereNotIn('status_key', Status::closedKeys());
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function archiveTickets()
|
||||
{
|
||||
return $this->tickets->whereIn('status_key', Status::closedKeys());
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
$this->tab = $tab;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.dashboard');
|
||||
}
|
||||
}
|
||||
123
src/app/Livewire/Client/NewTicket.php
Normal file
123
src/app/Livewire/Client/NewTicket.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class NewTicket extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $step = 1;
|
||||
|
||||
public ?int $categoryId = null;
|
||||
|
||||
public ?int $subcategoryId = null;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public array $customValues = [];
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedCategory(): ?Category
|
||||
{
|
||||
return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedSubcategory(): ?Subcategory
|
||||
{
|
||||
return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null;
|
||||
}
|
||||
|
||||
public function selectCategory(int $id): void
|
||||
{
|
||||
$this->categoryId = $id;
|
||||
$this->subcategoryId = null;
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function selectSubcategory(int $id): void
|
||||
{
|
||||
$this->subcategoryId = $id;
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
{
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function backToSubcategory(): void
|
||||
{
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$rules = [
|
||||
'subject' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
];
|
||||
|
||||
foreach ($this->selectedSubcategory?->customFields ?? [] as $field) {
|
||||
$rules["customValues.{$field->id}"] = $field->required ? 'required' : 'nullable';
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $user->email,
|
||||
'subcategory_id' => $this->subcategoryId,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'custom_values' => $this->customValues,
|
||||
], $user);
|
||||
|
||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||
|
||||
$this->redirect(route('client.ticket', $ticket), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.new-ticket');
|
||||
}
|
||||
}
|
||||
138
src/app/Livewire/Client/TicketShow.php
Normal file
138
src/app/Livewire/Client/TicketShow.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Client;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketMessage;
|
||||
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 $attachments = [];
|
||||
|
||||
public ?int $editingMessageId = null;
|
||||
|
||||
public string $editingDraft = '';
|
||||
|
||||
public ?int $pendingDeleteMessageId = null;
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->customer_id === Auth::id(), 403);
|
||||
|
||||
$this->ticket = $ticket;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function ticketMessages()
|
||||
{
|
||||
return $this->ticket->publicMessages()->with(['author', 'authorLink.role', 'attachments'])->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function otherTickets()
|
||||
{
|
||||
return Auth::user()->ticketsAsCustomer()->where('id', '!=', $this->ticket->id)->get();
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function sendReply(): void
|
||||
{
|
||||
$this->validate(['reply' => 'required|string']);
|
||||
|
||||
app(TicketService::class)->clientReply($this->ticket, Auth::user(), $this->reply, $this->attachments);
|
||||
$this->reset(['reply', 'attachments']);
|
||||
unset($this->ticketMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
app(TicketService::class)->setStatus($this->ticket, 'closed');
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function reopen(): void
|
||||
{
|
||||
app(TicketService::class)->setStatus($this->ticket, 'open');
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function startEdit(int $messageId, string $body): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($messageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403);
|
||||
|
||||
$this->editingMessageId = $messageId;
|
||||
$this->editingDraft = $body;
|
||||
}
|
||||
|
||||
public function cancelEdit(): void
|
||||
{
|
||||
$this->editingMessageId = null;
|
||||
$this->editingDraft = '';
|
||||
}
|
||||
|
||||
public function saveEdit(): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($this->editingMessageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403);
|
||||
|
||||
$message->update(['body' => $this->editingDraft, 'edited' => true]);
|
||||
$this->cancelEdit();
|
||||
unset($this->ticketMessages);
|
||||
}
|
||||
|
||||
public function requestDelete(int $messageId): void
|
||||
{
|
||||
$this->pendingDeleteMessageId = $messageId;
|
||||
}
|
||||
|
||||
public function cancelDelete(): void
|
||||
{
|
||||
$this->pendingDeleteMessageId = null;
|
||||
}
|
||||
|
||||
public function confirmDelete(): void
|
||||
{
|
||||
$message = TicketMessage::query()->findOrFail($this->pendingDeleteMessageId);
|
||||
abort_unless($message->ticket_id === $this->ticket->id && $message->author_id === Auth::id() && $message->role === 'client', 403);
|
||||
|
||||
$message->delete();
|
||||
$this->pendingDeleteMessageId = null;
|
||||
unset($this->ticketMessages);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.client.ticket-show');
|
||||
}
|
||||
}
|
||||
159
src/app/Livewire/Landing.php
Normal file
159
src/app/Livewire/Landing.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\LdapUserProvisioner;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Landing extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $step = 1;
|
||||
|
||||
public ?int $categoryId = null;
|
||||
|
||||
public ?int $subcategoryId = null;
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public array $customValues = [];
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
public ?int $submittedTicketId = null;
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedCategory(): ?Category
|
||||
{
|
||||
return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedSubcategory(): ?Subcategory
|
||||
{
|
||||
return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function submittedTicket(): ?Ticket
|
||||
{
|
||||
return $this->submittedTicketId ? Ticket::query()->with('subcategory.category')->find($this->submittedTicketId) : null;
|
||||
}
|
||||
|
||||
public function selectCategory(int $id): void
|
||||
{
|
||||
$this->categoryId = $id;
|
||||
$this->subcategoryId = null;
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function selectSubcategory(int $id): void
|
||||
{
|
||||
$this->subcategoryId = $id;
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
{
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function backToSubcategory(): void
|
||||
{
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$rules = [
|
||||
'email' => 'required|email',
|
||||
'subject' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
];
|
||||
|
||||
foreach ($this->selectedSubcategory?->customFields ?? [] as $field) {
|
||||
$key = "customValues.{$field->id}";
|
||||
$rules[$key] = $field->required ? 'required' : 'nullable';
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
if (Settings::bool('restrict_tickets_to_ldap') && ! $this->emailIsKnown($this->email)) {
|
||||
$this->addError('email', 'Nie znaleziono użytkownika o podanym adresie e-mail.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $this->email,
|
||||
'subcategory_id' => $this->subcategoryId,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'custom_values' => $this->customValues,
|
||||
], null);
|
||||
|
||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||
|
||||
$this->submittedTicketId = $ticket->id;
|
||||
$this->reset(['step', 'categoryId', 'subcategoryId', 'email', 'subject', 'body', 'customValues', 'attachments']);
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function resetForm(): void
|
||||
{
|
||||
$this->submittedTicketId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for the "restrict guest ticket creation to LDAP" setting — allows
|
||||
* an already-provisioned local account too, so someone removed from LDAP
|
||||
* after their account was created doesn't lose the ability to write in.
|
||||
*/
|
||||
protected function emailIsKnown(string $email): bool
|
||||
{
|
||||
return User::query()->where('email', $email)->exists()
|
||||
|| app(LdapUserProvisioner::class)->existsInLdap($email);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.landing');
|
||||
}
|
||||
}
|
||||
138
src/app/Livewire/Operator/NewTicket.php
Normal file
138
src/app/Livewire/Operator/NewTicket.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Subcategory;
|
||||
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 NewTicket extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $step = 1;
|
||||
|
||||
public ?int $customerId = null;
|
||||
|
||||
public ?int $categoryId = null;
|
||||
|
||||
public ?int $subcategoryId = null;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public array $customValues = [];
|
||||
|
||||
public array $attachments = [];
|
||||
|
||||
#[Computed]
|
||||
public function clients()
|
||||
{
|
||||
return User::query()->withRole('client')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->with('subcategories')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedCategory(): ?Category
|
||||
{
|
||||
return $this->categoryId ? Category::query()->with('subcategories')->find($this->categoryId) : null;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function selectedSubcategory(): ?Subcategory
|
||||
{
|
||||
return $this->subcategoryId ? Subcategory::query()->with('customFields')->find($this->subcategoryId) : null;
|
||||
}
|
||||
|
||||
public function selectCategory(int $id): void
|
||||
{
|
||||
$this->categoryId = $id;
|
||||
$this->subcategoryId = null;
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function selectSubcategory(int $id): void
|
||||
{
|
||||
$this->subcategoryId = $id;
|
||||
$this->step = 3;
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
{
|
||||
$this->step = 1;
|
||||
}
|
||||
|
||||
public function backToSubcategory(): void
|
||||
{
|
||||
$this->step = 2;
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
{
|
||||
if (! $this->attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($error = Settings::validateAttachments($this->attachments)) {
|
||||
$this->attachments = [];
|
||||
$this->addError('attachments', $error);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeAttachment(int $index): void
|
||||
{
|
||||
unset($this->attachments[$index]);
|
||||
$this->attachments = array_values($this->attachments);
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$rules = [
|
||||
'customerId' => 'required|exists:users,id',
|
||||
'subject' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
];
|
||||
|
||||
foreach ($this->selectedSubcategory?->customFields ?? [] as $field) {
|
||||
$rules["customValues.{$field->id}"] = $field->required ? 'required' : 'nullable';
|
||||
}
|
||||
|
||||
$this->validate($rules);
|
||||
|
||||
$customer = User::query()->findOrFail($this->customerId);
|
||||
|
||||
$ticket = app(TicketService::class)->create([
|
||||
'email' => $customer->email,
|
||||
'subcategory_id' => $this->subcategoryId,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'custom_values' => $this->customValues,
|
||||
'assignee_id' => Auth::id(),
|
||||
], $customer);
|
||||
|
||||
$ticket->messages()->first()?->update([
|
||||
'body' => $ticket->body.' (zgłoszenie utworzone przez operatora w imieniu klienta)',
|
||||
]);
|
||||
|
||||
app(TicketService::class)->attachFiles($ticket, $ticket->messages()->first(), $this->attachments);
|
||||
|
||||
$this->redirect(route('operator.ticket', $ticket), navigate: true);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.operator.new-ticket');
|
||||
}
|
||||
}
|
||||
320
src/app/Livewire/Operator/Queue.php
Normal file
320
src/app/Livewire/Operator/Queue.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Services\TicketService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
class Queue extends Component
|
||||
{
|
||||
public string $queue = 'all';
|
||||
|
||||
public string $filterStatus = 'all';
|
||||
|
||||
public string $filterPriority = 'all';
|
||||
|
||||
public string $filterCategory = 'all';
|
||||
|
||||
#[Url]
|
||||
public ?int $filterCustomerId = null;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public string $sortBy = 'updated_at';
|
||||
|
||||
public string $sortDir = 'desc';
|
||||
|
||||
/** @var string[] */
|
||||
public array $visibleColumns = ['number', 'subject', 'customer', 'category', 'priority', 'status', 'sla', 'assignee'];
|
||||
|
||||
/** @var int[] */
|
||||
public array $selectedIds = [];
|
||||
|
||||
public bool $pendingDeleteSelected = false;
|
||||
|
||||
#[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 === 'all'
|
||||
? $this->statuses->reject(fn (Status $s) => $s->stage === 'closed')
|
||||
: $this->statuses;
|
||||
}
|
||||
|
||||
#[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())],
|
||||
'unassigned' => ['label' => 'Nieprzypisane', 'icon' => 'person_off', 'group' => 'Przegląd', 'filter' => fn ($q) => $q->whereNull('assignee_id')],
|
||||
'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),
|
||||
];
|
||||
}
|
||||
|
||||
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) !== '') {
|
||||
$term = '%'.trim($this->search).'%';
|
||||
$query->where(fn ($q) => $q->where('number', 'like', $term)
|
||||
->orWhere('subject', 'like', $term)
|
||||
->orWhere('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term));
|
||||
}
|
||||
|
||||
$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<string, string>
|
||||
*/
|
||||
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 = [];
|
||||
|
||||
if ($key === 'all' && $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');
|
||||
}
|
||||
}
|
||||
394
src/app/Livewire/Operator/Stats.php
Normal file
394
src/app/Livewire/Operator/Stats.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Operator;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Priority;
|
||||
use App\Models\SlaRule;
|
||||
use App\Models\Status;
|
||||
use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
class Stats extends Component
|
||||
{
|
||||
#[Url]
|
||||
public string $range = '30d';
|
||||
|
||||
#[Url]
|
||||
public ?string $from = null;
|
||||
|
||||
#[Url]
|
||||
public ?string $to = null;
|
||||
|
||||
#[Url]
|
||||
public string $filterTeam = 'all';
|
||||
|
||||
#[Url]
|
||||
public string $filterPriority = 'all';
|
||||
|
||||
#[Url]
|
||||
public string $filterCategory = 'all';
|
||||
|
||||
#[Url]
|
||||
public string $filterAssignee = 'all';
|
||||
|
||||
/**
|
||||
* Non-admin operators only ever see their own teams — same scoping as
|
||||
* Queue's team tabs, so the filter options never expose data the
|
||||
* visibleToOperator() query would filter back out anyway.
|
||||
*/
|
||||
#[Computed]
|
||||
public function teams()
|
||||
{
|
||||
$query = Team::query();
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
||||
}
|
||||
|
||||
return $query->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function priorities()
|
||||
{
|
||||
return Priority::query()->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function categories()
|
||||
{
|
||||
return Category::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function operators()
|
||||
{
|
||||
$query = User::query()->withRole('operator');
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$teamIds = $this->teams->pluck('id');
|
||||
$query->whereHas('teams', fn ($q) => $q->whereIn('teams.id', $teamIds));
|
||||
}
|
||||
|
||||
return $query->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{from: ?Carbon, to: ?Carbon}
|
||||
*/
|
||||
protected function bounds(): array
|
||||
{
|
||||
$now = now();
|
||||
|
||||
return match ($this->range) {
|
||||
'today' => ['from' => $now->clone()->startOfDay(), 'to' => $now],
|
||||
'7d' => ['from' => $now->clone()->subDays(6)->startOfDay(), 'to' => $now],
|
||||
'30d' => ['from' => $now->clone()->subDays(29)->startOfDay(), 'to' => $now],
|
||||
'90d' => ['from' => $now->clone()->subDays(89)->startOfDay(), 'to' => $now],
|
||||
'custom' => [
|
||||
'from' => $this->from ? Carbon::parse($this->from)->startOfDay() : $now->clone()->subDays(29)->startOfDay(),
|
||||
'to' => $this->to ? Carbon::parse($this->to)->endOfDay() : $now,
|
||||
],
|
||||
default => ['from' => null, 'to' => null],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared, fully-filtered ticket scope every stat below reads from —
|
||||
* cloned per terminal call (count/get/pluck) since none of those mutate
|
||||
* the underlying where clauses, only cloning avoids one query's
|
||||
* ->select()/->groupBy() leaking into the next.
|
||||
*/
|
||||
#[Computed]
|
||||
public function baseQuery()
|
||||
{
|
||||
$query = Ticket::query()->visibleToOperator(Auth::user());
|
||||
$bounds = $this->bounds();
|
||||
|
||||
if ($bounds['from']) {
|
||||
$query->where('tickets.created_at', '>=', $bounds['from']);
|
||||
}
|
||||
if ($bounds['to']) {
|
||||
$query->where('tickets.created_at', '<=', $bounds['to']);
|
||||
}
|
||||
if ($this->filterTeam !== 'all') {
|
||||
$query->where('tickets.team_id', $this->filterTeam);
|
||||
}
|
||||
if ($this->filterPriority !== 'all') {
|
||||
$query->where('tickets.priority_key', $this->filterPriority);
|
||||
}
|
||||
if ($this->filterCategory !== 'all') {
|
||||
$query->whereHas('subcategory', fn ($q) => $q->where('category_id', $this->filterCategory));
|
||||
}
|
||||
if ($this->filterAssignee === 'unassigned') {
|
||||
$query->whereNull('tickets.assignee_id');
|
||||
} elseif ($this->filterAssignee !== 'all') {
|
||||
$query->where('tickets.assignee_id', $this->filterAssignee);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function kpis(): array
|
||||
{
|
||||
$total = (clone $this->baseQuery)->count();
|
||||
$closedKeys = Status::closedKeys();
|
||||
$closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'open' => $total - $closed,
|
||||
'closed' => $closed,
|
||||
'closedPct' => $total > 0 ? round($closed / $total * 100, 1) : null,
|
||||
'avgFirstResponseHours' => $this->avgFirstResponseHours(),
|
||||
'avgResolutionHours' => $this->avgResolutionHours($closedKeys),
|
||||
'sla' => $this->slaBreachStats($closedKeys),
|
||||
];
|
||||
}
|
||||
|
||||
protected function avgFirstResponseHours(): ?float
|
||||
{
|
||||
$ids = (clone $this->baseQuery)->pluck('tickets.id');
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstReplies = DB::table('ticket_messages as tm')
|
||||
->join('ticket_message_authors as tma', 'tma.ticket_message_id', '=', 'tm.id')
|
||||
->join('roles as r', 'r.id', '=', 'tma.role_id')
|
||||
->where('r.key', 'operator')
|
||||
->whereIn('tm.ticket_id', $ids)
|
||||
->select('tm.ticket_id', DB::raw('MIN(tm.created_at) as first_reply'))
|
||||
->groupBy('tm.ticket_id')
|
||||
->pluck('first_reply', 'ticket_id');
|
||||
|
||||
if ($firstReplies->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$createdAts = Ticket::query()->whereIn('id', $firstReplies->keys())->pluck('created_at', 'id');
|
||||
|
||||
$minutes = $firstReplies->map(fn ($firstReply, $ticketId) => $createdAts[$ticketId]?->diffInMinutes(Carbon::parse($firstReply)))
|
||||
->filter(fn ($v) => $v !== null);
|
||||
|
||||
return $minutes->isEmpty() ? null : round($minutes->avg() / 60, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate: no dedicated "resolved_at" column exists, so a closed
|
||||
* ticket's updated_at (touched whenever its status changes — see
|
||||
* TicketService::setStatus()) stands in for when it was closed.
|
||||
*/
|
||||
protected function avgResolutionHours(array $closedKeys): ?float
|
||||
{
|
||||
$closed = (clone $this->baseQuery)->whereIn('tickets.status_key', $closedKeys)->get(['created_at', 'updated_at']);
|
||||
|
||||
if ($closed->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round($closed->avg(fn (Ticket $t) => $t->created_at->diffInMinutes($t->updated_at)) / 60, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket "breaches SLA" if its resolution deadline (priority's
|
||||
* sla_rules.resolution_mins, from creation) has passed — either already,
|
||||
* for a still-open ticket, or before it was closed (updated_at stands in
|
||||
* for the close time, same approximation as avgResolutionHours()).
|
||||
* Priorities with resolution_mins = 0 (e.g. "Brak") never breach.
|
||||
*/
|
||||
protected function slaBreachStats(array $closedKeys): array
|
||||
{
|
||||
$tickets = (clone $this->baseQuery)->get(['status_key', 'priority_key', 'created_at', 'updated_at']);
|
||||
|
||||
if ($tickets->isEmpty()) {
|
||||
return ['rate' => null, 'breached' => 0, 'total' => 0];
|
||||
}
|
||||
|
||||
$rules = SlaRule::query()->pluck('resolution_mins', 'priority_key');
|
||||
$now = now();
|
||||
|
||||
$breached = $tickets->filter(function (Ticket $t) use ($rules, $closedKeys, $now) {
|
||||
$mins = $rules[$t->priority_key] ?? 0;
|
||||
|
||||
if ($mins <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deadline = $t->created_at->clone()->addMinutes($mins);
|
||||
|
||||
return in_array($t->status_key, $closedKeys, true)
|
||||
? $t->updated_at->isAfter($deadline)
|
||||
: $now->isAfter($deadline);
|
||||
})->count();
|
||||
|
||||
return [
|
||||
'rate' => round($breached / $tickets->count() * 100, 1),
|
||||
'breached' => $breached,
|
||||
'total' => $tickets->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The "closed" stage's color, reused for the trend chart's closed-tickets
|
||||
* strip so it visually matches the same status everywhere else in the
|
||||
* app — falls back to the secondary brand hue if no status is closed.
|
||||
*/
|
||||
#[Computed]
|
||||
public function closedColor(): string
|
||||
{
|
||||
return Status::query()->where('stage', 'closed')->value('color') ?? 'var(--color-accent-2)';
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byStatus()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('status_key', DB::raw('count(*) as total'))
|
||||
->groupBy('status_key')
|
||||
->pluck('total', 'status_key');
|
||||
|
||||
return Status::query()->orderBy('sort_order')->get()->map(fn (Status $s) => [
|
||||
'label' => $s->label,
|
||||
'color' => $s->color,
|
||||
'count' => $counts[$s->key] ?? 0,
|
||||
])->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byPriority()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('priority_key', DB::raw('count(*) as total'))
|
||||
->groupBy('priority_key')
|
||||
->pluck('total', 'priority_key');
|
||||
|
||||
return $this->priorities->map(fn (Priority $p) => [
|
||||
'label' => $p->label,
|
||||
'color' => $p->color,
|
||||
'count' => $counts[$p->key] ?? 0,
|
||||
])->values();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byCategory()
|
||||
{
|
||||
return (clone $this->baseQuery)
|
||||
->whereNotNull('tickets.subcategory_id')
|
||||
->join('subcategories', 'subcategories.id', '=', 'tickets.subcategory_id')
|
||||
->join('categories', 'categories.id', '=', 'subcategories.category_id')
|
||||
->select('categories.name as label', DB::raw('count(*) as count'))
|
||||
->groupBy('categories.id', 'categories.name')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->map(fn ($row) => ['label' => $row->label, 'count' => (int) $row->count]);
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byTeam()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('team_id', DB::raw('count(*) as total'))
|
||||
->groupBy('team_id')
|
||||
->pluck('total', 'team_id');
|
||||
|
||||
$rows = $this->teams->map(fn (Team $t) => ['label' => $t->name, 'count' => $counts[$t->id] ?? 0])
|
||||
->sortByDesc('count')
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Bez zespołu', 'count' => $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function byAssignee()
|
||||
{
|
||||
$counts = (clone $this->baseQuery)
|
||||
->select('assignee_id', DB::raw('count(*) as total'))
|
||||
->groupBy('assignee_id')
|
||||
->pluck('total', 'assignee_id');
|
||||
|
||||
$rows = $this->operators->map(fn (User $u) => ['label' => $u->name, 'count' => $counts[$u->id] ?? 0])
|
||||
->filter(fn ($row) => $row['count'] > 0)
|
||||
->sortByDesc('count')
|
||||
->values();
|
||||
|
||||
if ($counts->get(null, 0)) {
|
||||
$rows->push(['label' => 'Nieprzypisane', 'count' => $counts->get(null)]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* per day — the KPI tiles/breakdowns above still reflect the full range.
|
||||
*/
|
||||
#[Computed]
|
||||
public function trend(): array
|
||||
{
|
||||
$bounds = $this->bounds();
|
||||
$to = ($bounds['to'] ?? now())->clone()->startOfDay();
|
||||
$from = $bounds['from']?->clone()->startOfDay();
|
||||
|
||||
if (! $from) {
|
||||
$earliest = (clone $this->baseQuery)->min('tickets.created_at');
|
||||
$from = $earliest ? Carbon::parse($earliest)->startOfDay() : $to->clone()->subDays(29);
|
||||
}
|
||||
|
||||
$maxDays = 60;
|
||||
if ($from->diffInDays($to) + 1 > $maxDays) {
|
||||
$from = $to->clone()->subDays($maxDays - 1);
|
||||
}
|
||||
|
||||
$created = (clone $this->baseQuery)
|
||||
->selectRaw('DATE(tickets.created_at) as d, count(*) as total')
|
||||
->groupBy('d')->pluck('total', 'd');
|
||||
|
||||
$closedKeys = Status::closedKeys();
|
||||
$closed = (clone $this->baseQuery)
|
||||
->whereIn('tickets.status_key', $closedKeys)
|
||||
->selectRaw('DATE(tickets.updated_at) as d, count(*) as total')
|
||||
->groupBy('d')->pluck('total', 'd');
|
||||
|
||||
$days = [];
|
||||
$cursor = $from->clone();
|
||||
|
||||
while ($cursor->lte($to)) {
|
||||
$key = $cursor->format('Y-m-d');
|
||||
$days[] = [
|
||||
'date' => $key,
|
||||
'label' => $cursor->translatedFormat('d.m'),
|
||||
'created' => (int) ($created[$key] ?? 0),
|
||||
'closed' => (int) ($closed[$key] ?? 0),
|
||||
];
|
||||
$cursor->addDay();
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
public function setRange(string $range): void
|
||||
{
|
||||
$this->range = $range;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.operator.stats');
|
||||
}
|
||||
}
|
||||
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