Repo rebuild
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Product;
|
||||
use Modules\Helpdesk\Models\Project;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Tag;
|
||||
use Modules\Helpdesk\Models\Team;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\SlaService;
|
||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||
|
||||
final class TicketEdit extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Ticket $ticket;
|
||||
|
||||
#[Validate('nullable|integer')]
|
||||
public ?int $company_id = null;
|
||||
|
||||
#[Validate('required|string|max:200')]
|
||||
public string $subject = '';
|
||||
|
||||
#[Validate('required|string|min:10')]
|
||||
public string $body = '';
|
||||
|
||||
#[Validate('required|integer|exists:helpdesk_statuses,id')]
|
||||
public ?int $status_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_kanban_boards,id')]
|
||||
public ?int $board_id = null;
|
||||
|
||||
#[Validate('required|integer|exists:helpdesk_priorities,id')]
|
||||
public ?int $priority_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_categories,id')]
|
||||
public ?int $category_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_projects,id')]
|
||||
public ?int $project_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_products,id')]
|
||||
public ?int $product_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_teams,id')]
|
||||
public ?int $team_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:users,id')]
|
||||
public ?int $agent_id = null;
|
||||
|
||||
#[Validate('nullable|date')]
|
||||
public ?string $due_at = null;
|
||||
|
||||
/** @var array<int, int> */
|
||||
#[Validate('array')]
|
||||
public array $tag_ids = [];
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
#[Validate('array')]
|
||||
public array $attachments = [];
|
||||
|
||||
/**
|
||||
* Load the editable ticket state, including existing attachments and assignment.
|
||||
*/
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
$this->authorize('update', $ticket);
|
||||
|
||||
$this->ticket = $this->freshTicket($ticket);
|
||||
$this->company_id = $this->ticket->company_id;
|
||||
$this->subject = $this->ticket->subject;
|
||||
$this->body = $this->ticket->body;
|
||||
$this->status_id = $this->ticket->status_id;
|
||||
$this->board_id = $this->ticket->board_id;
|
||||
$this->priority_id = $this->ticket->priority_id;
|
||||
$this->category_id = $this->ticket->category_id;
|
||||
$this->project_id = $this->ticket->project_id;
|
||||
$this->product_id = $this->ticket->product_id;
|
||||
$this->team_id = $this->ticket->team_id;
|
||||
$this->agent_id = $this->ticket->agent_id;
|
||||
$this->due_at = $this->ticket->due_at?->format('Y-m-d');
|
||||
$this->tag_ids = $this->ticket->tags->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save ticket edits, assignment and any newly uploaded ticket-level attachments.
|
||||
*/
|
||||
public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$this->validate();
|
||||
$this->validate([
|
||||
'attachments' => 'array',
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
|
||||
]);
|
||||
|
||||
$status = Status::query()->findOrFail($this->status_id);
|
||||
|
||||
$this->ticket->forceFill([
|
||||
'company_id' => $this->company_id,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'status_id' => $status->id,
|
||||
'board_id' => $this->board_id,
|
||||
'priority_id' => $this->priority_id,
|
||||
'category_id' => $this->category_id,
|
||||
'project_id' => $this->project_id,
|
||||
'product_id' => $this->product_id,
|
||||
'team_id' => $this->team_id,
|
||||
'agent_id' => $this->agent_id,
|
||||
'due_at' => $this->due_at,
|
||||
'completed_at' => $status->is_closed ? now() : null,
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->ticket->tags()->sync(array_values(array_unique(array_map('intval', $this->tag_ids))));
|
||||
$this->storeTicketAttachments();
|
||||
|
||||
if ($status->is_closed) {
|
||||
$sla->recordClosure($this->ticket);
|
||||
}
|
||||
|
||||
$fresh = $this->freshTicket($this->ticket);
|
||||
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
|
||||
|
||||
session()->flash('success', 'Ticket ' . $fresh->reference . ' has been updated.');
|
||||
|
||||
return redirect()->route('helpdesk.tickets.show', $fresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an existing ticket-level attachment from storage and the database.
|
||||
*/
|
||||
public function removeAttachment(int $attachmentId): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$attachment = $this->ticket->attachments()->whereKey($attachmentId)->firstOrFail();
|
||||
|
||||
if (method_exists($attachment, 'deleteFile')) {
|
||||
$attachment->deleteFile();
|
||||
}
|
||||
|
||||
$attachment->delete();
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the edit form, including Select2 and Flatpickr browser widgets.
|
||||
*/
|
||||
public function clearForm(): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
$this->resetErrorBag();
|
||||
$this->resetValidation();
|
||||
|
||||
$this->subject = '';
|
||||
$this->body = '';
|
||||
$this->company_id = null;
|
||||
$this->status_id = null;
|
||||
$this->board_id = null;
|
||||
$this->priority_id = null;
|
||||
$this->category_id = null;
|
||||
$this->project_id = null;
|
||||
$this->product_id = null;
|
||||
$this->team_id = null;
|
||||
$this->agent_id = null;
|
||||
$this->due_at = null;
|
||||
$this->tag_ids = [];
|
||||
$this->attachments = [];
|
||||
|
||||
$this->dispatch('helpdesk-form-cleared');
|
||||
$this->dispatch('helpdesk-editor-reset', model: 'body');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-edit', [
|
||||
'boards' => KanbanBoard::query()->with('owner:id,name,email')->orderByDesc('is_default')->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'categories' => Category::query()->with('agents:id')->orderBy('name')->get(),
|
||||
'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(),
|
||||
'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(),
|
||||
'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(),
|
||||
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
'tags' => Tag::query()->orderBy('name')->get(),
|
||||
'agents' => $this->agentOptions(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return assignable users for this ticket.
|
||||
*
|
||||
* The dropdown must never be blank just because optional helpdesk_agent/helpdesk_admin
|
||||
* flags are missing or false. Prefer tenant/company users, then fall back to all users.
|
||||
*
|
||||
* @return EloquentCollection<int, User>
|
||||
*/
|
||||
private function agentOptions(): EloquentCollection
|
||||
{
|
||||
$columns = ['id', 'name', 'email'];
|
||||
|
||||
if (Schema::hasColumn('users', 'company_id')) {
|
||||
$columns[] = 'company_id';
|
||||
}
|
||||
|
||||
$query = User::query()->select($columns)->orderBy('name');
|
||||
|
||||
if ($this->company_id !== null && Schema::hasColumn('users', 'company_id')) {
|
||||
$tenantUsers = (clone $query)
|
||||
->where(function ($query): void {
|
||||
$query->where('company_id', $this->company_id)
|
||||
->orWhereNull('company_id');
|
||||
})
|
||||
->when($this->agent_id !== null, function ($query): void {
|
||||
$query->orWhere('id', $this->agent_id);
|
||||
})
|
||||
->get();
|
||||
|
||||
if ($tenantUsers->isNotEmpty()) {
|
||||
return $tenantUsers;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function freshTicket(Ticket $ticket): Ticket
|
||||
{
|
||||
return $ticket->fresh([
|
||||
'user:id,name,email',
|
||||
'owner:id,name,email',
|
||||
'agent:id,name,email',
|
||||
'status',
|
||||
'priority',
|
||||
'category',
|
||||
'project',
|
||||
'team',
|
||||
'product',
|
||||
'tags',
|
||||
'attachments.user',
|
||||
]) ?? $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store ticket-level attachments in a schema-tolerant way.
|
||||
*/
|
||||
private function storeTicketAttachments(): void
|
||||
{
|
||||
if ($this->attachments === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->attachments as $upload) {
|
||||
if (! $upload instanceof TemporaryUploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk = (string) config('helpdesk.disk', 'local');
|
||||
$path = $upload->store('tickets/' . $this->ticket->id, $disk);
|
||||
$this->ticket->attachments()->create($this->attachmentPayload($upload, $path, $disk));
|
||||
}
|
||||
|
||||
$this->attachments = [];
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build attachment attributes using the columns available in the project migration.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function attachmentPayload(TemporaryUploadedFile $upload, string $path, string $disk): array
|
||||
{
|
||||
$table = 'helpdesk_attachments';
|
||||
$payload = [];
|
||||
$columns = [
|
||||
'user_id' => Auth::id(),
|
||||
'uploaded_by_id' => Auth::id(),
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'file_path' => $path,
|
||||
'filename' => basename($path),
|
||||
'file_name' => basename($path),
|
||||
'original_name' => $upload->getClientOriginalName(),
|
||||
'original_filename' => $upload->getClientOriginalName(),
|
||||
'mime_type' => $upload->getMimeType(),
|
||||
'mime' => $upload->getMimeType(),
|
||||
'size' => $upload->getSize(),
|
||||
'size_bytes' => $upload->getSize(),
|
||||
];
|
||||
|
||||
foreach ($columns as $column => $value) {
|
||||
if (Schema::hasColumn($table, $column)) {
|
||||
$payload[$column] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* Laravel validates source-code attachments by extension because browsers often report
|
||||
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(): array
|
||||
{
|
||||
return [
|
||||
'file',
|
||||
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user