added jquery and also fixed fontawsome
This commit is contained in:
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Concerns;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method hasMany(string $class, string $string)
|
|
||||||
*/
|
|
||||||
trait HasHelpdeskTickets
|
|
||||||
{
|
|
||||||
/** @return HasMany<Ticket> */
|
|
||||||
public function helpdeskTickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Ticket> */
|
|
||||||
public function helpdeskAssignedTickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'agent_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isHelpdeskAgent(): bool
|
|
||||||
{
|
|
||||||
return (bool) (($this->helpdesk_agent ?? false) || ($this->helpdesk_admin ?? false));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isHelpdeskAdmin(): bool
|
|
||||||
{
|
|
||||||
return (bool) ($this->helpdesk_admin ?? false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'route_prefix' => env('HELPDESK_ROUTE_PREFIX', 'helpdesk'),
|
|
||||||
'middleware' => ['web', 'auth'],
|
|
||||||
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'local'),
|
|
||||||
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
|
|
||||||
'allowed_mimes' => ['md','pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'],
|
|
||||||
'default_status_slug' => 'new',
|
|
||||||
'closed_status_slug' => 'closed',
|
|
||||||
];
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', static function (Blueprint $table): void {
|
|
||||||
if (! Schema::hasColumn('users', 'helpdesk_agent')) {
|
|
||||||
$table->boolean('helpdesk_agent')->default(false)->after('password');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Schema::hasColumn('users', 'helpdesk_admin')) {
|
|
||||||
$table->boolean('helpdesk_admin')->default(false)->after('helpdesk_agent');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('helpdesk_statuses', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name', 80);
|
|
||||||
$table->string('slug', 100)->unique();
|
|
||||||
$table->string('color', 30)->default('zinc');
|
|
||||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
|
||||||
$table->boolean('is_closed')->default(false);
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_priorities', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name', 80);
|
|
||||||
$table->string('slug', 100)->unique();
|
|
||||||
$table->string('color', 30)->default('zinc');
|
|
||||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_categories', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->string('name', 120);
|
|
||||||
$table->string('slug', 140);
|
|
||||||
$table->string('color', 30)->default('blue');
|
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_teams', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->string('name', 120);
|
|
||||||
$table->string('slug', 140);
|
|
||||||
$table->string('color', 30)->default('indigo');
|
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->timestamp('archived_at')->nullable();
|
|
||||||
$table->softDeletes();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_projects', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete();
|
|
||||||
$table->string('title', 160);
|
|
||||||
$table->string('slug', 180);
|
|
||||||
$table->string('color', 30)->default('blue');
|
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->timestamp('archived_at')->nullable();
|
|
||||||
$table->softDeletes();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('helpdesk_tickets', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('reference', 32)->unique();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
|
||||||
$table->foreignId('agent_id')->nullable()->constrained('users')->nullOnDelete();
|
|
||||||
$table->foreignId('status_id')->constrained('helpdesk_statuses')->restrictOnDelete();
|
|
||||||
$table->foreignId('priority_id')->constrained('helpdesk_priorities')->restrictOnDelete();
|
|
||||||
$table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete();
|
|
||||||
$table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete();
|
|
||||||
$table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete();
|
|
||||||
$table->string('subject', 200);
|
|
||||||
$table->longText('body');
|
|
||||||
$table->timestamp('due_at')->nullable();
|
|
||||||
$table->timestamp('completed_at')->nullable();
|
|
||||||
$table->timestamp('last_activity_at')->nullable();
|
|
||||||
$table->timestamp('archived_at')->nullable();
|
|
||||||
$table->softDeletes();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['company_id', 'project_id']);
|
|
||||||
$table->index(['team_id', 'status_id']);
|
|
||||||
$table->index(['status_id', 'priority_id']);
|
|
||||||
$table->index(['agent_id', 'completed_at']);
|
|
||||||
$table->index(['user_id', 'created_at']);
|
|
||||||
$table->index('last_activity_at');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('helpdesk_git_repositories', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete();
|
|
||||||
$table->string('name', 160);
|
|
||||||
$table->string('provider', 40)->default('git');
|
|
||||||
$table->string('remote_url', 500);
|
|
||||||
$table->string('default_branch', 120)->default('main');
|
|
||||||
$table->string('webhook_secret', 120)->nullable();
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['company_id', 'project_id']);
|
|
||||||
$table->index(['provider', 'is_active']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_git_commits', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('repository_id')->constrained('helpdesk_git_repositories')->cascadeOnDelete();
|
|
||||||
$table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete();
|
|
||||||
$table->string('hash', 80);
|
|
||||||
$table->string('short_hash', 16);
|
|
||||||
$table->string('branch', 120)->nullable();
|
|
||||||
$table->string('author_name', 160)->nullable();
|
|
||||||
$table->string('author_email', 190)->nullable();
|
|
||||||
$table->text('message');
|
|
||||||
$table->string('url', 500)->nullable();
|
|
||||||
$table->timestamp('committed_at')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['repository_id', 'hash']);
|
|
||||||
$table->index(['ticket_id', 'committed_at']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_workflow_templates', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name', 160);
|
|
||||||
$table->string('event', 80);
|
|
||||||
$table->string('subject', 220);
|
|
||||||
$table->longText('html_body');
|
|
||||||
$table->longText('text_body')->nullable();
|
|
||||||
$table->string('to_role', 40)->default('requester');
|
|
||||||
$table->string('cc', 500)->nullable();
|
|
||||||
$table->string('bcc', 500)->nullable();
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['event', 'is_active']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_workflow_logs', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('template_id')->nullable()->constrained('helpdesk_workflow_templates')->nullOnDelete();
|
|
||||||
$table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete();
|
|
||||||
$table->string('event', 80);
|
|
||||||
$table->string('recipient', 190)->nullable();
|
|
||||||
$table->string('subject', 220)->nullable();
|
|
||||||
$table->string('status', 40)->default('pending');
|
|
||||||
$table->text('error')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['ticket_id', 'event']);
|
|
||||||
$table->index(['status', 'created_at']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('helpdesk_comments', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
|
||||||
$table->foreignId('parent_id')->nullable()->constrained('helpdesk_comments')->cascadeOnDelete();
|
|
||||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
|
||||||
$table->longText('body');
|
|
||||||
$table->boolean('is_internal')->default(false);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['ticket_id', 'parent_id', 'created_at']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_attachments', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
|
||||||
$table->foreignId('comment_id')->nullable()->constrained('helpdesk_comments')->cascadeOnDelete();
|
|
||||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
|
||||||
$table->string('disk', 80);
|
|
||||||
$table->string('path', 500);
|
|
||||||
$table->string('original_name', 255);
|
|
||||||
$table->string('mime_type', 160)->nullable();
|
|
||||||
$table->unsignedBigInteger('size')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['ticket_id', 'comment_id', 'created_at']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('helpdesk_category_user', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('category_id')->constrained('helpdesk_categories')->cascadeOnDelete();
|
|
||||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['category_id', 'user_id']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
if (! Schema::hasTable('helpdesk_kanban_boards')) {
|
|
||||||
Schema::create('helpdesk_kanban_boards', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name');
|
|
||||||
$table->string('slug')->unique();
|
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->boolean('is_default')->default(false);
|
|
||||||
$table->unsignedInteger('sort_order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->softDeletes();
|
|
||||||
|
|
||||||
$table->index(['is_default', 'sort_order']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('helpdesk_kanban_boards');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
if (! Schema::hasTable('helpdesk_kanban_board_statuses')) {
|
|
||||||
Schema::create('helpdesk_kanban_board_statuses', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('board_id')->constrained('helpdesk_kanban_boards')->cascadeOnDelete();
|
|
||||||
$table->foreignId('status_id')->constrained('helpdesk_statuses')->cascadeOnDelete();
|
|
||||||
$table->unsignedInteger('sort_order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['board_id', 'status_id']);
|
|
||||||
$table->index(['board_id', 'sort_order']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('helpdesk_kanban_board_statuses');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
-208
@@ -1,208 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('helpdesk_products', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->string('name', 160);
|
|
||||||
$table->string('slug', 180);
|
|
||||||
$table->string('type', 60)->default('service');
|
|
||||||
$table->string('version', 80)->nullable();
|
|
||||||
$table->string('release_channel', 80)->nullable();
|
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
Schema::create('helpdesk_sla_policies', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->foreignId('priority_id')->nullable()->constrained('helpdesk_priorities')->nullOnDelete();
|
|
||||||
$table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete();
|
|
||||||
$table->string('name', 160);
|
|
||||||
$table->string('slug', 180);
|
|
||||||
$table->unsignedInteger('first_response_minutes')->default(240);
|
|
||||||
$table->unsignedInteger('resolution_minutes')->default(2880);
|
|
||||||
$table->boolean('business_hours_only')->default(false);
|
|
||||||
$table->boolean('is_default')->default(false);
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
$table->index(['priority_id', 'category_id', 'is_active']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('helpdesk_tickets', static function (Blueprint $table): void {
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'sla_policy_id')) {
|
|
||||||
$table->foreignId('sla_policy_id')->nullable()->after('team_id')->constrained('helpdesk_sla_policies')->nullOnDelete();
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'product_id')) {
|
|
||||||
$table->foreignId('product_id')->nullable()->after('project_id')->constrained('helpdesk_products')->nullOnDelete();
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'first_response_at')) {
|
|
||||||
$table->timestamp('first_response_at')->nullable()->after('due_at');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'sla_first_response_due_at')) {
|
|
||||||
$table->timestamp('sla_first_response_due_at')->nullable()->after('first_response_at');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'sla_resolution_due_at')) {
|
|
||||||
$table->timestamp('sla_resolution_due_at')->nullable()->after('sla_first_response_due_at');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'sla_first_response_breached_at')) {
|
|
||||||
$table->timestamp('sla_first_response_breached_at')->nullable()->after('sla_resolution_due_at');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'sla_resolution_breached_at')) {
|
|
||||||
$table->timestamp('sla_resolution_breached_at')->nullable()->after('sla_first_response_breached_at');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'public_replies_count')) {
|
|
||||||
$table->unsignedInteger('public_replies_count')->default(0)->after('sla_resolution_breached_at');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_tickets', 'time_spent_minutes')) {
|
|
||||||
$table->unsignedInteger('time_spent_minutes')->default(0)->after('public_replies_count');
|
|
||||||
}
|
|
||||||
|
|
||||||
$table->index(['sla_policy_id', 'sla_resolution_due_at'], 'helpdesk_tickets_sla_policy_due_idx');
|
|
||||||
$table->index(['product_id', 'completed_at'], 'helpdesk_tickets_product_closed_idx');
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('helpdesk_workflow_logs', static function (Blueprint $table): void {
|
|
||||||
if (! Schema::hasColumn('helpdesk_workflow_logs', 'tracking_token')) {
|
|
||||||
$table->string('tracking_token', 64)->nullable()->unique()->after('status');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_workflow_logs', 'first_opened_at')) {
|
|
||||||
$table->timestamp('first_opened_at')->nullable()->after('tracking_token');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_workflow_logs', 'last_opened_at')) {
|
|
||||||
$table->timestamp('last_opened_at')->nullable()->after('first_opened_at');
|
|
||||||
}
|
|
||||||
if (! Schema::hasColumn('helpdesk_workflow_logs', 'opened_count')) {
|
|
||||||
$table->unsignedInteger('opened_count')->default(0)->after('last_opened_at');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_canned_responses', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete();
|
|
||||||
$table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete();
|
|
||||||
$table->string('name', 160);
|
|
||||||
$table->string('slug', 180);
|
|
||||||
$table->string('visibility', 40)->default('agents');
|
|
||||||
$table->string('subject', 220)->nullable();
|
|
||||||
$table->longText('body');
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->unsignedInteger('sort_order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
$table->index(['category_id', 'team_id', 'is_active']);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Schema::create('helpdesk_automation_rules', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->string('name', 160);
|
|
||||||
$table->string('slug', 180);
|
|
||||||
$table->string('match_sender', 190)->nullable();
|
|
||||||
$table->string('match_subject', 220)->nullable();
|
|
||||||
$table->string('match_mailbox', 190)->nullable();
|
|
||||||
$table->foreignId('match_category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete();
|
|
||||||
$table->foreignId('set_priority_id')->nullable()->constrained('helpdesk_priorities')->nullOnDelete();
|
|
||||||
$table->foreignId('set_category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete();
|
|
||||||
$table->foreignId('set_team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete();
|
|
||||||
$table->foreignId('set_project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete();
|
|
||||||
$table->foreignId('set_status_id')->nullable()->constrained('helpdesk_statuses')->nullOnDelete();
|
|
||||||
$table->foreignId('set_agent_id')->nullable()->constrained('users')->nullOnDelete();
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->unsignedInteger('sort_order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
$table->index(['is_active', 'sort_order']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_ticket_sla_events', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
|
||||||
$table->foreignId('sla_policy_id')->nullable()->constrained('helpdesk_sla_policies')->nullOnDelete();
|
|
||||||
$table->string('event', 80);
|
|
||||||
$table->timestamp('due_at')->nullable();
|
|
||||||
$table->timestamp('occurred_at')->nullable();
|
|
||||||
$table->boolean('breached')->default(false);
|
|
||||||
$table->json('meta')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
$table->index(['ticket_id', 'event']);
|
|
||||||
$table->index(['breached', 'due_at']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_ticket_ratings', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
|
||||||
$table->foreignId('agent_id')->nullable()->constrained('users')->nullOnDelete();
|
|
||||||
$table->foreignId('team_id')->nullable()->constrained('helpdesk_teams')->nullOnDelete();
|
|
||||||
$table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete();
|
|
||||||
$table->unsignedTinyInteger('rating');
|
|
||||||
$table->string('token', 80)->unique();
|
|
||||||
$table->text('comment')->nullable();
|
|
||||||
$table->timestamp('rated_at')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
$table->index(['agent_id', 'rating']);
|
|
||||||
$table->index(['team_id', 'category_id']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_tags', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->string('name', 80);
|
|
||||||
$table->string('slug', 100);
|
|
||||||
$table->string('color', 30)->default('zinc');
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_ticket_tag', static function (Blueprint $table): void {
|
|
||||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
|
||||||
$table->foreignId('tag_id')->constrained('helpdesk_tags')->cascadeOnDelete();
|
|
||||||
$table->primary(['ticket_id', 'tag_id']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_checklist_templates', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
|
|
||||||
$table->foreignId('category_id')->nullable()->constrained('helpdesk_categories')->nullOnDelete();
|
|
||||||
$table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete();
|
|
||||||
$table->string('name', 160);
|
|
||||||
$table->string('slug', 180);
|
|
||||||
$table->json('items');
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->unique(['company_id', 'slug']);
|
|
||||||
$table->index(['category_id', 'project_id', 'is_active']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('helpdesk_checklist_items', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
|
||||||
$table->foreignId('template_id')->nullable()->constrained('helpdesk_checklist_templates')->nullOnDelete();
|
|
||||||
$table->string('title', 220);
|
|
||||||
$table->text('description')->nullable();
|
|
||||||
$table->boolean('is_completed')->default(false);
|
|
||||||
$table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete();
|
|
||||||
$table->timestamp('completed_at')->nullable();
|
|
||||||
$table->unsignedInteger('sort_order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->index(['ticket_id', 'is_completed']);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Add auditable Git webhook ingestion support.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
if (Schema::hasTable('helpdesk_git_webhook_logs')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Schema::create('helpdesk_git_webhook_logs', static function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('repository_id')->constrained('helpdesk_git_repositories')->cascadeOnDelete();
|
|
||||||
$table->string('provider', 40);
|
|
||||||
$table->string('delivery_id', 190)->nullable();
|
|
||||||
$table->string('event', 120)->nullable();
|
|
||||||
$table->string('status', 40)->default('accepted');
|
|
||||||
$table->string('message', 500)->nullable();
|
|
||||||
$table->unsignedInteger('commit_count')->default(0);
|
|
||||||
$table->unsignedInteger('linked_count')->default(0);
|
|
||||||
$table->unsignedInteger('ignored_count')->default(0);
|
|
||||||
$table->json('payload')->nullable();
|
|
||||||
$table->timestamp('received_at')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['repository_id', 'status', 'received_at']);
|
|
||||||
$table->index(['provider', 'event']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Database\Seeders;
|
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Modules\Helpdesk\Models\CannedResponse;
|
|
||||||
use Modules\Helpdesk\Models\Category;
|
|
||||||
use Modules\Helpdesk\Models\ChecklistTemplate;
|
|
||||||
use Modules\Helpdesk\Models\KanbanBoard;
|
|
||||||
use Modules\Helpdesk\Models\Priority;
|
|
||||||
use Modules\Helpdesk\Models\Product;
|
|
||||||
use Modules\Helpdesk\Models\Project;
|
|
||||||
use Modules\Helpdesk\Models\SlaPolicy;
|
|
||||||
use Modules\Helpdesk\Models\Status;
|
|
||||||
use Modules\Helpdesk\Models\Tag;
|
|
||||||
use Modules\Helpdesk\Models\Team;
|
|
||||||
use Modules\Helpdesk\Models\WorkflowTemplate;
|
|
||||||
|
|
||||||
final class HelpdeskDatabaseSeeder extends Seeder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Seed the Helpdesk module lookup data and default workflow templates.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
$this->seedStatuses();
|
|
||||||
$this->seedPriorities();
|
|
||||||
$this->seedCategories();
|
|
||||||
$this->seedTeamsAndProjects();
|
|
||||||
$this->seedKanbanBoards();
|
|
||||||
$this->seedWorkflowTemplates();
|
|
||||||
$this->seedRedmineInspiredDefaults();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed ticket workflow statuses.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function seedStatuses(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['name' => 'New', 'slug' => 'new', 'color' => 'sky', 'sort_order' => 5, 'is_closed' => false],
|
|
||||||
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false],
|
|
||||||
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false],
|
|
||||||
['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'rose', 'sort_order' => 30, 'is_closed' => false],
|
|
||||||
['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'violet', 'sort_order' => 40, 'is_closed' => false],
|
|
||||||
['name' => 'Closed', 'slug' => 'closed', 'color' => 'emerald', 'sort_order' => 90, 'is_closed' => true],
|
|
||||||
] as $row) {
|
|
||||||
Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed ticket priorities.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function seedPriorities(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10],
|
|
||||||
['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20],
|
|
||||||
['name' => 'Low', 'slug' => 'low', 'color' => 'emerald', 'sort_order' => 30],
|
|
||||||
] as $row) {
|
|
||||||
Priority::query()->updateOrCreate(['slug' => $row['slug']], $row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed ticket categories.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function seedCategories(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.'],
|
|
||||||
['name' => 'Systems Deployment', 'color' => 'cyan', 'description' => 'Deployment, release, infrastructure and environment requests.'],
|
|
||||||
['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.'],
|
|
||||||
['name' => 'Bug', 'color' => 'rose', 'description' => 'Defects, regressions and incorrect behaviour.'],
|
|
||||||
] as $row) {
|
|
||||||
Category::query()->updateOrCreate(
|
|
||||||
['company_id' => null, 'slug' => Str::slug($row['name'])],
|
|
||||||
$row + ['company_id' => null, 'slug' => Str::slug($row['name'])]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed the default support team and a default project.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function seedTeamsAndProjects(): void
|
|
||||||
{
|
|
||||||
$team = Team::query()->updateOrCreate(
|
|
||||||
['company_id' => null, 'slug' => 'support'],
|
|
||||||
[
|
|
||||||
'company_id' => null,
|
|
||||||
'name' => 'Support',
|
|
||||||
'slug' => 'support',
|
|
||||||
'color' => 'blue',
|
|
||||||
'description' => 'Default support team for triage, ownership and ticket routing.',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
Project::query()->updateOrCreate(
|
|
||||||
['company_id' => null, 'slug' => 'support-desk'],
|
|
||||||
[
|
|
||||||
'company_id' => null,
|
|
||||||
'team_id' => $team->id,
|
|
||||||
'title' => 'Support Desk',
|
|
||||||
'slug' => 'support-desk',
|
|
||||||
'color' => 'indigo',
|
|
||||||
'description' => 'Default project for operational helpdesk tickets.',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed the default configurable Kanban board.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function seedKanbanBoards(): void
|
|
||||||
{
|
|
||||||
KanbanBoard::query()->where('is_default', true)->update(['is_default' => false]);
|
|
||||||
|
|
||||||
$board = KanbanBoard::query()->updateOrCreate(
|
|
||||||
['slug' => 'standard-support'],
|
|
||||||
[
|
|
||||||
'name' => 'Standard Support',
|
|
||||||
'slug' => 'standard-support',
|
|
||||||
'description' => 'Default Helpdesk workflow board covering new work through closure.',
|
|
||||||
'is_default' => true,
|
|
||||||
'sort_order' => 1,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$statuses = Status::query()
|
|
||||||
->whereIn('slug', ['new', 'open', 'in-progress', 'stalled', 'on-hold', 'closed'])
|
|
||||||
->orderBy('sort_order')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$sync = [];
|
|
||||||
|
|
||||||
foreach ($statuses as $index => $status) {
|
|
||||||
$sync[$status->id] = ['sort_order' => ($index + 1) * 10];
|
|
||||||
}
|
|
||||||
|
|
||||||
$board->statuses()->syncWithoutDetaching($sync);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed default professional workflow email templates.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
private function seedWorkflowTemplates(): void
|
|
||||||
{
|
|
||||||
foreach ([
|
|
||||||
['name' => 'Ticket raised requester notice', 'event' => 'ticket.created', 'subject' => 'Ticket {{ ticket.reference }} has been raised', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been raised successfully.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
|
|
||||||
['name' => 'Ticket updated requester notice', 'event' => 'ticket.updated', 'subject' => 'Ticket {{ ticket.reference }} has been updated', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been updated.</p><p><strong>Status:</strong> {{ ticket.status }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
|
|
||||||
['name' => 'Ticket response requester notice', 'event' => 'ticket.commented', 'subject' => 'New response on ticket {{ ticket.reference }}', 'html_body' => '<p>Hello {{ requester.name }},</p><p>A new response has been added to <strong>{{ ticket.reference }}</strong>.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p>', 'to_role' => 'requester'],
|
|
||||||
['name' => 'Ticket closed requester notice', 'event' => 'ticket.closed', 'subject' => 'Ticket {{ ticket.reference }} has been closed', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been closed.</p><p>Thank you for using the support desk.</p>', 'to_role' => 'requester'],
|
|
||||||
] as $template) {
|
|
||||||
WorkflowTemplate::query()->updateOrCreate(
|
|
||||||
['name' => $template['name'], 'event' => $template['event']],
|
|
||||||
$template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed RedmineUP-inspired professional helpdesk defaults.
|
|
||||||
*/
|
|
||||||
private function seedRedmineInspiredDefaults(): void
|
|
||||||
{
|
|
||||||
$medium = Priority::query()->where('slug', 'medium')->first();
|
|
||||||
$bug = Category::query()->where('slug', 'bug')->first();
|
|
||||||
|
|
||||||
SlaPolicy::query()->updateOrCreate(
|
|
||||||
['company_id' => null, 'slug' => 'standard-support-sla'],
|
|
||||||
[
|
|
||||||
'company_id' => null,
|
|
||||||
'priority_id' => $medium?->id,
|
|
||||||
'category_id' => null,
|
|
||||||
'name' => 'Standard Support SLA',
|
|
||||||
'slug' => 'standard-support-sla',
|
|
||||||
'first_response_minutes' => 240,
|
|
||||||
'resolution_minutes' => 2880,
|
|
||||||
'business_hours_only' => false,
|
|
||||||
'is_default' => true,
|
|
||||||
'is_active' => true,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
CannedResponse::query()->updateOrCreate(
|
|
||||||
['company_id' => null, 'slug' => 'professional-acknowledgement'],
|
|
||||||
[
|
|
||||||
'company_id' => null,
|
|
||||||
'category_id' => null,
|
|
||||||
'team_id' => null,
|
|
||||||
'name' => 'Professional acknowledgement',
|
|
||||||
'slug' => 'professional-acknowledgement',
|
|
||||||
'visibility' => 'agents',
|
|
||||||
'subject' => null,
|
|
||||||
'body' => '<p>Hello {{ requester.name }},</p><p>Thank you for the update on {{ ticket.reference }}. I am reviewing this now and will come back with the next action shortly.</p><p>Regards,<br>{{ agent.name }}</p>',
|
|
||||||
'is_active' => true,
|
|
||||||
'sort_order' => 10,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
ChecklistTemplate::query()->updateOrCreate(
|
|
||||||
['company_id' => null, 'slug' => 'bug-triage-checklist'],
|
|
||||||
[
|
|
||||||
'company_id' => null,
|
|
||||||
'category_id' => $bug?->id,
|
|
||||||
'project_id' => null,
|
|
||||||
'name' => 'Bug triage checklist',
|
|
||||||
'slug' => 'bug-triage-checklist',
|
|
||||||
'items' => ['Confirm reproduction steps', 'Check recent deployment/build', 'Identify affected tenant/company', 'Record fix or workaround'],
|
|
||||||
'is_active' => true,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ([
|
|
||||||
['name' => 'Escalated', 'slug' => 'escalated', 'color' => 'red'],
|
|
||||||
['name' => 'Waiting on customer', 'slug' => 'waiting-on-customer', 'color' => 'amber'],
|
|
||||||
] as $tag) {
|
|
||||||
Tag::query()->updateOrCreate(['company_id' => null, 'slug' => $tag['slug']], $tag + ['company_id' => null]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Product::query()->updateOrCreate(
|
|
||||||
['company_id' => null, 'slug' => 'wms-platform'],
|
|
||||||
[
|
|
||||||
'company_id' => null,
|
|
||||||
'name' => 'WMS Platform',
|
|
||||||
'slug' => 'wms-platform',
|
|
||||||
'type' => 'service',
|
|
||||||
'version' => null,
|
|
||||||
'release_channel' => 'core',
|
|
||||||
'description' => 'Default service record for WMS platform support tickets.',
|
|
||||||
'is_active' => true,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# Helpdesk Git Webhook Tracking
|
|
||||||
|
|
||||||
The Helpdesk module can ingest signed Git push webhooks and automatically link commits to tickets when commit messages, branch names, or commit URLs contain a ticket reference such as `HD-2026-00012`.
|
|
||||||
|
|
||||||
## 1. Register the repository
|
|
||||||
|
|
||||||
Open the Helpdesk settings page:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/helpdesk/settings
|
|
||||||
```
|
|
||||||
|
|
||||||
In the **Git** section, create a repository with:
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| Provider | `github`, `gitlab`, or `git` |
|
|
||||||
| Remote URL | Your repository remote URL |
|
|
||||||
| Default branch | Usually `develop` or `main` |
|
|
||||||
| Webhook secret | A strong random secret, for example `openssl rand -hex 32` |
|
|
||||||
|
|
||||||
After saving, the repository table shows the webhook endpoint:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/api/helpdesk/git/webhook/{repository_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Ticket reference convention
|
|
||||||
|
|
||||||
Use the Helpdesk ticket reference in branch names and commit messages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/HD-2026-00012-canned-responses
|
|
||||||
git commit -m "HD-2026-00012 Add canned response macro rendering"
|
|
||||||
```
|
|
||||||
|
|
||||||
The parser recognises this format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
HD-YYYY-00000
|
|
||||||
```
|
|
||||||
|
|
||||||
The first matching reference is linked to the stored commit when the ticket exists and matches the repository company/project scope.
|
|
||||||
|
|
||||||
## 3. GitHub setup
|
|
||||||
|
|
||||||
In GitHub, open the repository settings and add a webhook:
|
|
||||||
|
|
||||||
| GitHub field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| Payload URL | `https://your-app.test/api/helpdesk/git/webhook/{repository_id}` |
|
|
||||||
| Content type | `application/json` |
|
|
||||||
| Secret | Same value stored in Helpdesk |
|
|
||||||
| Events | Push events |
|
|
||||||
|
|
||||||
The module validates `X-Hub-Signature-256` using HMAC SHA-256 and rejects unsigned or invalid requests.
|
|
||||||
|
|
||||||
## 4. GitLab setup
|
|
||||||
|
|
||||||
In GitLab, open **Settings > Webhooks** and add:
|
|
||||||
|
|
||||||
| GitLab field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| URL | `https://your-app.test/api/helpdesk/git/webhook/{repository_id}` |
|
|
||||||
| Secret token | Same value stored in Helpdesk |
|
|
||||||
| Trigger | Push events |
|
|
||||||
|
|
||||||
The module validates `X-Gitlab-Token` and rejects invalid requests.
|
|
||||||
|
|
||||||
## 5. Generic Git provider setup
|
|
||||||
|
|
||||||
For a simple internal provider, send a JSON push payload to the same endpoint with:
|
|
||||||
|
|
||||||
```http
|
|
||||||
X-Helpdesk-Token: your-secret
|
|
||||||
X-Helpdesk-Event: push
|
|
||||||
```
|
|
||||||
|
|
||||||
The payload should include:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ref": "refs/heads/develop",
|
|
||||||
"commits": [
|
|
||||||
{
|
|
||||||
"id": "full-commit-hash",
|
|
||||||
"message": "HD-2026-00012 Fix ticket workflow",
|
|
||||||
"url": "https://example.test/commit/full-commit-hash",
|
|
||||||
"timestamp": "2026-06-15T10:00:00+00:00",
|
|
||||||
"author": {
|
|
||||||
"name": "Support Agent",
|
|
||||||
"email": "agent@example.com"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Audit trail
|
|
||||||
|
|
||||||
Every accepted, ignored, or rejected webhook delivery is recorded in:
|
|
||||||
|
|
||||||
```text
|
|
||||||
helpdesk_git_webhook_logs
|
|
||||||
```
|
|
||||||
|
|
||||||
Stored data includes provider, event, delivery id, status, message, commit count, linked count, ignored count, payload, and received timestamp.
|
|
||||||
|
|
||||||
## 7. Verification commands
|
|
||||||
|
|
||||||
Run the focused feature test after installing the updated module:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan test Modules/Helpdesk/Tests/Feature/GitWebhookIngestionTest.php
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the full Helpdesk test set:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan test Modules/Helpdesk/Tests/Feature Modules/Helpdesk/Tests/Unit
|
|
||||||
php artisan dusk Modules/Helpdesk/Tests/Browser
|
|
||||||
```
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# Helpdesk Module Install
|
|
||||||
|
|
||||||
## 1. Copy the module
|
|
||||||
|
|
||||||
Copy `Modules/Helpdesk` into your Laravel 13 project.
|
|
||||||
|
|
||||||
## 2. Register the provider
|
|
||||||
|
|
||||||
If your module loader reads `module.json`, no manual provider entry is needed. Otherwise add this provider to `bootstrap/providers.php`:
|
|
||||||
|
|
||||||
```php
|
|
||||||
Modules\Helpdesk\Providers\HelpdeskServiceProvider::class,
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Add the helpdesk disk
|
|
||||||
|
|
||||||
Add this to `config/filesystems.php` disks:
|
|
||||||
|
|
||||||
```php
|
|
||||||
'helpdesk' => [
|
|
||||||
'driver' => 'local',
|
|
||||||
'root' => storage_path('app/public/helpdesk'),
|
|
||||||
'url' => env('APP_URL') . '/storage/helpdesk',
|
|
||||||
'visibility' => 'public',
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan storage:link
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Add relations to `App\Models\User`
|
|
||||||
|
|
||||||
```php
|
|
||||||
use Modules\Helpdesk\Concerns\HasHelpdeskTickets;
|
|
||||||
|
|
||||||
class User extends Authenticatable
|
|
||||||
{
|
|
||||||
use HasHelpdeskTickets;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add casts if you want strict booleans:
|
|
||||||
|
|
||||||
```php
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
|
||||||
'helpdesk_agent' => 'boolean',
|
|
||||||
'helpdesk_admin' => 'boolean',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Run migrations and seeders
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan migrate
|
|
||||||
php artisan db:seed --class="Modules\\Helpdesk\\database\\seeders\\HelpdeskDatabaseSeeder"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Add dashboard widget
|
|
||||||
|
|
||||||
Place this where you want it on your dashboard:
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@include('helpdesk::dashboard.card')
|
|
||||||
```
|
|
||||||
|
|
||||||
or directly:
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<livewire:helpdesk.dashboard-widget />
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Navigation link
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<flux:navlist.item icon="lifebuoy" :href="route('helpdesk.tickets.index')" :current="request()->routeIs('helpdesk.*')" wire:navigate>
|
|
||||||
Helpdesk
|
|
||||||
</flux:navlist.item>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan test Modules/Helpdesk/tests/Feature
|
|
||||||
php artisan dusk Modules/Helpdesk/tests/Browser
|
|
||||||
```
|
|
||||||
## 9. Git webhook tracking
|
|
||||||
|
|
||||||
The module supports signed Git push webhooks for repository commit tracking. Register repositories in `/helpdesk/settings`, copy the generated webhook endpoint from the Git table, and configure GitHub/GitLab push webhooks with the same secret stored on the repository.
|
|
||||||
|
|
||||||
See `Docs/GIT_WEBHOOK_TRACKING.md` for provider-specific setup, ticket reference conventions, and verification commands.
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# WMS Support Menu Patch
|
|
||||||
|
|
||||||
Add this block to the main application sidebar inside `<nav class="wms-sidebar-nav">`, preferably after the Operations group and before Integration & Reporting.
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@includeIf('helpdesk::partials.sidebar-support-menu')
|
|
||||||
```
|
|
||||||
|
|
||||||
The partial checks that the Helpdesk module is enabled through `nwidart/laravel-modules` when the Module facade exists, and also checks that the Helpdesk routes are registered before showing links.
|
|
||||||
|
|
||||||
After adding the include, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
artisan optimize:clear
|
|
||||||
artisan octane:reload
|
|
||||||
```
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
if (! function_exists('helpdesk_module_name')) {
|
|
||||||
/**
|
|
||||||
* Return the Helpdesk module name.
|
|
||||||
*/
|
|
||||||
function helpdesk_module_name(): string
|
|
||||||
{
|
|
||||||
return 'Helpdesk';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Http\Controllers;
|
|
||||||
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use JsonException;
|
|
||||||
use Modules\Helpdesk\Models\GitRepository;
|
|
||||||
use Modules\Helpdesk\Services\GitWebhookIngestionService;
|
|
||||||
|
|
||||||
final class GitWebhookController
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Ingest a signed Git provider webhook for a registered Helpdesk repository.
|
|
||||||
*/
|
|
||||||
public function __invoke(Request $request, GitRepository $repository, GitWebhookIngestionService $service): JsonResponse
|
|
||||||
{
|
|
||||||
$provider = strtolower((string) ($repository->provider ?: 'git'));
|
|
||||||
$event = $this->eventName($request, $provider);
|
|
||||||
$deliveryId = $this->deliveryId($request, $provider);
|
|
||||||
$rawPayload = $request->getContent();
|
|
||||||
|
|
||||||
if (! $repository->is_active) {
|
|
||||||
$result = ['status' => 'rejected', 'message' => 'Repository is inactive.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
|
|
||||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
|
|
||||||
|
|
||||||
return response()->json($result, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
|
|
||||||
$result = ['status' => 'rejected', 'message' => 'Git webhook signature or token validation failed.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
|
|
||||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
|
|
||||||
|
|
||||||
return response()->json($result, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
/** @var array<string, mixed> $payload */
|
|
||||||
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
|
||||||
} catch (JsonException) {
|
|
||||||
$result = ['status' => 'rejected', 'message' => 'Invalid JSON webhook payload.', 'commit_count' => 0, 'linked_count' => 0, 'ignored_count' => 1];
|
|
||||||
$service->logWebhook($repository, $provider, $deliveryId, $event, $result);
|
|
||||||
|
|
||||||
return response()->json($result, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $service->ingest($repository, $payload, $provider, $deliveryId, $event);
|
|
||||||
|
|
||||||
return response()->json($result, 202);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate provider-specific webhook signatures or tokens.
|
|
||||||
*/
|
|
||||||
private function hasValidSignature(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
|
|
||||||
{
|
|
||||||
$secret = trim((string) $repository->webhook_secret);
|
|
||||||
|
|
||||||
if ($secret === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($provider) {
|
|
||||||
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
|
|
||||||
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
|
|
||||||
default => hash_equals($secret, (string) $request->header('X-Helpdesk-Token', '')),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate GitHub's X-Hub-Signature-256 HMAC header.
|
|
||||||
*/
|
|
||||||
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
|
|
||||||
{
|
|
||||||
$signature = (string) $request->header('X-Hub-Signature-256', '');
|
|
||||||
$expected = 'sha256='.hash_hmac('sha256', $rawPayload, $secret);
|
|
||||||
|
|
||||||
return hash_equals($expected, $signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the event name from provider-specific headers.
|
|
||||||
*/
|
|
||||||
private function eventName(Request $request, string $provider): ?string
|
|
||||||
{
|
|
||||||
return match ($provider) {
|
|
||||||
'github' => $request->header('X-GitHub-Event'),
|
|
||||||
'gitlab' => $request->header('X-Gitlab-Event'),
|
|
||||||
default => $request->header('X-Helpdesk-Event', 'push'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the provider delivery/request id used for idempotent audit tracing.
|
|
||||||
*/
|
|
||||||
private function deliveryId(Request $request, string $provider): ?string
|
|
||||||
{
|
|
||||||
return match ($provider) {
|
|
||||||
'github' => $request->header('X-GitHub-Delivery'),
|
|
||||||
'gitlab' => $request->header('X-Gitlab-Event-UUID') ?: $request->header('X-Request-Id'),
|
|
||||||
default => $request->header('X-Helpdesk-Delivery'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Support\HtmlString;
|
|
||||||
|
|
||||||
final class HelpdeskController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display the Helpdesk module landing page inside the application shell.
|
|
||||||
*/
|
|
||||||
public function __invoke(): View
|
|
||||||
{
|
|
||||||
return view('layouts.app', [
|
|
||||||
'title' => __('Helpdesk'),
|
|
||||||
'slot' => new HtmlString(view('helpdesk::index')->render()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Modules\Helpdesk\Services\TicketService;
|
|
||||||
|
|
||||||
final class TicketStoreController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Store a new helpdesk ticket using a normal HTTP form post.
|
|
||||||
*
|
|
||||||
* This avoids the Livewire update endpoint for ticket creation, which prevents
|
|
||||||
* proxy/hash update-route 404s from blocking ticket creation.
|
|
||||||
*/
|
|
||||||
public function __invoke(Request $request, TicketService $tickets): RedirectResponse
|
|
||||||
{
|
|
||||||
$maxUploadKilobytes = (int) config('helpdesk.max_upload_kb', 10240);
|
|
||||||
$allowedMimes = (array) config('helpdesk.allowed_mimes', ['jpg', 'jpeg', 'png', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx']);
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'company_id' => ['nullable', 'integer'],
|
|
||||||
'subject' => ['required', 'string', 'max:200'],
|
|
||||||
'body' => ['required', 'string', 'min:10'],
|
|
||||||
'priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')],
|
|
||||||
'category_id' => ['nullable', 'integer', Rule::exists('helpdesk_categories', 'id')],
|
|
||||||
'project_id' => ['nullable', 'integer', Rule::exists('helpdesk_projects', 'id')],
|
|
||||||
'product_id' => ['nullable', 'integer', Rule::exists('helpdesk_products', 'id')],
|
|
||||||
'team_id' => ['nullable', 'integer', Rule::exists('helpdesk_teams', 'id')],
|
|
||||||
'due_at' => ['nullable', 'date'],
|
|
||||||
'attachments' => ['nullable', 'array'],
|
|
||||||
'attachments.*' => ['file', 'max:'.$maxUploadKilobytes, 'mimes:'.implode(',', $allowedMimes)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = Auth::user();
|
|
||||||
|
|
||||||
abort_if($user === null, 403);
|
|
||||||
|
|
||||||
$uploadedFiles = $request->file('attachments', []);
|
|
||||||
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
|
|
||||||
|
|
||||||
$ticket = $tickets->create($user, [
|
|
||||||
'company_id' => $validated['company_id'] ?? null,
|
|
||||||
'subject' => $validated['subject'],
|
|
||||||
'body' => $validated['body'],
|
|
||||||
'priority_id' => $validated['priority_id'],
|
|
||||||
'category_id' => $validated['category_id'] ?? null,
|
|
||||||
'project_id' => $validated['project_id'] ?? null,
|
|
||||||
'product_id' => $validated['product_id'] ?? null,
|
|
||||||
'team_id' => $validated['team_id'] ?? null,
|
|
||||||
'due_at' => $validated['due_at'] ?? null,
|
|
||||||
], $uploadedFiles);
|
|
||||||
|
|
||||||
return redirect()
|
|
||||||
->route('helpdesk.tickets.index')
|
|
||||||
->with('success', 'Ticket '.$ticket->reference.' has been created.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Http\Controllers;
|
|
||||||
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Modules\Helpdesk\Models\WorkflowLog;
|
|
||||||
|
|
||||||
final class WorkflowOpenController
|
|
||||||
{
|
|
||||||
public function __invoke(string $token): Response
|
|
||||||
{
|
|
||||||
$log = WorkflowLog::query()->where('tracking_token', $token)->first();
|
|
||||||
|
|
||||||
if ($log) {
|
|
||||||
$log->forceFill([
|
|
||||||
'first_opened_at' => $log->first_opened_at ?? now(),
|
|
||||||
'last_opened_at' => now(),
|
|
||||||
'opened_count' => ((int) $log->opened_count) + 1,
|
|
||||||
])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(base64_decode('R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='), 200, [
|
|
||||||
'Content-Type' => 'image/gif',
|
|
||||||
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Modules\Helpdesk\Models\GitCommit;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
|
|
||||||
final class HelpdeskDashboardWidget extends Component
|
|
||||||
{
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
$user = Auth::user();
|
|
||||||
$base = Ticket::query()->visibleTo($user);
|
|
||||||
$totalCount = (clone $base)->count();
|
|
||||||
$openCount = (clone $base)->whereNull('completed_at')->count();
|
|
||||||
$closedCount = (clone $base)->whereNotNull('completed_at')->count();
|
|
||||||
$criticalCount = (clone $base)->whereNull('completed_at')->whereHas('priority', static fn ($q) => $q->whereIn('slug', ['critical', 'urgent', 'high']))->count();
|
|
||||||
$closedPercent = $totalCount > 0 ? (int) round(($closedCount / $totalCount) * 100) : 0;
|
|
||||||
$openPercent = $totalCount > 0 ? (int) round(($openCount / $totalCount) * 100) : 0;
|
|
||||||
|
|
||||||
return view('helpdesk::livewire.dashboard-widget', [
|
|
||||||
'totalCount' => $totalCount,
|
|
||||||
'openCount' => $openCount,
|
|
||||||
'closedCount' => $closedCount,
|
|
||||||
'criticalCount' => $criticalCount,
|
|
||||||
'closedPercent' => $closedPercent,
|
|
||||||
'openPercent' => $openPercent,
|
|
||||||
'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(),
|
|
||||||
'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Modules\Helpdesk\Models\KanbanBoard as KanbanBoardModel;
|
|
||||||
use Modules\Helpdesk\Models\Status;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
|
||||||
|
|
||||||
final class KanbanBoard extends Component
|
|
||||||
{
|
|
||||||
public ?int $board_id = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->board_id = KanbanBoardModel::query()
|
|
||||||
->where('is_default', true)
|
|
||||||
->orderBy('sort_order')
|
|
||||||
->value('id') ?: KanbanBoardModel::query()->orderBy('sort_order')->value('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move a ticket to another workflow status.
|
|
||||||
*/
|
|
||||||
public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void
|
|
||||||
{
|
|
||||||
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
|
||||||
$status = Status::query()->findOrFail((int) $statusId);
|
|
||||||
|
|
||||||
$this->authorize('update', $ticket);
|
|
||||||
|
|
||||||
$ticket->forceFill([
|
|
||||||
'status_id' => $status->id,
|
|
||||||
'completed_at' => $status->is_closed ? now() : null,
|
|
||||||
'last_activity_at' => now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$workflow->dispatch(
|
|
||||||
$ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket,
|
|
||||||
$status->is_closed ? 'ticket.closed' : 'ticket.updated',
|
|
||||||
Auth::user()
|
|
||||||
);
|
|
||||||
|
|
||||||
session()->flash('success', 'Ticket moved to ' . $status->name . '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the Kanban board.
|
|
||||||
*/
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
$boards = KanbanBoardModel::query()
|
|
||||||
->with('statuses')
|
|
||||||
->orderBy('sort_order')
|
|
||||||
->orderBy('name')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$board = $boards->firstWhere('id', $this->board_id) ?? $boards->first();
|
|
||||||
$statuses = $board?->statuses ?? collect();
|
|
||||||
|
|
||||||
if ($statuses->isEmpty()) {
|
|
||||||
$statuses = Status::query()->orderBy('sort_order')->orderBy('name')->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('helpdesk::livewire.kanban-board', [
|
|
||||||
'boards' => $boards,
|
|
||||||
'board' => $board,
|
|
||||||
'statuses' => $statuses,
|
|
||||||
'ticketsByStatus' => $this->ticketsByStatus(),
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get visible tickets grouped by status.
|
|
||||||
*
|
|
||||||
* @return array<int, Collection<int, Ticket>>
|
|
||||||
*/
|
|
||||||
private function ticketsByStatus(): array
|
|
||||||
{
|
|
||||||
return Ticket::query()
|
|
||||||
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems'])
|
|
||||||
->visibleTo(Auth::user())
|
|
||||||
->latest('last_activity_at')
|
|
||||||
->limit(300)
|
|
||||||
->get()
|
|
||||||
->groupBy('status_id')
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,652 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Modules\Helpdesk\Models\AutomationRule;
|
|
||||||
use Modules\Helpdesk\Models\CannedResponse;
|
|
||||||
use Modules\Helpdesk\Models\ChecklistTemplate;
|
|
||||||
use Modules\Helpdesk\Models\Category;
|
|
||||||
use Modules\Helpdesk\Models\GitRepository;
|
|
||||||
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\SlaPolicy;
|
|
||||||
use Modules\Helpdesk\Models\Tag;
|
|
||||||
use Modules\Helpdesk\Models\Team;
|
|
||||||
use Modules\Helpdesk\Models\WorkflowTemplate;
|
|
||||||
|
|
||||||
final class SettingsManager extends Component
|
|
||||||
{
|
|
||||||
public string $section = 'statuses';
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $cannedForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $slaForm = ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $automationForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $productForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $tagForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc'];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
|
|
||||||
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
|
|
||||||
$this->categoryForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
|
|
||||||
$this->teamForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
|
|
||||||
$this->projectForm += ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
|
|
||||||
$this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
|
|
||||||
$this->repositoryForm += ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
|
|
||||||
$this->workflowForm += ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
|
|
||||||
$this->cannedForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
|
|
||||||
$this->slaForm += ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true];
|
|
||||||
$this->automationForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0];
|
|
||||||
$this->productForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
|
|
||||||
$this->tagForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc'];
|
|
||||||
$this->checklistTemplateForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveStatus(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'statusForm.name' => ['required', 'string', 'max:80'],
|
|
||||||
'statusForm.slug' => ['nullable', 'string', 'max:100'],
|
|
||||||
'statusForm.color' => ['required', 'string', 'max:30'],
|
|
||||||
'statusForm.sort_order' => ['required', 'integer', 'min:0'],
|
|
||||||
'statusForm.is_closed' => ['boolean'],
|
|
||||||
])['statusForm'];
|
|
||||||
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
Status::query()->updateOrCreate(['id' => $this->statusForm['id']], $data);
|
|
||||||
$this->resetStatusForm();
|
|
||||||
session()->flash('success', 'Status saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editStatus(int $id): void
|
|
||||||
{
|
|
||||||
$this->statusForm = Status::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order', 'is_closed']);
|
|
||||||
$this->section = 'statuses';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteStatus(int $id): void
|
|
||||||
{
|
|
||||||
Status::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Status deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetStatusForm(): void
|
|
||||||
{
|
|
||||||
$this->statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0, 'is_closed' => false];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function savePriority(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'priorityForm.name' => ['required', 'string', 'max:80'],
|
|
||||||
'priorityForm.slug' => ['nullable', 'string', 'max:100'],
|
|
||||||
'priorityForm.color' => ['required', 'string', 'max:30'],
|
|
||||||
'priorityForm.sort_order' => ['required', 'integer', 'min:0'],
|
|
||||||
])['priorityForm'];
|
|
||||||
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
Priority::query()->updateOrCreate(['id' => $this->priorityForm['id']], $data);
|
|
||||||
$this->resetPriorityForm();
|
|
||||||
session()->flash('success', 'Priority saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editPriority(int $id): void
|
|
||||||
{
|
|
||||||
$this->priorityForm = Priority::query()->findOrFail($id)->only(['id', 'name', 'slug', 'color', 'sort_order']);
|
|
||||||
$this->section = 'priorities';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deletePriority(int $id): void
|
|
||||||
{
|
|
||||||
Priority::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Priority deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetPriorityForm(): void
|
|
||||||
{
|
|
||||||
$this->priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc', 'sort_order' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveCategory(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'categoryForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'categoryForm.name' => ['required', 'string', 'max:120'],
|
|
||||||
'categoryForm.slug' => ['nullable', 'string', 'max:140'],
|
|
||||||
'categoryForm.color' => ['required', 'string', 'max:30'],
|
|
||||||
'categoryForm.description' => ['nullable', 'string'],
|
|
||||||
])['categoryForm'];
|
|
||||||
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
Category::query()->updateOrCreate(['id' => $this->categoryForm['id']], $data);
|
|
||||||
$this->resetCategoryForm();
|
|
||||||
session()->flash('success', 'Category saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editCategory(int $id): void
|
|
||||||
{
|
|
||||||
$this->categoryForm = Category::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description']);
|
|
||||||
$this->section = 'categories';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteCategory(int $id): void
|
|
||||||
{
|
|
||||||
Category::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Category deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetCategoryForm(): void
|
|
||||||
{
|
|
||||||
$this->categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => ''];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveTeam(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'teamForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'teamForm.name' => ['required', 'string', 'max:120'],
|
|
||||||
'teamForm.slug' => ['nullable', 'string', 'max:140'],
|
|
||||||
'teamForm.color' => ['required', 'string', 'max:30'],
|
|
||||||
'teamForm.description' => ['nullable', 'string'],
|
|
||||||
])['teamForm'];
|
|
||||||
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
Team::query()->updateOrCreate(['id' => $this->teamForm['id']], $data);
|
|
||||||
$this->resetTeamForm();
|
|
||||||
session()->flash('success', 'Team saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editTeam(int $id): void
|
|
||||||
{
|
|
||||||
$this->teamForm = Team::query()->findOrFail($id)->only(['id', 'company_id', 'name', 'slug', 'color', 'description', 'archived_at']);
|
|
||||||
$this->section = 'teams';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteTeam(int $id): void
|
|
||||||
{
|
|
||||||
Team::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Team deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetTeamForm(): void
|
|
||||||
{
|
|
||||||
$this->teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'archived_at' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveProject(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'projectForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'projectForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
|
||||||
'projectForm.title' => ['required', 'string', 'max:160'],
|
|
||||||
'projectForm.slug' => ['nullable', 'string', 'max:180'],
|
|
||||||
'projectForm.color' => ['required', 'string', 'max:30'],
|
|
||||||
'projectForm.description' => ['nullable', 'string'],
|
|
||||||
])['projectForm'];
|
|
||||||
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['title']);
|
|
||||||
Project::query()->updateOrCreate(['id' => $this->projectForm['id']], $data);
|
|
||||||
$this->resetProjectForm();
|
|
||||||
session()->flash('success', 'Project saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editProject(int $id): void
|
|
||||||
{
|
|
||||||
$this->projectForm = Project::query()->findOrFail($id)->only(['id', 'company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at']);
|
|
||||||
$this->section = 'projects';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteProject(int $id): void
|
|
||||||
{
|
|
||||||
Project::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Project deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetProjectForm(): void
|
|
||||||
{
|
|
||||||
$this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function saveBoard(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'boardForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'boardForm.slug' => ['nullable', 'string', 'max:180'],
|
|
||||||
'boardForm.description' => ['nullable', 'string'],
|
|
||||||
'boardForm.is_default' => ['boolean'],
|
|
||||||
'boardForm.sort_order' => ['required', 'integer', 'min:0'],
|
|
||||||
'boardForm.status_ids' => ['array'],
|
|
||||||
'boardForm.status_ids.*' => ['integer', 'exists:helpdesk_statuses,id'],
|
|
||||||
])['boardForm'];
|
|
||||||
|
|
||||||
$statusIds = collect($data['status_ids'] ?? [])->map(static fn ($id): int => (int) $id)->filter()->values();
|
|
||||||
unset($data['status_ids']);
|
|
||||||
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
|
|
||||||
if ((bool) ($data['is_default'] ?? false)) {
|
|
||||||
KanbanBoard::query()->whereKeyNot($this->boardForm['id'])->update(['is_default' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$board = KanbanBoard::query()->updateOrCreate(['id' => $this->boardForm['id']], $data);
|
|
||||||
|
|
||||||
$sync = [];
|
|
||||||
foreach ($statusIds as $index => $statusId) {
|
|
||||||
$sync[$statusId] = ['sort_order' => ($index + 1) * 10];
|
|
||||||
}
|
|
||||||
|
|
||||||
$board->statuses()->sync($sync);
|
|
||||||
$this->resetBoardForm();
|
|
||||||
$this->section = 'boards';
|
|
||||||
session()->flash('success', 'Kanban board saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editBoard(int $id): void
|
|
||||||
{
|
|
||||||
$board = KanbanBoard::query()->with('statuses')->findOrFail($id);
|
|
||||||
|
|
||||||
$this->boardForm = [
|
|
||||||
'id' => $board->id,
|
|
||||||
'name' => $board->name,
|
|
||||||
'slug' => $board->slug,
|
|
||||||
'description' => $board->description,
|
|
||||||
'is_default' => $board->is_default,
|
|
||||||
'sort_order' => $board->sort_order,
|
|
||||||
'status_ids' => $board->statuses->pluck('id')->map(static fn ($id): int => (int) $id)->all(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->section = 'boards';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteBoard(int $id): void
|
|
||||||
{
|
|
||||||
KanbanBoard::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Kanban board deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetBoardForm(): void
|
|
||||||
{
|
|
||||||
$this->boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveRepository(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'repositoryForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'repositoryForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
|
||||||
'repositoryForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'repositoryForm.provider' => ['required', 'string', 'max:40'],
|
|
||||||
'repositoryForm.remote_url' => ['required', 'string', 'max:500'],
|
|
||||||
'repositoryForm.default_branch' => ['required', 'string', 'max:120'],
|
|
||||||
'repositoryForm.webhook_secret' => ['nullable', 'string', 'max:120'],
|
|
||||||
])['repositoryForm'];
|
|
||||||
|
|
||||||
$data['provider'] = strtolower(trim((string) $data['provider']));
|
|
||||||
|
|
||||||
GitRepository::query()->updateOrCreate(['id' => $this->repositoryForm['id']], $data);
|
|
||||||
$this->resetRepositoryForm();
|
|
||||||
session()->flash('success', 'Git repository saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editRepository(int $id): void
|
|
||||||
{
|
|
||||||
$this->repositoryForm = GitRepository::query()->findOrFail($id)->only(['id', 'company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret']);
|
|
||||||
$this->section = 'git';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteRepository(int $id): void
|
|
||||||
{
|
|
||||||
GitRepository::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Git repository deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetRepositoryForm(): void
|
|
||||||
{
|
|
||||||
$this->repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveWorkflowTemplate(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'workflowForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'workflowForm.event' => ['required', 'string', 'max:80'],
|
|
||||||
'workflowForm.subject' => ['required', 'string', 'max:220'],
|
|
||||||
'workflowForm.html_body' => ['required', 'string'],
|
|
||||||
'workflowForm.text_body' => ['nullable', 'string'],
|
|
||||||
'workflowForm.to_role' => ['required', 'string', 'max:40'],
|
|
||||||
'workflowForm.cc' => ['nullable', 'string', 'max:500'],
|
|
||||||
'workflowForm.bcc' => ['nullable', 'string', 'max:500'],
|
|
||||||
'workflowForm.is_active' => ['boolean'],
|
|
||||||
])['workflowForm'];
|
|
||||||
|
|
||||||
WorkflowTemplate::query()->updateOrCreate(['id' => $this->workflowForm['id']], $data);
|
|
||||||
$this->resetWorkflowTemplateForm();
|
|
||||||
session()->flash('success', 'Workflow message saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editWorkflowTemplate(int $id): void
|
|
||||||
{
|
|
||||||
$this->workflowForm = WorkflowTemplate::query()->findOrFail($id)->only(['id', 'name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active']);
|
|
||||||
$this->section = 'workflow';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteWorkflowTemplate(int $id): void
|
|
||||||
{
|
|
||||||
WorkflowTemplate::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Workflow message deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetWorkflowTemplateForm(): void
|
|
||||||
{
|
|
||||||
$this->workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function saveCannedResponse(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'cannedForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'cannedForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
|
||||||
'cannedForm.team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
|
||||||
'cannedForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'cannedForm.slug' => ['nullable', 'string', 'max:180'],
|
|
||||||
'cannedForm.visibility' => ['required', 'string', 'max:40'],
|
|
||||||
'cannedForm.subject' => ['nullable', 'string', 'max:220'],
|
|
||||||
'cannedForm.body' => ['required', 'string'],
|
|
||||||
'cannedForm.is_active' => ['boolean'],
|
|
||||||
'cannedForm.sort_order' => ['required', 'integer', 'min:0'],
|
|
||||||
])['cannedForm'];
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
CannedResponse::query()->updateOrCreate(['id' => $this->cannedForm['id']], $data);
|
|
||||||
$this->resetCannedResponseForm();
|
|
||||||
session()->flash('success', 'Canned response saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editCannedResponse(int $id): void
|
|
||||||
{
|
|
||||||
$this->cannedForm = CannedResponse::query()->findOrFail($id)->only(['id','company_id','category_id','team_id','name','slug','visibility','subject','body','is_active','sort_order']);
|
|
||||||
$this->section = 'canned';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteCannedResponse(int $id): void
|
|
||||||
{
|
|
||||||
CannedResponse::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Canned response deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetCannedResponseForm(): void
|
|
||||||
{
|
|
||||||
$this->cannedForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'team_id' => null, 'name' => '', 'slug' => '', 'visibility' => 'agents', 'subject' => '', 'body' => '', 'is_active' => true, 'sort_order' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveSlaPolicy(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'slaForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'slaForm.priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'],
|
|
||||||
'slaForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
|
||||||
'slaForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'slaForm.slug' => ['nullable', 'string', 'max:180'],
|
|
||||||
'slaForm.first_response_minutes' => ['required', 'integer', 'min:1'],
|
|
||||||
'slaForm.resolution_minutes' => ['required', 'integer', 'min:1'],
|
|
||||||
'slaForm.business_hours_only' => ['boolean'],
|
|
||||||
'slaForm.is_default' => ['boolean'],
|
|
||||||
'slaForm.is_active' => ['boolean'],
|
|
||||||
])['slaForm'];
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
if ((bool) ($data['is_default'] ?? false)) {
|
|
||||||
SlaPolicy::query()->whereKeyNot($this->slaForm['id'])->update(['is_default' => false]);
|
|
||||||
}
|
|
||||||
SlaPolicy::query()->updateOrCreate(['id' => $this->slaForm['id']], $data);
|
|
||||||
$this->resetSlaPolicyForm();
|
|
||||||
session()->flash('success', 'SLA policy saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editSlaPolicy(int $id): void
|
|
||||||
{
|
|
||||||
$this->slaForm = SlaPolicy::query()->findOrFail($id)->only(['id','company_id','priority_id','category_id','name','slug','first_response_minutes','resolution_minutes','business_hours_only','is_default','is_active']);
|
|
||||||
$this->section = 'sla';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteSlaPolicy(int $id): void
|
|
||||||
{
|
|
||||||
SlaPolicy::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'SLA policy deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetSlaPolicyForm(): void
|
|
||||||
{
|
|
||||||
$this->slaForm = ['id' => null, 'company_id' => null, 'priority_id' => null, 'category_id' => null, 'name' => '', 'slug' => '', 'first_response_minutes' => 240, 'resolution_minutes' => 2880, 'business_hours_only' => false, 'is_default' => false, 'is_active' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveAutomationRule(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'automationForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'automationForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'automationForm.slug' => ['nullable', 'string', 'max:180'],
|
|
||||||
'automationForm.match_sender' => ['nullable', 'string', 'max:190'],
|
|
||||||
'automationForm.match_subject' => ['nullable', 'string', 'max:220'],
|
|
||||||
'automationForm.match_mailbox' => ['nullable', 'string', 'max:190'],
|
|
||||||
'automationForm.match_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
|
||||||
'automationForm.set_priority_id' => ['nullable', 'integer', 'exists:helpdesk_priorities,id'],
|
|
||||||
'automationForm.set_category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
|
||||||
'automationForm.set_team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
|
||||||
'automationForm.set_project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
|
||||||
'automationForm.set_status_id' => ['nullable', 'integer', 'exists:helpdesk_statuses,id'],
|
|
||||||
'automationForm.set_agent_id' => ['nullable', 'integer', 'exists:users,id'],
|
|
||||||
'automationForm.is_active' => ['boolean'],
|
|
||||||
'automationForm.sort_order' => ['required', 'integer', 'min:0'],
|
|
||||||
])['automationForm'];
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
AutomationRule::query()->updateOrCreate(['id' => $this->automationForm['id']], $data);
|
|
||||||
$this->resetAutomationRuleForm();
|
|
||||||
session()->flash('success', 'Automation rule saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editAutomationRule(int $id): void
|
|
||||||
{
|
|
||||||
$this->automationForm = AutomationRule::query()->findOrFail($id)->only(['id','company_id','name','slug','match_sender','match_subject','match_mailbox','match_category_id','set_priority_id','set_category_id','set_team_id','set_project_id','set_status_id','set_agent_id','is_active','sort_order']);
|
|
||||||
$this->section = 'automation';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteAutomationRule(int $id): void
|
|
||||||
{
|
|
||||||
AutomationRule::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Automation rule deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetAutomationRuleForm(): void
|
|
||||||
{
|
|
||||||
$this->automationForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'match_sender' => '', 'match_subject' => '', 'match_mailbox' => '', 'match_category_id' => null, 'set_priority_id' => null, 'set_category_id' => null, 'set_team_id' => null, 'set_project_id' => null, 'set_status_id' => null, 'set_agent_id' => null, 'is_active' => true, 'sort_order' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveProduct(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'productForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'productForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'productForm.slug' => ['nullable', 'string', 'max:180'],
|
|
||||||
'productForm.type' => ['required', 'string', 'max:60'],
|
|
||||||
'productForm.version' => ['nullable', 'string', 'max:80'],
|
|
||||||
'productForm.release_channel' => ['nullable', 'string', 'max:80'],
|
|
||||||
'productForm.description' => ['nullable', 'string'],
|
|
||||||
'productForm.is_active' => ['boolean'],
|
|
||||||
])['productForm'];
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
Product::query()->updateOrCreate(['id' => $this->productForm['id']], $data);
|
|
||||||
$this->resetProductForm();
|
|
||||||
session()->flash('success', 'Product/service saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editProduct(int $id): void
|
|
||||||
{
|
|
||||||
$this->productForm = Product::query()->findOrFail($id)->only(['id','company_id','name','slug','type','version','release_channel','description','is_active']);
|
|
||||||
$this->section = 'products';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteProduct(int $id): void
|
|
||||||
{
|
|
||||||
Product::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Product/service deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetProductForm(): void
|
|
||||||
{
|
|
||||||
$this->productForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'type' => 'service', 'version' => '', 'release_channel' => '', 'description' => '', 'is_active' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveTag(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'tagForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'tagForm.name' => ['required', 'string', 'max:80'],
|
|
||||||
'tagForm.slug' => ['nullable', 'string', 'max:100'],
|
|
||||||
'tagForm.color' => ['required', 'string', 'max:30'],
|
|
||||||
])['tagForm'];
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
Tag::query()->updateOrCreate(['id' => $this->tagForm['id']], $data);
|
|
||||||
$this->resetTagForm();
|
|
||||||
session()->flash('success', 'Tag saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editTag(int $id): void
|
|
||||||
{
|
|
||||||
$this->tagForm = Tag::query()->findOrFail($id)->only(['id','company_id','name','slug','color']);
|
|
||||||
$this->section = 'tags';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteTag(int $id): void
|
|
||||||
{
|
|
||||||
Tag::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Tag deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetTagForm(): void
|
|
||||||
{
|
|
||||||
$this->tagForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'zinc'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveChecklistTemplate(): void
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'checklistTemplateForm.company_id' => ['nullable', 'integer'],
|
|
||||||
'checklistTemplateForm.category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
|
||||||
'checklistTemplateForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
|
||||||
'checklistTemplateForm.name' => ['required', 'string', 'max:160'],
|
|
||||||
'checklistTemplateForm.slug' => ['nullable', 'string', 'max:180'],
|
|
||||||
'checklistTemplateForm.items_text' => ['required', 'string'],
|
|
||||||
'checklistTemplateForm.is_active' => ['boolean'],
|
|
||||||
])['checklistTemplateForm'];
|
|
||||||
$itemsText = (string) ($data['items_text'] ?? '');
|
|
||||||
unset($data['items_text']);
|
|
||||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
|
||||||
$data['items'] = collect(preg_split('/\r\n|\r|\n/', $itemsText) ?: [])->map(fn ($line): string => trim($line))->filter()->values()->all();
|
|
||||||
ChecklistTemplate::query()->updateOrCreate(['id' => $this->checklistTemplateForm['id']], $data);
|
|
||||||
$this->resetChecklistTemplateForm();
|
|
||||||
session()->flash('success', 'Checklist template saved.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editChecklistTemplate(int $id): void
|
|
||||||
{
|
|
||||||
$template = ChecklistTemplate::query()->findOrFail($id);
|
|
||||||
$this->checklistTemplateForm = $template->only(['id','company_id','category_id','project_id','name','slug','is_active']) + [
|
|
||||||
'items_text' => collect($template->items ?? [])->map(fn ($item): string => is_array($item) ? (string) ($item['title'] ?? '') : (string) $item)->implode("\n"),
|
|
||||||
];
|
|
||||||
$this->section = 'checklists';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteChecklistTemplate(int $id): void
|
|
||||||
{
|
|
||||||
ChecklistTemplate::query()->findOrFail($id)->delete();
|
|
||||||
session()->flash('success', 'Checklist template deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resetChecklistTemplateForm(): void
|
|
||||||
{
|
|
||||||
$this->checklistTemplateForm = ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('helpdesk::livewire.settings-manager', [
|
|
||||||
'companies' => $this->companies(),
|
|
||||||
'statuses' => Status::query()->withCount('tickets')->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
'priorities' => Priority::query()->withCount('tickets')->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
'categories' => Category::query()->withCount('tickets')->orderBy('name')->get(),
|
|
||||||
'teams' => Team::query()->withCount(['projects', 'tickets'])->orderBy('name')->get(),
|
|
||||||
'projects' => Project::query()->with(['team'])->withCount('tickets')->orderBy('title')->get(),
|
|
||||||
'boards' => KanbanBoard::query()->with('statuses')->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
'repositories' => GitRepository::query()->with(['project'])->withCount(['commits', 'webhookLogs'])->orderBy('name')->get(),
|
|
||||||
'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(),
|
|
||||||
'cannedResponses' => CannedResponse::query()->with(['category','team'])->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
'slaPolicies' => SlaPolicy::query()->with(['priority','category'])->orderByDesc('is_default')->orderBy('name')->get(),
|
|
||||||
'automationRules' => AutomationRule::query()->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
'products' => Product::query()->withCount('tickets')->orderBy('name')->get(),
|
|
||||||
'tags' => Tag::query()->orderBy('name')->get(),
|
|
||||||
'checklistTemplates' => ChecklistTemplate::query()->orderBy('name')->get(),
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int, object> */
|
|
||||||
private function companies(): array
|
|
||||||
{
|
|
||||||
if (! Schema::hasTable('companies')) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return DB::table('companies')->select(['id', 'name'])->orderBy('name')->get()->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function slug(string $value): string
|
|
||||||
{
|
|
||||||
return Str::slug($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
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\Priority;
|
|
||||||
use Modules\Helpdesk\Models\Product;
|
|
||||||
use Modules\Helpdesk\Models\Project;
|
|
||||||
use Modules\Helpdesk\Models\Team;
|
|
||||||
use Modules\Helpdesk\Services\TicketService;
|
|
||||||
|
|
||||||
final class TicketCreate extends Component
|
|
||||||
{
|
|
||||||
use WithFileUploads;
|
|
||||||
|
|
||||||
#[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_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|date')]
|
|
||||||
public ?string $due_at = null;
|
|
||||||
|
|
||||||
/** @var array<int, TemporaryUploadedFile> */
|
|
||||||
#[Validate('array')]
|
|
||||||
public array $attachments = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a new helpdesk ticket.
|
|
||||||
*/
|
|
||||||
public function save(TicketService $tickets): mixed
|
|
||||||
{
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb').'|mimes:'.implode(',', config('helpdesk.allowed_mimes')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$ticket = $tickets->create(Auth::user(), [
|
|
||||||
'company_id' => $this->company_id,
|
|
||||||
'subject' => $this->subject,
|
|
||||||
'body' => $this->body,
|
|
||||||
'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,
|
|
||||||
'due_at' => $this->due_at,
|
|
||||||
], $this->attachments);
|
|
||||||
return redirect()->route('helpdesk.tickets.index');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('helpdesk::livewire.ticket-create', [
|
|
||||||
'priorities' => Priority::query()->orderBy('sort_order')->get(),
|
|
||||||
'categories' => Category::query()->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(),
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Modules\Helpdesk\Models\Category;
|
|
||||||
use Modules\Helpdesk\Models\Priority;
|
|
||||||
use Modules\Helpdesk\Models\Product;
|
|
||||||
use Modules\Helpdesk\Models\Project;
|
|
||||||
use Modules\Helpdesk\Models\Status;
|
|
||||||
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
|
|
||||||
{
|
|
||||||
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('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;
|
|
||||||
|
|
||||||
public function mount(Ticket $ticket): void
|
|
||||||
{
|
|
||||||
$this->authorize('update', $ticket);
|
|
||||||
$this->ticket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'agent']) ?? $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->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');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed
|
|
||||||
{
|
|
||||||
$this->authorize('update', $this->ticket);
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
$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,
|
|
||||||
'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();
|
|
||||||
|
|
||||||
if ($status->is_closed) {
|
|
||||||
$sla->recordClosure($this->ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
$fresh = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'product']) ?? $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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('helpdesk::livewire.ticket-edit', [
|
|
||||||
'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
'categories' => Category::query()->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(),
|
|
||||||
'agents' => User::query()
|
|
||||||
->where(static function ($query): void {
|
|
||||||
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
|
|
||||||
})
|
|
||||||
->orderBy('name')
|
|
||||||
->get(['id', 'name', 'email']),
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Livewire\Attributes\Url;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\WithPagination;
|
|
||||||
use Modules\Helpdesk\Models\Priority;
|
|
||||||
use Modules\Helpdesk\Models\Status;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
|
|
||||||
final class TicketIndex extends Component
|
|
||||||
{
|
|
||||||
use WithPagination;
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $search = '';
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $status = '';
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $priority = '';
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $scope = 'open';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset pagination when filters change.
|
|
||||||
*/
|
|
||||||
public function updated(): void
|
|
||||||
{
|
|
||||||
$this->resetPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the ticket list.
|
|
||||||
*/
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('helpdesk::livewire.ticket-index', [
|
|
||||||
'tickets' => $this->tickets(),
|
|
||||||
'statuses' => Status::query()->orderBy('sort_order')->get(),
|
|
||||||
'priorities' => Priority::query()->orderBy('sort_order')->get(),
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build paginated ticket results.
|
|
||||||
*
|
|
||||||
* @return LengthAwarePaginator<int, Ticket>
|
|
||||||
*/
|
|
||||||
private function tickets(): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
$user = Auth::user();
|
|
||||||
$search = trim($this->search);
|
|
||||||
|
|
||||||
return Ticket::query()
|
|
||||||
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])
|
|
||||||
->visibleTo($user)
|
|
||||||
->when($this->scope === 'open', static fn (Builder $query): Builder => $query->whereNull('completed_at'))
|
|
||||||
->when($this->scope === 'closed', static fn (Builder $query): Builder => $query->whereNotNull('completed_at'))
|
|
||||||
->when($this->status !== '', fn (Builder $query): Builder => $query->where('status_id', (int) $this->status))
|
|
||||||
->when($this->priority !== '', fn (Builder $query): Builder => $query->where('priority_id', (int) $this->priority))
|
|
||||||
->when($search !== '', static function (Builder $query) use ($search): Builder {
|
|
||||||
return $query->where(static function (Builder $subQuery) use ($search): void {
|
|
||||||
$subQuery->where('reference', 'like', '%'.$search.'%')
|
|
||||||
->orWhere('subject', 'like', '%'.$search.'%')
|
|
||||||
->orWhere('body', 'like', '%'.$search.'%');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->latest('last_activity_at')
|
|
||||||
->paginate(15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Livewire;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
||||||
use Livewire\WithFileUploads;
|
|
||||||
use Modules\Helpdesk\Models\CannedResponse;
|
|
||||||
use Modules\Helpdesk\Models\ChecklistItem;
|
|
||||||
use Modules\Helpdesk\Models\Priority;
|
|
||||||
use Modules\Helpdesk\Models\Status;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
use Modules\Helpdesk\Services\CannedResponseRenderer;
|
|
||||||
use Modules\Helpdesk\Services\ChecklistService;
|
|
||||||
use Modules\Helpdesk\Services\SlaService;
|
|
||||||
use Modules\Helpdesk\Services\TicketService;
|
|
||||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
|
||||||
|
|
||||||
final class TicketShow extends Component
|
|
||||||
{
|
|
||||||
use WithFileUploads;
|
|
||||||
|
|
||||||
public Ticket $ticket;
|
|
||||||
|
|
||||||
#[Validate('required|string|min:2')]
|
|
||||||
public string $commentBody = '';
|
|
||||||
|
|
||||||
public bool $internal = false;
|
|
||||||
|
|
||||||
/** @var array<int, TemporaryUploadedFile> */
|
|
||||||
public array $attachments = [];
|
|
||||||
|
|
||||||
public ?int $status_id = null;
|
|
||||||
|
|
||||||
public ?int $priority_id = null;
|
|
||||||
|
|
||||||
public ?int $reply_parent_id = null;
|
|
||||||
|
|
||||||
public string $replyBody = '';
|
|
||||||
|
|
||||||
public ?int $canned_response_id = null;
|
|
||||||
|
|
||||||
public function mount(Ticket $ticket): void
|
|
||||||
{
|
|
||||||
$this->authorize('view', $ticket);
|
|
||||||
$this->ticket = $this->freshTicket($ticket);
|
|
||||||
$this->status_id = $this->ticket->status_id;
|
|
||||||
$this->priority_id = $this->ticket->priority_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function startReply(int $commentId): void
|
|
||||||
{
|
|
||||||
$this->reply_parent_id = $commentId;
|
|
||||||
$this->replyBody = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancelReply(): void
|
|
||||||
{
|
|
||||||
$this->reply_parent_id = null;
|
|
||||||
$this->replyBody = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addComment(TicketService $tickets): void
|
|
||||||
{
|
|
||||||
$this->authorize('comment', $this->ticket);
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'commentBody' => ['required', 'string', 'min:2'],
|
|
||||||
'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb').'|mimes:'.implode(',', config('helpdesk.allowed_mimes')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments);
|
|
||||||
|
|
||||||
$this->reset(['commentBody', 'internal', 'attachments']);
|
|
||||||
$this->ticket = $this->freshTicket($this->ticket);
|
|
||||||
$this->dispatch('helpdesk-editor-reset', model: 'commentBody');
|
|
||||||
$this->dispatch('helpdesk-comment-added');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addReply(TicketService $tickets): void
|
|
||||||
{
|
|
||||||
$this->authorize('comment', $this->ticket);
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'reply_parent_id' => ['required', 'integer', 'exists:helpdesk_comments,id'],
|
|
||||||
'replyBody' => ['required', 'string', 'min:2'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tickets->comment($this->ticket, Auth::user(), $this->replyBody, $this->internal, [], $this->reply_parent_id);
|
|
||||||
|
|
||||||
$this->reset(['reply_parent_id', 'replyBody']);
|
|
||||||
$this->ticket = $this->freshTicket($this->ticket);
|
|
||||||
$this->dispatch('helpdesk-editor-reset', model: 'replyBody');
|
|
||||||
$this->dispatch('helpdesk-reply-added');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function applyCannedResponse(CannedResponseRenderer $renderer): void
|
|
||||||
{
|
|
||||||
$this->authorize('comment', $this->ticket);
|
|
||||||
|
|
||||||
if ($this->canned_response_id === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = CannedResponse::query()
|
|
||||||
->where('is_active', true)
|
|
||||||
->findOrFail($this->canned_response_id);
|
|
||||||
|
|
||||||
$rendered = $renderer->render($response->body, $this->ticket, Auth::user());
|
|
||||||
$this->commentBody = trim($this->commentBody."\n\n".$rendered);
|
|
||||||
$this->dispatch('helpdesk-canned-response-applied', body: $this->commentBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleChecklistItem(int $itemId, ChecklistService $checklists): void
|
|
||||||
{
|
|
||||||
$this->authorize('update', $this->ticket);
|
|
||||||
|
|
||||||
$item = ChecklistItem::query()
|
|
||||||
->where('ticket_id', $this->ticket->id)
|
|
||||||
->findOrFail($itemId);
|
|
||||||
|
|
||||||
$checklists->toggle($item, Auth::user());
|
|
||||||
$this->ticket = $this->freshTicket($this->ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateStatus(WorkflowNotificationService $workflow, SlaService $sla): void
|
|
||||||
{
|
|
||||||
$this->authorize('update', $this->ticket);
|
|
||||||
$this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]);
|
|
||||||
|
|
||||||
$status = Status::query()->findOrFail($this->status_id);
|
|
||||||
$this->ticket->forceFill([
|
|
||||||
'status_id' => $status->id,
|
|
||||||
'completed_at' => $status->is_closed ? now() : null,
|
|
||||||
'last_activity_at' => now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
if ($status->is_closed) {
|
|
||||||
$sla->recordClosure($this->ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->ticket = $this->freshTicket($this->ticket);
|
|
||||||
$workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
|
|
||||||
$this->dispatch('helpdesk-status-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatePriority(WorkflowNotificationService $workflow): void
|
|
||||||
{
|
|
||||||
$this->authorize('update', $this->ticket);
|
|
||||||
$this->validate(['priority_id' => ['required', 'integer', 'exists:helpdesk_priorities,id']]);
|
|
||||||
|
|
||||||
$this->ticket->forceFill([
|
|
||||||
'priority_id' => $this->priority_id,
|
|
||||||
'last_activity_at' => now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->ticket = $this->freshTicket($this->ticket);
|
|
||||||
$workflow->dispatch($this->ticket, 'ticket.updated', Auth::user());
|
|
||||||
$this->dispatch('helpdesk-priority-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('helpdesk::livewire.ticket-show', [
|
|
||||||
'statuses' => Status::query()->orderBy('sort_order')->get(),
|
|
||||||
'priorities' => Priority::query()->orderBy('sort_order')->get(),
|
|
||||||
'cannedResponses' => CannedResponse::query()->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
|
|
||||||
])->layout('layouts.app');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function freshTicket(Ticket $ticket): Ticket
|
|
||||||
{
|
|
||||||
$ticket = $ticket->fresh([
|
|
||||||
'owner',
|
|
||||||
'agent',
|
|
||||||
'status',
|
|
||||||
'priority',
|
|
||||||
'category',
|
|
||||||
'project',
|
|
||||||
'team',
|
|
||||||
'product',
|
|
||||||
'slaPolicy',
|
|
||||||
'slaEvents',
|
|
||||||
'ratings',
|
|
||||||
'tags',
|
|
||||||
'checklistItems',
|
|
||||||
'attachments.user',
|
|
||||||
'gitCommits.repository',
|
|
||||||
]) ?? $ticket;
|
|
||||||
|
|
||||||
$comments = \Modules\Helpdesk\Models\Comment::query()
|
|
||||||
->where('ticket_id', $ticket->id)
|
|
||||||
->with(['user', 'attachments.user'])
|
|
||||||
->oldest()
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$ticket->setRelation('comments', $this->buildCommentTree($comments->groupBy('parent_id')));
|
|
||||||
|
|
||||||
return $ticket;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an unlimited nested comment tree from a flat eager-loaded collection.
|
|
||||||
*
|
|
||||||
* @param Collection<int|string, Collection<int, mixed>> $grouped
|
|
||||||
* @return Collection<int, mixed>
|
|
||||||
*/
|
|
||||||
private function buildCommentTree(Collection $grouped, ?int $parentId = null): Collection
|
|
||||||
{
|
|
||||||
return $grouped->get($parentId, collect())->map(function ($comment) use ($grouped) {
|
|
||||||
$comment->setRelation('replies', $this->buildCommentTree($grouped, (int) $comment->id));
|
|
||||||
|
|
||||||
return $comment;
|
|
||||||
})->values();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
final class Attachment extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_attachments';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['ticket_id', 'comment_id', 'user_id', 'disk', 'path', 'original_name', 'mime_type', 'size'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['size' => 'integer'];
|
|
||||||
|
|
||||||
/** @return BelongsTo<Ticket, Attachment> */
|
|
||||||
public function ticket(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Comment, Attachment> */
|
|
||||||
public function comment(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Comment::class, 'comment_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<User, Attachment> */
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function url(): string
|
|
||||||
{
|
|
||||||
return Storage::disk($this->disk)->url($this->path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
final class AutomationRule extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_automation_rules';
|
|
||||||
protected $fillable = ['company_id','name','slug','match_sender','match_subject','match_mailbox','match_category_id','set_priority_id','set_category_id','set_team_id','set_project_id','set_status_id','set_agent_id','is_active','sort_order'];
|
|
||||||
protected $casts = ['company_id'=>'integer','match_category_id'=>'integer','set_priority_id'=>'integer','set_category_id'=>'integer','set_team_id'=>'integer','set_project_id'=>'integer','set_status_id'=>'integer','set_agent_id'=>'integer','is_active'=>'boolean','sort_order'=>'integer'];
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
final class CannedResponse extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_canned_responses';
|
|
||||||
protected $fillable = ['company_id','category_id','team_id','name','slug','visibility','subject','body','is_active','sort_order'];
|
|
||||||
protected $casts = ['company_id'=>'integer','category_id'=>'integer','team_id'=>'integer','is_active'=>'boolean','sort_order'=>'integer'];
|
|
||||||
public function category(): BelongsTo { return $this->belongsTo(Category::class, 'category_id'); }
|
|
||||||
public function team(): BelongsTo { return $this->belongsTo(Team::class, 'team_id'); }
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
final class Category extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_categories';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['company_id' => 'integer'];
|
|
||||||
|
|
||||||
/** @return HasMany<Ticket> */
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'category_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsToMany<User> */
|
|
||||||
public function agents(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(User::class, 'helpdesk_category_user', 'category_id', 'user_id')->withTimestamps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
final class ChecklistItem extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_checklist_items';
|
|
||||||
protected $fillable = ['ticket_id','template_id','title','description','is_completed','completed_by','completed_at','sort_order'];
|
|
||||||
protected $casts = ['ticket_id'=>'integer','template_id'=>'integer','is_completed'=>'boolean','completed_by'=>'integer','completed_at'=>'datetime','sort_order'=>'integer'];
|
|
||||||
public function ticket(): BelongsTo { return $this->belongsTo(Ticket::class, 'ticket_id'); }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
final class ChecklistTemplate extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_checklist_templates';
|
|
||||||
protected $fillable = ['company_id','category_id','project_id','name','slug','items','is_active'];
|
|
||||||
protected $casts = ['company_id'=>'integer','category_id'=>'integer','project_id'=>'integer','items'=>'array','is_active'=>'boolean'];
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
final class Comment extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_comments';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['ticket_id', 'parent_id', 'user_id', 'body', 'is_internal'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['is_internal' => 'boolean'];
|
|
||||||
|
|
||||||
/** @return BelongsTo<Ticket, Comment> */
|
|
||||||
public function ticket(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Comment, Comment> */
|
|
||||||
public function parent(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(self::class, 'parent_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Comment> */
|
|
||||||
public function replies(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(self::class, 'parent_id')->oldest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<User, Comment> */
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Attachment> */
|
|
||||||
public function attachments(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Attachment::class, 'comment_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
final class GitCommit extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_git_commits';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = [
|
|
||||||
'repository_id', 'ticket_id', 'hash', 'short_hash', 'branch', 'author_name', 'author_email', 'message', 'url', 'committed_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['repository_id' => 'integer', 'ticket_id' => 'integer', 'committed_at' => 'datetime'];
|
|
||||||
|
|
||||||
/** @return BelongsTo<GitRepository, GitCommit> */
|
|
||||||
public function repository(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(GitRepository::class, 'repository_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Ticket, GitCommit> */
|
|
||||||
public function ticket(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
final class GitRepository extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_git_repositories';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret', 'is_active'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['company_id' => 'integer', 'project_id' => 'integer', 'is_active' => 'boolean'];
|
|
||||||
|
|
||||||
/** @return BelongsTo<Project, GitRepository> */
|
|
||||||
public function project(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Project::class, 'project_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<GitCommit> */
|
|
||||||
public function commits(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(GitCommit::class, 'repository_id')->latest('committed_at');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<GitWebhookLog> */
|
|
||||||
public function webhookLogs(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(GitWebhookLog::class, 'repository_id')->latest('received_at');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
final class GitWebhookLog extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_git_webhook_logs';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = [
|
|
||||||
'repository_id',
|
|
||||||
'provider',
|
|
||||||
'delivery_id',
|
|
||||||
'event',
|
|
||||||
'status',
|
|
||||||
'message',
|
|
||||||
'commit_count',
|
|
||||||
'linked_count',
|
|
||||||
'ignored_count',
|
|
||||||
'payload',
|
|
||||||
'received_at',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = [
|
|
||||||
'repository_id' => 'integer',
|
|
||||||
'commit_count' => 'integer',
|
|
||||||
'linked_count' => 'integer',
|
|
||||||
'ignored_count' => 'integer',
|
|
||||||
'payload' => 'array',
|
|
||||||
'received_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repository that received the provider webhook.
|
|
||||||
*
|
|
||||||
* @return BelongsTo<GitRepository, GitWebhookLog>
|
|
||||||
*/
|
|
||||||
public function repository(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(GitRepository::class, 'repository_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configurable Helpdesk Kanban board.
|
|
||||||
*/
|
|
||||||
final class KanbanBoard extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_kanban_boards';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'slug',
|
|
||||||
'description',
|
|
||||||
'is_default',
|
|
||||||
'sort_order',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = [
|
|
||||||
'is_default' => 'boolean',
|
|
||||||
'sort_order' => 'integer',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @return BelongsToMany<Status> */
|
|
||||||
public function statuses(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Status::class, 'helpdesk_kanban_board_statuses', 'board_id', 'status_id')
|
|
||||||
->withPivot(['sort_order'])
|
|
||||||
->orderBy('helpdesk_kanban_board_statuses.sort_order')
|
|
||||||
->orderBy('helpdesk_statuses.sort_order')
|
|
||||||
->withTimestamps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
final class Priority extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_priorities';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['name', 'slug', 'color', 'sort_order'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['sort_order' => 'integer'];
|
|
||||||
|
|
||||||
/** @return HasMany<Ticket> */
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'priority_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
final class Product extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_products';
|
|
||||||
protected $fillable = ['company_id','name','slug','type','version','release_channel','description','is_active'];
|
|
||||||
protected $casts = ['company_id'=>'integer','is_active'=>'boolean'];
|
|
||||||
public function tickets(): HasMany { return $this->hasMany(Ticket::class, 'product_id'); }
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
final class Project extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use SoftDeletes;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_projects';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['company_id' => 'integer', 'team_id' => 'integer', 'archived_at' => 'datetime'];
|
|
||||||
|
|
||||||
/** @return BelongsTo<Team, Project> */
|
|
||||||
public function team(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Team::class, 'team_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Ticket> */
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'project_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
final class SlaPolicy extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_sla_policies';
|
|
||||||
protected $fillable = ['company_id','priority_id','category_id','name','slug','first_response_minutes','resolution_minutes','business_hours_only','is_default','is_active'];
|
|
||||||
protected $casts = ['company_id'=>'integer','priority_id'=>'integer','category_id'=>'integer','first_response_minutes'=>'integer','resolution_minutes'=>'integer','business_hours_only'=>'boolean','is_default'=>'boolean','is_active'=>'boolean'];
|
|
||||||
public function priority(): BelongsTo { return $this->belongsTo(Priority::class, 'priority_id'); }
|
|
||||||
public function category(): BelongsTo { return $this->belongsTo(Category::class, 'category_id'); }
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $name
|
|
||||||
* @property string $slug
|
|
||||||
* @property string $color
|
|
||||||
* @property bool $is_closed
|
|
||||||
*/
|
|
||||||
final class Status extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_statuses';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['name', 'slug', 'color', 'sort_order', 'is_closed'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['is_closed' => 'boolean', 'sort_order' => 'integer'];
|
|
||||||
|
|
||||||
/** @return HasMany<Ticket> */
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'status_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
|
|
||||||
final class Tag extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_tags';
|
|
||||||
protected $fillable = ['company_id','name','slug','color'];
|
|
||||||
protected $casts = ['company_id'=>'integer'];
|
|
||||||
public function tickets(): BelongsToMany { return $this->belongsToMany(Ticket::class, 'helpdesk_ticket_tag', 'tag_id', 'ticket_id'); }
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
final class Team extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
use SoftDeletes;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_teams';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description', 'archived_at'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = [
|
|
||||||
'company_id' => 'integer',
|
|
||||||
'archived_at' => 'datetime',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @return HasMany<Project> */
|
|
||||||
public function projects(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Project::class, 'team_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Ticket> */
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'team_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $reference
|
|
||||||
* @property string $subject
|
|
||||||
* @property string $body
|
|
||||||
*/
|
|
||||||
final class Ticket extends Model
|
|
||||||
{
|
|
||||||
use SoftDeletes;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_tickets';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = [
|
|
||||||
'reference', 'company_id', 'user_id', 'agent_id', 'status_id', 'priority_id', 'category_id', 'project_id', 'team_id',
|
|
||||||
'subject', 'body', 'due_at', 'completed_at', 'last_activity_at', 'archived_at',
|
|
||||||
'sla_policy_id', 'product_id', 'first_response_at', 'sla_first_response_due_at', 'sla_resolution_due_at',
|
|
||||||
'sla_first_response_breached_at', 'sla_resolution_breached_at', 'public_replies_count', 'time_spent_minutes',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = [
|
|
||||||
'company_id' => 'integer',
|
|
||||||
'team_id' => 'integer',
|
|
||||||
'due_at' => 'datetime',
|
|
||||||
'completed_at' => 'datetime',
|
|
||||||
'last_activity_at' => 'datetime',
|
|
||||||
'archived_at' => 'datetime',
|
|
||||||
'sla_policy_id' => 'integer',
|
|
||||||
'product_id' => 'integer',
|
|
||||||
'first_response_at' => 'datetime',
|
|
||||||
'sla_first_response_due_at' => 'datetime',
|
|
||||||
'sla_resolution_due_at' => 'datetime',
|
|
||||||
'sla_first_response_breached_at' => 'datetime',
|
|
||||||
'sla_resolution_breached_at' => 'datetime',
|
|
||||||
'public_replies_count' => 'integer',
|
|
||||||
'time_spent_minutes' => 'integer',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @return BelongsTo<User, Ticket> */
|
|
||||||
public function owner(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'user_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<User, Ticket> */
|
|
||||||
public function agent(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class, 'agent_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Status, Ticket> */
|
|
||||||
public function status(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Status::class, 'status_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Priority, Ticket> */
|
|
||||||
public function priority(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Priority::class, 'priority_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Category, Ticket> */
|
|
||||||
public function category(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Category::class, 'category_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Project, Ticket> */
|
|
||||||
public function project(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Project::class, 'project_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Team, Ticket> */
|
|
||||||
public function team(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Team::class, 'team_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Comment> */
|
|
||||||
public function comments(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Comment::class, 'ticket_id')
|
|
||||||
->whereNull('parent_id')
|
|
||||||
->oldest();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** @return BelongsTo<SlaPolicy, Ticket> */
|
|
||||||
public function slaPolicy(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(SlaPolicy::class, 'sla_policy_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Product, Ticket> */
|
|
||||||
public function product(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Product::class, 'product_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<TicketSlaEvent> */
|
|
||||||
public function slaEvents(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TicketSlaEvent::class, 'ticket_id')->latest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<TicketRating> */
|
|
||||||
public function ratings(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TicketRating::class, 'ticket_id')->latest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<ChecklistItem> */
|
|
||||||
public function checklistItems(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(ChecklistItem::class, 'ticket_id')->orderBy('sort_order')->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsToMany<Tag> */
|
|
||||||
public function tags(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Tag::class, 'helpdesk_ticket_tag', 'ticket_id', 'tag_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSlaFirstResponseBreachedAttribute(): bool
|
|
||||||
{
|
|
||||||
return $this->first_response_at === null
|
|
||||||
&& $this->sla_first_response_due_at !== null
|
|
||||||
&& now()->greaterThan($this->sla_first_response_due_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSlaResolutionBreachedAttribute(): bool
|
|
||||||
{
|
|
||||||
return $this->completed_at === null
|
|
||||||
&& $this->sla_resolution_due_at !== null
|
|
||||||
&& now()->greaterThan($this->sla_resolution_due_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<GitCommit> */
|
|
||||||
public function gitCommits(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(GitCommit::class, 'ticket_id')->latest('committed_at');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Attachment> */
|
|
||||||
public function attachments(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Attachment::class, 'ticket_id')->latest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query @return Builder<Ticket> */
|
|
||||||
public function scopeOpen(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->whereNull('completed_at');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query @return Builder<Ticket> */
|
|
||||||
public function scopeVisibleTo(Builder $query, User $user): Builder
|
|
||||||
{
|
|
||||||
if ((bool) ($user->helpdesk_admin ?? false)) {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((bool) ($user->helpdesk_agent ?? false)) {
|
|
||||||
return $query->where(static function (Builder $subQuery) use ($user): void {
|
|
||||||
$subQuery->where('agent_id', $user->id)->orWhere('user_id', $user->id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where('user_id', $user->id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
final class TicketRating extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_ticket_ratings';
|
|
||||||
protected $fillable = ['ticket_id','agent_id','team_id','category_id','rating','token','comment','rated_at'];
|
|
||||||
protected $casts = ['ticket_id'=>'integer','agent_id'=>'integer','team_id'=>'integer','category_id'=>'integer','rating'=>'integer','rated_at'=>'datetime'];
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
final class TicketSlaEvent extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'helpdesk_ticket_sla_events';
|
|
||||||
protected $fillable = ['ticket_id','sla_policy_id','event','due_at','occurred_at','breached','meta'];
|
|
||||||
protected $casts = ['ticket_id'=>'integer','sla_policy_id'=>'integer','due_at'=>'datetime','occurred_at'=>'datetime','breached'=>'boolean','meta'=>'array'];
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
final class WorkflowLog extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_workflow_logs';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['template_id', 'ticket_id', 'event', 'recipient', 'subject', 'status', 'tracking_token', 'first_opened_at', 'last_opened_at', 'opened_count', 'error'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = [
|
|
||||||
'first_opened_at' => 'datetime',
|
|
||||||
'last_opened_at' => 'datetime',
|
|
||||||
'opened_count' => 'integer',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @return BelongsTo<WorkflowTemplate, WorkflowLog> */
|
|
||||||
public function template(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(WorkflowTemplate::class, 'template_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Ticket, WorkflowLog> */
|
|
||||||
public function ticket(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
final class WorkflowTemplate extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $table = 'helpdesk_workflow_templates';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
protected $fillable = ['name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active'];
|
|
||||||
|
|
||||||
/** @var array<string, string> */
|
|
||||||
protected $casts = ['is_active' => 'boolean'];
|
|
||||||
|
|
||||||
/** @return HasMany<WorkflowLog> */
|
|
||||||
public function logs(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(WorkflowLog::class, 'template_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Policies;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
|
|
||||||
final class TicketPolicy
|
|
||||||
{
|
|
||||||
public function viewAny(User $user): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function view(User $user, Ticket $ticket): bool
|
|
||||||
{
|
|
||||||
return $this->isPrivileged($user)
|
|
||||||
|| ((bool) ($user->helpdesk_agent ?? false) && ((int) $ticket->agent_id === (int) $user->id || (int) $ticket->user_id === (int) $user->id))
|
|
||||||
|| (int) $ticket->user_id === (int) $user->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(User $user): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(User $user, Ticket $ticket): bool
|
|
||||||
{
|
|
||||||
return $this->isPrivileged($user)
|
|
||||||
|| ((bool) ($user->helpdesk_agent ?? false) && (int) $ticket->agent_id === (int) $user->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function comment(User $user, Ticket $ticket): bool
|
|
||||||
{
|
|
||||||
return $this->view($user, $ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(User $user, Ticket $ticket): bool
|
|
||||||
{
|
|
||||||
return $this->isPrivileged($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isPrivileged(User $user): bool
|
|
||||||
{
|
|
||||||
if ((bool) ($user->helpdesk_admin ?? false)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method_exists($user, 'isMpbaPlatformAdmin') && $user->isMpbaPlatformAdmin()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
use Modules\Helpdesk\Livewire\HelpdeskDashboardWidget;
|
|
||||||
use Modules\Helpdesk\Livewire\KanbanBoard;
|
|
||||||
use Modules\Helpdesk\Livewire\SettingsManager;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketCreate;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketIndex;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketShow;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
use Modules\Helpdesk\Policies\TicketPolicy;
|
|
||||||
use Modules\Helpdesk\Support\ModuleInfo;
|
|
||||||
|
|
||||||
final class HelpdeskServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Module name.
|
|
||||||
*/
|
|
||||||
protected string $moduleName = 'Helpdesk';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lowercase module name.
|
|
||||||
*/
|
|
||||||
protected string $moduleNameLower = 'helpdesk';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register module services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
$this->app->register(RouteServiceProvider::class);
|
|
||||||
|
|
||||||
$this->mergeConfigFrom(
|
|
||||||
module_path(ModuleInfo::name(), 'Config/config.php'),
|
|
||||||
ModuleInfo::nameLower()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap module services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
$this->registerTranslations();
|
|
||||||
$this->registerConfig();
|
|
||||||
$this->registerViews();
|
|
||||||
$this->registerHelpers();
|
|
||||||
$this->registerCommands();
|
|
||||||
$this->registerLivewireComponents();
|
|
||||||
$this->registerPolicies();
|
|
||||||
|
|
||||||
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register Livewire components used by the module.
|
|
||||||
*/
|
|
||||||
protected function registerLivewireComponents(): void
|
|
||||||
{
|
|
||||||
Livewire::component('helpdesk.ticket-index', TicketIndex::class);
|
|
||||||
Livewire::component('helpdesk.ticket-create', TicketCreate::class);
|
|
||||||
Livewire::component('helpdesk.ticket-show', TicketShow::class);
|
|
||||||
Livewire::component('helpdesk.dashboard-widget', HelpdeskDashboardWidget::class);
|
|
||||||
Livewire::component('helpdesk.kanban-board', KanbanBoard::class);
|
|
||||||
Livewire::component('helpdesk.settings-manager', SettingsManager::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register policies and gates.
|
|
||||||
*/
|
|
||||||
protected function registerPolicies(): void
|
|
||||||
{
|
|
||||||
Gate::policy(Ticket::class, TicketPolicy::class);
|
|
||||||
|
|
||||||
Gate::define('helpdesk-admin', static fn ($user): bool => (bool) ($user->helpdesk_admin ?? false));
|
|
||||||
Gate::define('helpdesk-agent', static fn ($user): bool => (bool) (($user->helpdesk_agent ?? false) || ($user->helpdesk_admin ?? false)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register helpers.
|
|
||||||
*/
|
|
||||||
protected function registerHelpers(): void
|
|
||||||
{
|
|
||||||
foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) {
|
|
||||||
require_once $filename;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically register console commands.
|
|
||||||
*/
|
|
||||||
protected function registerCommands(): void
|
|
||||||
{
|
|
||||||
if (! $this->app->runningInConsole()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$commandPath = module_path(ModuleInfo::name(), 'Console');
|
|
||||||
|
|
||||||
if (! is_dir($commandPath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$commands = collect(File::allFiles($commandPath))
|
|
||||||
->map(fn ($file): string => 'Modules\\'.$this->moduleName.'\\Console\\'.$file->getFilenameWithoutExtension())
|
|
||||||
->filter(fn (string $class): bool => class_exists($class))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$this->commands($commands);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register config publishing.
|
|
||||||
*/
|
|
||||||
protected function registerConfig(): void
|
|
||||||
{
|
|
||||||
$this->publishes([
|
|
||||||
module_path(ModuleInfo::name(), 'Config/config.php') => config_path(ModuleInfo::nameLower().'.php'),
|
|
||||||
], 'config');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register module views.
|
|
||||||
*/
|
|
||||||
protected function registerViews(): void
|
|
||||||
{
|
|
||||||
$viewPath = resource_path('views/modules/'.ModuleInfo::nameLower());
|
|
||||||
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
|
|
||||||
|
|
||||||
$this->publishes([
|
|
||||||
$sourcePath => $viewPath,
|
|
||||||
], ['views', ModuleInfo::nameLower().'-module-views']);
|
|
||||||
|
|
||||||
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
|
|
||||||
|
|
||||||
Blade::componentNamespace('Modules\Helpdesk\View\Components', 'helpdesk');
|
|
||||||
Blade::anonymousComponentPath($sourcePath.'/components', 'helpdesk');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register translations.
|
|
||||||
*/
|
|
||||||
protected function registerTranslations(): void
|
|
||||||
{
|
|
||||||
$langPath = resource_path('lang/modules/'.ModuleInfo::nameLower());
|
|
||||||
|
|
||||||
if (is_dir($langPath)) {
|
|
||||||
$this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get publishable view override paths.
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function getPublishableViewPaths(): array
|
|
||||||
{
|
|
||||||
$paths = [];
|
|
||||||
|
|
||||||
foreach (Config::get('view.paths') as $path) {
|
|
||||||
if (is_dir($path.'/modules/'.ModuleInfo::nameLower())) {
|
|
||||||
$paths[] = $path.'/modules/'.ModuleInfo::nameLower();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get provided services.
|
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function provides(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Providers;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
|
|
||||||
use Modules\Helpdesk\Support\ModuleInfo;
|
|
||||||
|
|
||||||
final class RouteServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Define module routes.
|
|
||||||
*/
|
|
||||||
public function map(): void
|
|
||||||
{
|
|
||||||
$this->mapApiRoutes();
|
|
||||||
$this->mapWebRoutes();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define web routes.
|
|
||||||
*/
|
|
||||||
protected function mapWebRoutes(): void
|
|
||||||
{
|
|
||||||
Route::middleware(['web'])
|
|
||||||
->prefix(config('helpdesk.route_prefix', 'helpdesk'))
|
|
||||||
->name('helpdesk.')
|
|
||||||
->get('/workflow/open/{token}', WorkflowOpenController::class)
|
|
||||||
->name('workflow.open');
|
|
||||||
|
|
||||||
Route::middleware(config('helpdesk.middleware', ['web', 'auth']))
|
|
||||||
->prefix(config('helpdesk.route_prefix', 'helpdesk'))
|
|
||||||
->name('helpdesk.')
|
|
||||||
->group(module_path(ModuleInfo::name(), 'Routes/web.php'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define API routes.
|
|
||||||
*/
|
|
||||||
protected function mapApiRoutes(): void
|
|
||||||
{
|
|
||||||
Route::prefix('api/helpdesk')
|
|
||||||
->middleware('api')
|
|
||||||
->name('api.helpdesk.')
|
|
||||||
->group(module_path(ModuleInfo::name(), 'Routes/api.php'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.helpdesk-module{}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import './bootstrap';
|
|
||||||
import Sortable from 'sortablejs';
|
|
||||||
|
|
||||||
window.Sortable = Sortable;
|
|
||||||
console.info('Helpdesk module assets loaded');
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
window.axios = window.axios || axios;
|
|
||||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.helpdesk-module {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
@props([
|
|
||||||
'label' => 'Details',
|
|
||||||
'rows' => 10,
|
|
||||||
'placeholder' => 'Write your message...',
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$wireModel = $attributes->wire('model');
|
|
||||||
$model = $wireModel->value();
|
|
||||||
$initialValue = $model ? data_get($this, $model, '') : '';
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div
|
|
||||||
{{ $attributes->whereDoesntStartWith('wire:model')->merge(['class' => 'space-y-2']) }}
|
|
||||||
x-on:helpdesk-editor-reset.window="if ($event.detail.model === model) clear()"
|
|
||||||
x-data="{
|
|
||||||
model: @js($model),
|
|
||||||
value: @js($initialValue),
|
|
||||||
sync() {
|
|
||||||
this.value = this.$refs.editor.innerHTML;
|
|
||||||
if (this.model) {
|
|
||||||
this.$wire.set(this.model, this.value, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
command(action, value = null) {
|
|
||||||
this.$refs.editor.focus();
|
|
||||||
document.execCommand(action, false, value);
|
|
||||||
this.sync();
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
this.value = '';
|
|
||||||
this.$refs.editor.innerHTML = '';
|
|
||||||
if (this.model) {
|
|
||||||
this.$wire.set(this.model, '', false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.$refs.editor.innerHTML = this.value || '';
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $label }}</label>
|
|
||||||
|
|
||||||
@if($model)
|
|
||||||
<input type="hidden" name="{{ $model }}" x-bind:value="value">
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-xl border border-zinc-300 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-950" wire:ignore>
|
|
||||||
<div class="flex flex-wrap gap-1 border-b border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-900">
|
|
||||||
<button type="button" x-on:click="command('bold')" class="rounded-lg px-2 py-1 text-sm font-bold hover:bg-zinc-200 dark:hover:bg-zinc-800">B</button>
|
|
||||||
<button type="button" x-on:click="command('italic')" class="rounded-lg px-2 py-1 text-sm italic hover:bg-zinc-200 dark:hover:bg-zinc-800">I</button>
|
|
||||||
<button type="button" x-on:click="command('underline')" class="rounded-lg px-2 py-1 text-sm underline hover:bg-zinc-200 dark:hover:bg-zinc-800">U</button>
|
|
||||||
<button type="button" x-on:click="command('insertUnorderedList')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">• List</button>
|
|
||||||
<button type="button" x-on:click="command('insertOrderedList')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">1. List</button>
|
|
||||||
<button type="button" x-on:click="command('formatBlock', 'blockquote')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Quote</button>
|
|
||||||
<button type="button" x-on:click="const url = prompt('URL'); if (url) command('createLink', url)" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Link</button>
|
|
||||||
<button type="button" x-on:click="command('removeFormat')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Clear</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
x-ref="editor"
|
|
||||||
contenteditable="true"
|
|
||||||
x-on:input.debounce.300ms="sync"
|
|
||||||
x-on:blur="sync"
|
|
||||||
class="prose prose-sm min-h-[{{ (int) $rows * 28 }}px] max-w-none bg-white p-4 text-sm text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100"
|
|
||||||
data-placeholder="{{ $placeholder }}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($model)
|
|
||||||
@error($model)
|
|
||||||
<div class="text-sm text-red-600">{{ $message }}</div>
|
|
||||||
@enderror
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
|
||||||
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
|
||||||
<tr><th class="px-4 py-3">Name</th><th class="px-4 py-3">Slug</th><th class="px-4 py-3">Colour</th><th class="px-4 py-3">Tickets</th><th class="px-4 py-3 text-right">Actions</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
|
||||||
@forelse ($items as $item)
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
|
|
||||||
<td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td>
|
|
||||||
<td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td>
|
|
||||||
<td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count ?? 0 }}</td>
|
|
||||||
<td class="px-4 py-3 text-right"><flux:button size="sm" wire:click="{{ $edit }}({{ $item->id }})" variant="primary">Edit</flux:button><flux:button size="sm" wire:click="{{ $delete }}({{ $item->id }})" variant="danger">Delete</flux:button></td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr><td colspan="5" class="px-4 py-10 text-center text-zinc-500">No records yet.</td></tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"><tr><th class="px-4 py-3">Project</th><th class="px-4 py-3">Team</th><th class="px-4 py-3">Slug</th><th class="px-4 py-3">Colour</th><th class="px-4 py-3">Tickets</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">@forelse ($items as $item)<tr><td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->title }}</td><td class="px-4 py-3 text-zinc-500">{{ $item->team?->name ?? 'No team' }}</td><td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td><td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td><td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count }}</td><td class="px-4 py-3 text-right"><flux:button size="sm" wire:click="editProject({{ $item->id }})" variant="primary">Edit</flux:button><flux:button size="sm" wire:click="deleteProject({{ $item->id }})" variant="danger">Delete</flux:button></td></tr>@empty<tr><td colspan="6" class="px-4 py-10 text-center text-zinc-500">No projects yet.</td></tr>@endforelse</tbody></table></div>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
@props(['items'])
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
|
||||||
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3">Repository</th>
|
|
||||||
<th class="px-4 py-3">Project</th>
|
|
||||||
<th class="px-4 py-3">Provider</th>
|
|
||||||
<th class="px-4 py-3">Branch</th>
|
|
||||||
<th class="px-4 py-3">Commits</th>
|
|
||||||
<th class="px-4 py-3">Webhook</th>
|
|
||||||
<th class="px-4 py-3 text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
|
||||||
@forelse ($items as $item)
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
|
|
||||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->project?->title ?? 'Global' }}</td>
|
|
||||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->provider }}</td>
|
|
||||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->default_branch }}</td>
|
|
||||||
<td class="px-4 py-3"><flux:badge color="zinc">{{ $item->commits_count }}</flux:badge></td>
|
|
||||||
<td class="px-4 py-3 text-xs text-zinc-600 dark:text-zinc-300">
|
|
||||||
<div class="font-medium text-zinc-800 dark:text-zinc-100">{{ $item->webhook_logs_count ?? 0 }} deliveries</div>
|
|
||||||
<code class="mt-1 block max-w-xs truncate rounded bg-zinc-100 px-2 py-1 text-[11px] text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">{{ route('api.helpdesk.git.webhook', ['repository' => $item->id]) }}</code>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-right">
|
|
||||||
<flux:button size="sm" variant="primary" wire:click="editRepository({{ $item->id }})">Edit</flux:button>
|
|
||||||
<flux:button size="sm" variant="danger" wire:click="deleteRepository({{ $item->id }})" wire:confirm="Delete this repository?">Delete</flux:button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr><td colspan="7" class="px-4 py-8 text-center text-zinc-500">No repositories yet.</td></tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<section class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="border-b border-zinc-200 bg-zinc-50 px-5 py-4 dark:border-zinc-700 dark:bg-zinc-800/70">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">{{ $title }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ $description }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="border-b border-zinc-200 p-5 dark:border-zinc-700">{{ $form }}</div>
|
|
||||||
<div>{{ $slot }}</div>
|
|
||||||
</section>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"><tr><th class="px-4 py-3">Team</th><th class="px-4 py-3">Slug</th><th class="px-4 py-3">Colour</th><th class="px-4 py-3">Projects</th><th class="px-4 py-3">Tickets</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">@forelse ($items as $item)<tr><td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td><td class="px-4 py-3 font-mono text-xs text-zinc-500">{{ $item->slug }}</td><td class="px-4 py-3"><flux:badge color="{{ $item->color }}">{{ $item->color }}</flux:badge></td><td class="px-4 py-3 text-zinc-500">{{ $item->projects_count }}</td><td class="px-4 py-3 text-zinc-500">{{ $item->tickets_count }}</td><td class="px-4 py-3 text-right"><flux:button size="sm" wire:click="editTeam({{ $item->id }})" variant="primary">Edit</flux:button><flux:button size="sm" wire:click="deleteTeam({{ $item->id }})" variant="danger">Delete</flux:button></td></tr>@empty<tr><td colspan="6" class="px-4 py-10 text-center text-zinc-500">No teams yet.</td></tr>@endforelse</tbody></table></div>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<livewire:helpdesk.dashboard-widget />
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<div class="space-y-6">
|
|
||||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-8 text-white">
|
|
||||||
<div class="grid gap-6 lg:grid-cols-[1fr_320px] lg:items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Support operations</p>
|
|
||||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight md:text-4xl">Helpdesk dashboard</h1>
|
|
||||||
<p class="mt-3 max-w-3xl text-sm leading-6 text-blue-100/90">Track open workload, closed tickets, response performance, project activity, priorities, Git commits and workflow communication from one professional support workspace.</p>
|
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
|
||||||
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus">New ticket</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost" icon="ticket">Active tickets</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.kanban')" wire:navigate variant="ghost" icon="squares-2x2">Kanban</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost" icon="cog-6-tooth">Settings</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/10 p-5 shadow-2xl backdrop-blur">
|
|
||||||
<div class="grid grid-cols-3 gap-2">
|
|
||||||
@foreach (range(1, 9) as $cell)
|
|
||||||
<div class="h-14 rounded-xl bg-white/15"></div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 rounded-xl bg-white/10 p-4">
|
|
||||||
<div class="h-2 rounded-full bg-blue-300/80"></div>
|
|
||||||
<div class="mt-3 h-2 w-3/4 rounded-full bg-emerald-300/80"></div>
|
|
||||||
<div class="mt-3 h-2 w-1/2 rounded-full bg-rose-300/80"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<flux:button :href="route('helpdesk.index')" wire:navigate variant="primary">Dashboard</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost">Tickets</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="ghost">Create</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.kanban')" wire:navigate variant="ghost">Kanban</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost">Settings</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<livewire:helpdesk.dashboard-widget />
|
|
||||||
</div>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<x-layouts.app :title="$title ?? __('Helpdesk')">
|
|
||||||
{{ $slot ?? '' }}
|
|
||||||
@yield('content')
|
|
||||||
</x-layouts.app>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<div class="space-y-6" dusk="helpdesk-dashboard-widget">
|
|
||||||
<div class="grid gap-4 md:grid-cols-4">
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="text-sm font-medium text-zinc-500">Total tickets</div>
|
|
||||||
<div class="mt-3 text-4xl font-semibold text-zinc-950 dark:text-white">{{ $totalCount }}</div>
|
|
||||||
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-2 rounded-full bg-blue-500" style="width: 100%"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="text-sm font-medium text-zinc-500">Open tickets</div>
|
|
||||||
<div class="mt-3 text-4xl font-semibold text-blue-700 dark:text-blue-300">{{ $openCount }}</div>
|
|
||||||
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-2 rounded-full bg-blue-500" style="width: {{ $openPercent }}%"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="text-sm font-medium text-zinc-500">Closed tickets</div>
|
|
||||||
<div class="mt-3 text-4xl font-semibold text-emerald-700 dark:text-emerald-300">{{ $closedCount }}</div>
|
|
||||||
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-2 rounded-full bg-emerald-500" style="width: {{ $closedPercent }}%"></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="text-sm font-medium text-zinc-500">High priority</div>
|
|
||||||
<div class="mt-3 text-4xl font-semibold text-red-700 dark:text-red-300">{{ $criticalCount }}</div>
|
|
||||||
<div class="mt-3 text-xs text-zinc-500">Open urgent/high/critical workload.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-3">
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 lg:col-span-2">
|
|
||||||
<div class="mb-5 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Performance</h2>
|
|
||||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Closure rate and remaining open workload.</p>
|
|
||||||
</div>
|
|
||||||
<flux:badge color="emerald">{{ $closedPercent }}% closed</flux:badge>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div>
|
|
||||||
<div class="mb-2 flex justify-between text-sm"><span>Closed</span><span>{{ $closedPercent }}%</span></div>
|
|
||||||
<div class="h-4 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-4 rounded-full bg-emerald-500" style="width: {{ $closedPercent }}%"></div></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mb-2 flex justify-between text-sm"><span>Open</span><span>{{ $openPercent }}%</span></div>
|
|
||||||
<div class="h-4 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-4 rounded-full bg-blue-500" style="width: {{ $openPercent }}%"></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="mb-5 flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent commits</h2>
|
|
||||||
<flux:button :href="route('helpdesk.settings')" wire:navigate size="sm" variant="ghost">Git setup</flux:button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@forelse ($recentCommits as $commit)
|
|
||||||
<div class="rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
|
|
||||||
<div class="mt-1 truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $commit->message }}</div>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">No commits tracked yet.</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="mb-5 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent tickets</h2>
|
|
||||||
<p class="text-sm text-zinc-500 dark:text-zinc-400">Latest support activity.</p>
|
|
||||||
</div>
|
|
||||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate size="sm" variant="ghost">View all</flux:button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@forelse ($recentTickets as $ticket)
|
|
||||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="block rounded-xl border border-zinc-100 p-3 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<span class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</span>
|
|
||||||
<flux:badge color="{{ $ticket->priority->color }}" size="sm">{{ $ticket->priority->name }}</flux:badge>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-xs text-zinc-500">{{ $ticket->reference }} · {{ $ticket->status->name }}</div>
|
|
||||||
</a>
|
|
||||||
@empty
|
|
||||||
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">No helpdesk tickets yet.</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<div class="mx-auto max-w-[1800px] space-y-6" dusk="helpdesk-kanban-board">
|
|
||||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk workflow</p>
|
|
||||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Kanban board</h1>
|
|
||||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Drag tickets between board columns. Boards and columns are configured in Helpdesk Settings.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus">New ticket</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost" icon="cog-6-tooth">Configure boards</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost" icon="list-bullet">List view</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($boards->isEmpty())
|
|
||||||
<div class="rounded-2xl border border-amber-300 bg-amber-50 px-5 py-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
|
|
||||||
No Kanban boards exist yet. Open Helpdesk Settings and create a board with at least one status column.
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="flex flex-col gap-3 rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Active board</h2>
|
|
||||||
<p class="text-xs text-zinc-500 dark:text-zinc-400">{{ $board?->description ?: 'Use settings to configure which statuses appear as draggable columns.' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select wire:model.live="board_id" class="min-w-[260px] rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
|
|
||||||
@foreach ($boards as $availableBoard)
|
|
||||||
<option value="{{ $availableBoard->id }}">{{ $availableBoard->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4" data-helpdesk-kanban-board>
|
|
||||||
@foreach ($statuses as $status)
|
|
||||||
@php($tickets = $ticketsByStatus[$status->id] ?? collect())
|
|
||||||
|
|
||||||
<section class="flex min-h-[640px] w-[330px] shrink-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-100 shadow-sm dark:border-zinc-700 dark:bg-zinc-950" dusk="kanban-column-{{ $status->slug }}">
|
|
||||||
<header class="border-b border-zinc-200 bg-white px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-bold uppercase tracking-wide text-zinc-900 dark:text-zinc-100">{{ $status->name }}</h2>
|
|
||||||
<p class="mt-1 text-xs text-zinc-500">{{ $tickets->count() }} tickets</p>
|
|
||||||
</div>
|
|
||||||
<span class="inline-flex h-4 w-4 rounded-full ring-4 ring-zinc-100 dark:ring-zinc-800" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($status->color) }}"></span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="helpdesk-kanban-column min-h-[560px] flex-1 space-y-3 p-3 transition data-[drag-over=true]:bg-blue-50 dark:data-[drag-over=true]:bg-blue-950/30"
|
|
||||||
data-helpdesk-kanban-column
|
|
||||||
data-status-id="{{ $status->id }}"
|
|
||||||
>
|
|
||||||
@forelse ($tickets as $ticket)
|
|
||||||
<article
|
|
||||||
class="group cursor-grab rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md active:cursor-grabbing dark:border-zinc-800 dark:bg-zinc-900"
|
|
||||||
draggable="true"
|
|
||||||
data-helpdesk-kanban-ticket
|
|
||||||
data-ticket-id="{{ $ticket->id }}"
|
|
||||||
dusk="kanban-ticket-{{ $ticket->id }}"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<div class="font-mono text-xs font-semibold text-blue-700 dark:text-blue-300">{{ $ticket->reference }}</div>
|
|
||||||
<h3 class="mt-1 line-clamp-3 text-sm font-semibold leading-5 text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($ticket->priority)
|
|
||||||
<span class="shrink-0 rounded-full px-2 py-1 text-xs font-bold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($ticket->priority->color) }}">
|
|
||||||
{{ $ticket->priority->name }}
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 space-y-1.5 text-xs text-zinc-500 dark:text-zinc-400">
|
|
||||||
<div class="flex items-center gap-2"><i class="fa-solid fa-user w-3" aria-hidden="true"></i> {{ $ticket->owner?->name ?? 'Unknown' }}</div>
|
|
||||||
<div class="flex items-center gap-2"><i class="fa-solid fa-user-gear w-3" aria-hidden="true"></i> {{ $ticket->agent?->name ?? 'Unassigned' }}</div>
|
|
||||||
@if ($ticket->project)
|
|
||||||
<div class="flex items-center gap-2"><i class="fa-solid fa-diagram-project w-3" aria-hidden="true"></i> {{ $ticket->project->title }}</div>
|
|
||||||
@endif
|
|
||||||
@if ($ticket->team)
|
|
||||||
<div class="flex items-center gap-2"><i class="fa-solid fa-users w-3" aria-hidden="true"></i> {{ $ticket->team->name }}</div>
|
|
||||||
@endif
|
|
||||||
@if ($ticket->product)
|
|
||||||
<div class="flex items-center gap-2"><i class="fa-solid fa-box w-3" aria-hidden="true"></i> {{ $ticket->product->name }}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($ticket->tags->isNotEmpty())
|
|
||||||
<div class="mt-3 flex flex-wrap gap-1">
|
|
||||||
@foreach ($ticket->tags as $tag)
|
|
||||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($ticket->checklistItems->isNotEmpty())
|
|
||||||
@php($done = $ticket->checklistItems->where('is_completed', true)->count())
|
|
||||||
<div class="mt-3 text-xs text-zinc-500">Checklist {{ $done }}/{{ $ticket->checklistItems->count() }}</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($ticket->sla_first_response_breached || $ticket->sla_resolution_breached)
|
|
||||||
<div class="mt-3 rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-xs font-semibold text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-100">SLA breach</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between gap-2 border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
|
||||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a>
|
|
||||||
@can('update', $ticket)
|
|
||||||
<a href="{{ route('helpdesk.tickets.edit', $ticket) }}" wire:navigate class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Edit</a>
|
|
||||||
@endcan
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
@empty
|
|
||||||
<div class="rounded-2xl border border-dashed border-zinc-300 bg-white p-6 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
Drop tickets here for {{ $status->name }}.
|
|
||||||
</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@script
|
|
||||||
<script>
|
|
||||||
const initialiseHelpdeskKanban = function () {
|
|
||||||
let draggedTicketId = null;
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-helpdesk-kanban-ticket]').forEach(function (card) {
|
|
||||||
if (card.dataset.nativeDragReady === '1') return;
|
|
||||||
card.dataset.nativeDragReady = '1';
|
|
||||||
|
|
||||||
card.addEventListener('dragstart', function (event) {
|
|
||||||
draggedTicketId = card.dataset.ticketId;
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
event.dataTransfer.setData('text/plain', draggedTicketId);
|
|
||||||
card.classList.add('opacity-60');
|
|
||||||
});
|
|
||||||
|
|
||||||
card.addEventListener('dragend', function () {
|
|
||||||
card.classList.remove('opacity-60');
|
|
||||||
document.querySelectorAll('[data-helpdesk-kanban-column]').forEach(function (column) {
|
|
||||||
column.dataset.dragOver = 'false';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-helpdesk-kanban-column]').forEach(function (column) {
|
|
||||||
if (column.dataset.nativeDropReady === '1') return;
|
|
||||||
column.dataset.nativeDropReady = '1';
|
|
||||||
|
|
||||||
column.addEventListener('dragover', function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
column.dataset.dragOver = 'true';
|
|
||||||
});
|
|
||||||
|
|
||||||
column.addEventListener('dragleave', function (event) {
|
|
||||||
if (!column.contains(event.relatedTarget)) {
|
|
||||||
column.dataset.dragOver = 'false';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
column.addEventListener('drop', function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
column.dataset.dragOver = 'false';
|
|
||||||
|
|
||||||
const ticketId = event.dataTransfer.getData('text/plain') || draggedTicketId;
|
|
||||||
const statusId = column.dataset.statusId;
|
|
||||||
|
|
||||||
if (!ticketId || !statusId) return;
|
|
||||||
|
|
||||||
$wire.moveTicket(ticketId, statusId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
initialiseHelpdeskKanban();
|
|
||||||
document.addEventListener('livewire:navigated', initialiseHelpdeskKanban);
|
|
||||||
document.addEventListener('livewire:updated', initialiseHelpdeskKanban);
|
|
||||||
</script>
|
|
||||||
@endscript
|
|
||||||
</div>
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
@php
|
|
||||||
/**
|
|
||||||
* Keep the settings view safe during rolling module upgrades or Livewire navigate
|
|
||||||
* hydration where the compiled Blade view may be newer than the loaded component.
|
|
||||||
*/
|
|
||||||
$companies = collect($companies ?? []);
|
|
||||||
$statuses = collect($statuses ?? []);
|
|
||||||
$priorities = collect($priorities ?? []);
|
|
||||||
$categories = collect($categories ?? []);
|
|
||||||
$teams = collect($teams ?? []);
|
|
||||||
$projects = collect($projects ?? []);
|
|
||||||
$boards = collect($boards ?? []);
|
|
||||||
$repositories = collect($repositories ?? []);
|
|
||||||
$workflowTemplates = collect($workflowTemplates ?? []);
|
|
||||||
$cannedResponses = collect($cannedResponses ?? []);
|
|
||||||
$slaPolicies = collect($slaPolicies ?? []);
|
|
||||||
$automationRules = collect($automationRules ?? []);
|
|
||||||
$checklistTemplates = collect($checklistTemplates ?? []);
|
|
||||||
$tags = collect($tags ?? []);
|
|
||||||
$products = collect($products ?? []);
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="space-y-6" dusk="helpdesk-settings-manager">
|
|
||||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk administration</p>
|
|
||||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Support configuration</h1>
|
|
||||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Manage lookup data, workflow messages, canned responses, SLA policies, automation rules, checklists, tags and product/service linkage.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<flux:button :href="route('helpdesk.index')" wire:navigate variant="ghost" icon="arrow-left">Helpdesk</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="primary" icon="ticket">Tickets</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-3 md:grid-cols-4 xl:grid-cols-7">
|
|
||||||
@foreach ([
|
|
||||||
'statuses' => ['label' => 'Statuses', 'count' => $statuses->count()],
|
|
||||||
'priorities' => ['label' => 'Priorities', 'count' => $priorities->count()],
|
|
||||||
'categories' => ['label' => 'Categories', 'count' => $categories->count()],
|
|
||||||
'teams' => ['label' => 'Teams', 'count' => $teams->count()],
|
|
||||||
'projects' => ['label' => 'Projects', 'count' => $projects->count()],
|
|
||||||
'boards' => ['label' => 'Boards', 'count' => $boards->count()],
|
|
||||||
'git' => ['label' => 'Git', 'count' => $repositories->count()],
|
|
||||||
'workflow' => ['label' => 'Workflow', 'count' => $workflowTemplates->count()],
|
|
||||||
'canned' => ['label' => 'Canned', 'count' => $cannedResponses->count()],
|
|
||||||
'sla' => ['label' => 'SLA', 'count' => $slaPolicies->count()],
|
|
||||||
'automation' => ['label' => 'Rules', 'count' => $automationRules->count()],
|
|
||||||
'checklists' => ['label' => 'Checks', 'count' => $checklistTemplates->count()],
|
|
||||||
'tags' => ['label' => 'Tags', 'count' => $tags->count()],
|
|
||||||
'products' => ['label' => 'Products', 'count' => $products->count()],
|
|
||||||
] as $key => $item)
|
|
||||||
<button type="button" wire:click="$set('section', '{{ $key }}')" class="rounded-2xl border px-4 py-3 text-left shadow-sm transition {{ $section === $key ? 'border-blue-500 bg-blue-50 text-blue-900 dark:border-blue-400 dark:bg-blue-950 dark:text-blue-100' : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800' }}">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
|
|
||||||
<div class="mt-1 text-2xl font-semibold">{{ $item['count'] }}</div>
|
|
||||||
</button>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($section === 'statuses')
|
|
||||||
<x-helpdesk::settings-section title="Statuses" description="Control workflow stages and mark which statuses close tickets.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveStatus" class="grid gap-4 md:grid-cols-5">
|
|
||||||
<flux:input wire:model="statusForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="statusForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="statusForm.color" label="Badge colour" />
|
|
||||||
<flux:input wire:model="statusForm.sort_order" label="Sort" type="number" min="0" />
|
|
||||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="statusForm.is_closed" class="rounded border-zinc-300"> Closed status</label>
|
|
||||||
<div class="md:col-span-5 flex gap-2"><flux:button type="submit" variant="primary">Save status</flux:button><flux:button type="button" wire:click="resetStatusForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<x-helpdesk::lookup-table :items="$statuses" edit="editStatus" delete="deleteStatus" />
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'priorities')
|
|
||||||
<x-helpdesk::settings-section title="Priorities" description="Define ticket urgency levels and display order.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="savePriority" class="grid gap-4 md:grid-cols-4">
|
|
||||||
<flux:input wire:model="priorityForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="priorityForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="priorityForm.color" label="Badge colour" />
|
|
||||||
<flux:input wire:model="priorityForm.sort_order" label="Sort" type="number" min="0" />
|
|
||||||
<div class="md:col-span-4 flex gap-2"><flux:button type="submit" variant="primary">Save priority</flux:button><flux:button type="button" wire:click="resetPriorityForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<x-helpdesk::lookup-table :items="$priorities" edit="editPriority" delete="deletePriority" />
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'categories')
|
|
||||||
<x-helpdesk::settings-section title="Categories" description="Group tickets by support area, optionally per company.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveCategory" class="grid gap-4 md:grid-cols-4">
|
|
||||||
<flux:select wire:model="categoryForm.company_id" label="Company"><option value="">Global</option>@foreach ($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:input wire:model="categoryForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="categoryForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="categoryForm.color" label="Badge colour" />
|
|
||||||
<div class="md:col-span-4"><flux:textarea wire:model="categoryForm.description" label="Description" rows="3" /></div>
|
|
||||||
<div class="md:col-span-4 flex gap-2"><flux:button type="submit" variant="primary">Save category</flux:button><flux:button type="button" wire:click="resetCategoryForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<x-helpdesk::lookup-table :items="$categories" edit="editCategory" delete="deleteCategory" />
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'teams')
|
|
||||||
<x-helpdesk::settings-section title="Teams" description="Create support teams under each company and assign projects to them.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveTeam" class="grid gap-4 md:grid-cols-4">
|
|
||||||
<flux:select wire:model="teamForm.company_id" label="Company"><option value="">Global</option>@foreach ($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:input wire:model="teamForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="teamForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="teamForm.color" label="Badge colour" />
|
|
||||||
<div class="md:col-span-4"><flux:textarea wire:model="teamForm.description" label="Description" rows="3" /></div>
|
|
||||||
<div class="md:col-span-4 flex gap-2"><flux:button type="submit" variant="primary">Save team</flux:button><flux:button type="button" wire:click="resetTeamForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<x-helpdesk::team-table :items="$teams" />
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'projects')
|
|
||||||
<x-helpdesk::settings-section title="Projects" description="Create company projects and optionally assign them to support teams.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveProject" class="grid gap-4 md:grid-cols-5">
|
|
||||||
<flux:select wire:model="projectForm.company_id" label="Company"><option value="">Global</option>@foreach ($companies as $company)<option value="{{ $company->id }}">{{ $company->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:select wire:model="projectForm.team_id" label="Team"><option value="">No team</option>@foreach ($teams as $team)<option value="{{ $team->id }}">{{ $team->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:input wire:model="projectForm.title" label="Title" />
|
|
||||||
<flux:input wire:model="projectForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="projectForm.color" label="Badge colour" />
|
|
||||||
<div class="md:col-span-5"><flux:textarea wire:model="projectForm.description" label="Description" rows="3" /></div>
|
|
||||||
<div class="md:col-span-5 flex gap-2"><flux:button type="submit" variant="primary">Save project</flux:button><flux:button type="button" wire:click="resetProjectForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<x-helpdesk::project-table :items="$projects" />
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
|
|
||||||
@if ($section === 'boards')
|
|
||||||
<x-helpdesk::settings-section title="Kanban Boards" description="Create configurable workflow boards and choose which statuses appear as draggable columns.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveBoard" class="grid gap-4 md:grid-cols-6">
|
|
||||||
<flux:input wire:model="boardForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="boardForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="boardForm.sort_order" label="Sort" type="number" min="0" />
|
|
||||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200">
|
|
||||||
<input type="checkbox" wire:model="boardForm.is_default" class="rounded border-zinc-300"> Default board
|
|
||||||
</label>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<flux:textarea wire:model="boardForm.description" label="Description" rows="2" />
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-6">
|
|
||||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Board columns</label>
|
|
||||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
|
||||||
@foreach ($statuses as $status)
|
|
||||||
<label class="flex items-center gap-2 rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
|
|
||||||
<input type="checkbox" wire:model="boardForm.status_ids" value="{{ $status->id }}" class="rounded border-zinc-300">
|
|
||||||
<span class="inline-flex h-3 w-3 rounded-full" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($status->color) }}"></span>
|
|
||||||
<span>{{ $status->name }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-6 flex gap-2">
|
|
||||||
<flux:button type="submit" variant="primary">Save board</flux:button>
|
|
||||||
<flux:button type="button" wire:click="resetBoardForm" variant="ghost">Reset</flux:button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
|
||||||
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3">Board</th>
|
|
||||||
<th class="px-4 py-3">Columns</th>
|
|
||||||
<th class="px-4 py-3">Default</th>
|
|
||||||
<th class="px-4 py-3 text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
|
||||||
@forelse ($boards as $boardItem)
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $boardItem->name }}</div>
|
|
||||||
<div class="text-xs text-zinc-500">{{ $boardItem->slug }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
@foreach ($boardItem->statuses as $status)
|
|
||||||
<span class="rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($status->color) }}">{{ $status->name }}</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3"><flux:badge color="{{ $boardItem->is_default ? 'emerald' : 'zinc' }}">{{ $boardItem->is_default ? 'Default' : 'No' }}</flux:badge></td>
|
|
||||||
<td class="px-4 py-3 text-right">
|
|
||||||
<flux:button size="sm" variant="primary" wire:click="editBoard({{ $boardItem->id }})">Edit</flux:button>
|
|
||||||
<flux:button size="sm" variant="danger" wire:click="deleteBoard({{ $boardItem->id }})" wire:confirm="Delete this board?">Delete</flux:button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr><td colspan="4" class="px-4 py-8 text-center text-zinc-500">No Kanban boards yet.</td></tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'git')
|
|
||||||
<x-helpdesk::settings-section title="Git repositories" description="Configure repositories that can be linked to projects and tickets. Commits can then be stored against tickets for traceability.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveRepository" class="grid gap-4 md:grid-cols-6">
|
|
||||||
<flux:select wire:model="repositoryForm.company_id" label="Company">
|
|
||||||
<option value="">Global</option>
|
|
||||||
@foreach ($companies as $company)
|
|
||||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
<flux:select wire:model="repositoryForm.project_id" label="Project">
|
|
||||||
<option value="">No project</option>
|
|
||||||
@foreach ($projects as $project)
|
|
||||||
<option value="{{ $project->id }}">{{ $project->title }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
<flux:input wire:model="repositoryForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="repositoryForm.provider" label="Provider" placeholder="git, github, gitlab" />
|
|
||||||
<flux:input wire:model="repositoryForm.default_branch" label="Default branch" />
|
|
||||||
<flux:input wire:model="repositoryForm.webhook_secret" label="Webhook secret" />
|
|
||||||
<div class="md:col-span-6">
|
|
||||||
<flux:input wire:model="repositoryForm.remote_url" label="Remote URL" placeholder="git@github.com:company/repo.git" />
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-6 flex gap-2">
|
|
||||||
<flux:button type="submit" variant="primary">Save repository</flux:button>
|
|
||||||
<flux:button type="button" wire:click="resetRepositoryForm" variant="ghost">Reset</flux:button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<x-helpdesk::repository-table :items="$repositories" />
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
|
|
||||||
@if ($section === 'workflow')
|
|
||||||
<x-helpdesk::settings-section title="Workflow messages" description="Define the professional email messages sent when tickets are raised, updated, replied to and closed.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveWorkflowTemplate" class="grid gap-4 md:grid-cols-6">
|
|
||||||
<flux:input wire:model="workflowForm.name" label="Name" />
|
|
||||||
<flux:select wire:model="workflowForm.event" label="Event">
|
|
||||||
<option value="ticket.created">Ticket raised</option>
|
|
||||||
<option value="ticket.updated">Ticket updated</option>
|
|
||||||
<option value="ticket.commented">Response added</option>
|
|
||||||
<option value="ticket.reply_created">Reply added</option>
|
|
||||||
<option value="ticket.closed">Ticket closed</option>
|
|
||||||
</flux:select>
|
|
||||||
<flux:select wire:model="workflowForm.to_role" label="Send to">
|
|
||||||
<option value="requester">Requester</option>
|
|
||||||
<option value="agent">Assigned agent</option>
|
|
||||||
<option value="actor">User making change</option>
|
|
||||||
</flux:select>
|
|
||||||
<flux:input wire:model="workflowForm.cc" label="CC" placeholder="Optional comma-separated emails" />
|
|
||||||
<flux:input wire:model="workflowForm.bcc" label="BCC" placeholder="Optional comma-separated emails" />
|
|
||||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="workflowForm.is_active" class="rounded border-zinc-300"> Active</label>
|
|
||||||
<div class="md:col-span-6">
|
|
||||||
<flux:input wire:model="workflowForm.subject" label="Subject" placeholder="Ticket @{{ ticket.reference }} has been raised" />
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-6">
|
|
||||||
<flux:textarea wire:model="workflowForm.html_body" label="HTML email body" rows="8" placeholder="Use tokens like @{{ ticket.reference }}, @{{ ticket.subject }}, @{{ ticket.status }}, @{{ requester.name }}, @{{ ticket.url }}" />
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-6">
|
|
||||||
<flux:textarea wire:model="workflowForm.text_body" label="Plain text body" rows="4" placeholder="Optional fallback text" />
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-6 flex gap-2">
|
|
||||||
<flux:button type="submit" variant="primary">Save workflow message</flux:button>
|
|
||||||
<flux:button type="button" wire:click="resetWorkflowTemplateForm" variant="ghost">Reset</flux:button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
|
||||||
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3">Name</th>
|
|
||||||
<th class="px-4 py-3">Event</th>
|
|
||||||
<th class="px-4 py-3">Recipient</th>
|
|
||||||
<th class="px-4 py-3">Active</th>
|
|
||||||
<th class="px-4 py-3 text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
|
||||||
@forelse ($workflowTemplates as $template)
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $template->name }}</td>
|
|
||||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $template->event }}</td>
|
|
||||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $template->to_role }}</td>
|
|
||||||
<td class="px-4 py-3"><flux:badge color="{{ $template->is_active ? 'emerald' : 'zinc' }}">{{ $template->is_active ? 'Active' : 'Inactive' }}</flux:badge></td>
|
|
||||||
<td class="px-4 py-3 text-right">
|
|
||||||
<flux:button size="sm" variant="ghost" wire:click="editWorkflowTemplate({{ $template->id }})">Edit</flux:button>
|
|
||||||
<flux:button size="sm" variant="danger" wire:click="deleteWorkflowTemplate({{ $template->id }})" wire:confirm="Delete this workflow message?">Delete</flux:button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr><td colspan="5" class="px-4 py-8 text-center text-zinc-500">No workflow messages yet.</td></tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
|
|
||||||
@if ($section === 'canned')
|
|
||||||
<x-helpdesk::settings-section title="Canned responses" description="Save professional reusable replies with macro tokens for ticket, requester, agent, status and URL values.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveCannedResponse" class="grid gap-4 md:grid-cols-6">
|
|
||||||
<flux:input wire:model="cannedForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="cannedForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="cannedForm.sort_order" label="Sort" type="number" min="0" />
|
|
||||||
<flux:select wire:model="cannedForm.category_id" label="Category"><option value="">Any category</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:select wire:model="cannedForm.team_id" label="Team"><option value="">Any team</option>@foreach ($teams as $team)<option value="{{ $team->id }}">{{ $team->name }}</option>@endforeach</flux:select>
|
|
||||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="cannedForm.is_active" class="rounded border-zinc-300"> Active</label>
|
|
||||||
<div class="md:col-span-6"><flux:input wire:model="cannedForm.subject" label="Optional subject" /></div>
|
|
||||||
<div class="md:col-span-6"><flux:textarea wire:model="cannedForm.body" label="Reply body" rows="6" placeholder="Hello @{{ requester.name }}, Your ticket @{{ ticket.reference }} has been reviewed." /></div>
|
|
||||||
<div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save canned response</flux:button><flux:button type="button" wire:click="resetCannedResponseForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
@forelse ($cannedResponses as $response)
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="flex items-start justify-between gap-3"><div><h3 class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $response->name }}</h3><p class="text-xs text-zinc-500">{{ $response->category?->name ?? 'Any category' }} · {{ $response->team?->name ?? 'Any team' }}</p></div><flux:badge color="{{ $response->is_active ? 'emerald' : 'zinc' }}">{{ $response->is_active ? 'Active' : 'Inactive' }}</flux:badge></div>
|
|
||||||
<p class="mt-3 line-clamp-3 text-sm text-zinc-600 dark:text-zinc-300">{{ strip_tags($response->body) }}</p>
|
|
||||||
<div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editCannedResponse({{ $response->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteCannedResponse({{ $response->id }})" wire:confirm="Delete this canned response?">Delete</flux:button></div>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2 xl:col-span-3">No canned responses yet.</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'sla')
|
|
||||||
<x-helpdesk::settings-section title="SLA policies" description="Define first response and resolution targets used for breach indicators, reporting and ticket cards.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveSlaPolicy" class="grid gap-4 md:grid-cols-6">
|
|
||||||
<flux:input wire:model="slaForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="slaForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:select wire:model="slaForm.priority_id" label="Priority"><option value="">Any priority</option>@foreach ($priorities as $priority)<option value="{{ $priority->id }}">{{ $priority->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:select wire:model="slaForm.category_id" label="Category"><option value="">Any category</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:input wire:model="slaForm.first_response_minutes" label="First response minutes" type="number" min="1" />
|
|
||||||
<flux:input wire:model="slaForm.resolution_minutes" label="Resolution minutes" type="number" min="1" />
|
|
||||||
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="slaForm.is_default" class="rounded border-zinc-300"> Default</label>
|
|
||||||
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="slaForm.business_hours_only" class="rounded border-zinc-300"> Business hours</label>
|
|
||||||
<label class="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="slaForm.is_active" class="rounded border-zinc-300"> Active</label>
|
|
||||||
<div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save SLA policy</flux:button><flux:button type="button" wire:click="resetSlaPolicyForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"><table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700"><thead class="bg-zinc-50 text-left text-xs font-semibold uppercase text-zinc-500 dark:bg-zinc-800"><tr><th class="px-4 py-3">Policy</th><th class="px-4 py-3">Targets</th><th class="px-4 py-3">Applies to</th><th class="px-4 py-3 text-right">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">@forelse ($slaPolicies as $policy)<tr><td class="px-4 py-3 font-medium">{{ $policy->name }} @if($policy->is_default)<flux:badge color="blue">Default</flux:badge>@endif</td><td class="px-4 py-3">{{ $policy->first_response_minutes }}m response · {{ $policy->resolution_minutes }}m close</td><td class="px-4 py-3">{{ $policy->priority?->name ?? 'Any priority' }} · {{ $policy->category?->name ?? 'Any category' }}</td><td class="px-4 py-3 text-right"><flux:button size="sm" variant="primary" wire:click="editSlaPolicy({{ $policy->id }})">Edit</flux:button> <flux:button size="sm" variant="danger" wire:click="deleteSlaPolicy({{ $policy->id }})" wire:confirm="Delete this SLA policy?">Delete</flux:button></td></tr>@empty<tr><td colspan="4" class="px-4 py-8 text-center text-zinc-500">No SLA policies yet.</td></tr>@endforelse</tbody></table></div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'automation')
|
|
||||||
<x-helpdesk::settings-section title="Email automation rules" description="Match incoming support messages and automatically assign priority, category, team, project, agent and status.">
|
|
||||||
<x-slot:form>
|
|
||||||
<form wire:submit="saveAutomationRule" class="grid gap-4 md:grid-cols-6">
|
|
||||||
<flux:input wire:model="automationForm.name" label="Name" />
|
|
||||||
<flux:input wire:model="automationForm.slug" label="Slug" placeholder="auto-generated" />
|
|
||||||
<flux:input wire:model="automationForm.sort_order" label="Sort" type="number" min="0" />
|
|
||||||
<flux:input wire:model="automationForm.match_sender" label="Sender contains" />
|
|
||||||
<flux:input wire:model="automationForm.match_subject" label="Subject contains" />
|
|
||||||
<flux:input wire:model="automationForm.match_mailbox" label="Mailbox contains" />
|
|
||||||
<flux:select wire:model="automationForm.set_priority_id" label="Set priority"><option value="">No change</option>@foreach ($priorities as $priority)<option value="{{ $priority->id }}">{{ $priority->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:select wire:model="automationForm.set_category_id" label="Set category"><option value="">No change</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:select wire:model="automationForm.set_team_id" label="Set team"><option value="">No change</option>@foreach ($teams as $team)<option value="{{ $team->id }}">{{ $team->name }}</option>@endforeach</flux:select>
|
|
||||||
<flux:select wire:model="automationForm.set_project_id" label="Set project"><option value="">No change</option>@foreach ($projects as $project)<option value="{{ $project->id }}">{{ $project->title }}</option>@endforeach</flux:select>
|
|
||||||
<flux:select wire:model="automationForm.set_status_id" label="Set status"><option value="">No change</option>@foreach ($statuses as $status)<option value="{{ $status->id }}">{{ $status->name }}</option>@endforeach</flux:select>
|
|
||||||
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="automationForm.is_active" class="rounded border-zinc-300"> Active</label>
|
|
||||||
<div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save automation rule</flux:button><flux:button type="button" wire:click="resetAutomationRuleForm" variant="ghost">Reset</flux:button></div>
|
|
||||||
</form>
|
|
||||||
</x-slot:form>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">@forelse ($automationRules as $rule)<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900"><h3 class="font-semibold">{{ $rule->name }}</h3><p class="mt-1 text-sm text-zinc-500">Sender: {{ $rule->match_sender ?: 'Any' }} · Subject: {{ $rule->match_subject ?: 'Any' }}</p><div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editAutomationRule({{ $rule->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteAutomationRule({{ $rule->id }})" wire:confirm="Delete this rule?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2">No automation rules yet.</div>@endforelse</div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'checklists')
|
|
||||||
<x-helpdesk::settings-section title="Checklist templates" description="Apply repeatable checklist items by category or project and show completion progress on tickets and Kanban cards.">
|
|
||||||
<x-slot:form><form wire:submit="saveChecklistTemplate" class="grid gap-4 md:grid-cols-6"><flux:input wire:model="checklistTemplateForm.name" label="Name" /><flux:input wire:model="checklistTemplateForm.slug" label="Slug" /><flux:select wire:model="checklistTemplateForm.category_id" label="Category"><option value="">Any category</option>@foreach ($categories as $category)<option value="{{ $category->id }}">{{ $category->name }}</option>@endforeach</flux:select><flux:select wire:model="checklistTemplateForm.project_id" label="Project"><option value="">Any project</option>@foreach ($projects as $project)<option value="{{ $project->id }}">{{ $project->title }}</option>@endforeach</flux:select><label class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox" wire:model="checklistTemplateForm.is_active" class="rounded border-zinc-300"> Active</label><div class="md:col-span-6"><flux:textarea wire:model="checklistTemplateForm.items_text" label="Checklist items, one per line" rows="6" /></div><div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save checklist</flux:button><flux:button type="button" wire:click="resetChecklistTemplateForm" variant="ghost">Reset</flux:button></div></form></x-slot:form>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">@forelse ($checklistTemplates as $template)<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900"><h3 class="font-semibold">{{ $template->name }}</h3><p class="mt-1 text-sm text-zinc-500">{{ count($template->items ?? []) }} items</p><div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editChecklistTemplate({{ $template->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteChecklistTemplate({{ $template->id }})" wire:confirm="Delete this checklist?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2 xl:col-span-3">No checklist templates yet.</div>@endforelse</div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'tags')
|
|
||||||
<x-helpdesk::settings-section title="Ticket tags" description="Maintain visible ticket labels for search, triage and Kanban card context.">
|
|
||||||
<x-slot:form><form wire:submit="saveTag" class="grid gap-4 md:grid-cols-5"><flux:input wire:model="tagForm.name" label="Name" /><flux:input wire:model="tagForm.slug" label="Slug" /><flux:input wire:model="tagForm.color" label="Colour" /><div class="md:col-span-5 flex gap-2"><flux:button type="submit" variant="primary">Save tag</flux:button><flux:button type="button" wire:click="resetTagForm" variant="ghost">Reset</flux:button></div></form></x-slot:form>
|
|
||||||
<div class="flex flex-wrap gap-3">@forelse ($tags as $tag)<div class="rounded-2xl border border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900"><span class="rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span><div class="mt-3 flex gap-2"><flux:button size="sm" variant="primary" wire:click="editTag({{ $tag->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteTag({{ $tag->id }})" wire:confirm="Delete this tag?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700">No tags yet.</div>@endforelse</div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($section === 'products')
|
|
||||||
<x-helpdesk::settings-section title="Products and services" description="Link tickets to products, services, releases or builds and report support cost/time by product.">
|
|
||||||
<x-slot:form><form wire:submit="saveProduct" class="grid gap-4 md:grid-cols-6"><flux:input wire:model="productForm.name" label="Name" /><flux:input wire:model="productForm.slug" label="Slug" /><flux:input wire:model="productForm.type" label="Type" /><flux:input wire:model="productForm.version" label="Version" /><flux:input wire:model="productForm.release_channel" label="Release/build" /><label class="flex items-end gap-2 pb-2 text-sm"><input type="checkbox" wire:model="productForm.is_active" class="rounded border-zinc-300"> Active</label><div class="md:col-span-6"><flux:textarea wire:model="productForm.description" label="Description" rows="3" /></div><div class="md:col-span-6 flex gap-2"><flux:button type="submit" variant="primary">Save product/service</flux:button><flux:button type="button" wire:click="resetProductForm" variant="ghost">Reset</flux:button></div></form></x-slot:form>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">@forelse ($products as $product)<div class="rounded-2xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900"><h3 class="font-semibold">{{ $product->name }}</h3><p class="mt-1 text-sm text-zinc-500">{{ ucfirst($product->type) }} · {{ $product->version ?: 'No version' }} · {{ $product->tickets_count }} tickets</p><div class="mt-4 flex justify-end gap-2"><flux:button size="sm" variant="primary" wire:click="editProduct({{ $product->id }})">Edit</flux:button><flux:button size="sm" variant="danger" wire:click="deleteProduct({{ $product->id }})" wire:confirm="Delete this product/service?">Delete</flux:button></div></div>@empty<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500 dark:border-zinc-700 md:col-span-2 xl:col-span-3">No products or services yet.</div>@endforelse</div>
|
|
||||||
</x-helpdesk::settings-section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<div class="space-y-6">
|
|
||||||
<style>
|
|
||||||
.helpdesk-required-star {
|
|
||||||
color: #ef4444 !important;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select2-container--default .select2-selection--single {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select2-container--default .select2-selection--single .select2-selection__clear {
|
|
||||||
position: absolute !important;
|
|
||||||
top: 50% !important;
|
|
||||||
right: 2.25rem !important;
|
|
||||||
left: auto !important;
|
|
||||||
float: none !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: #f87171 !important;
|
|
||||||
font-weight: 800 !important;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@include('helpdesk::partials.page-header', [
|
|
||||||
'eyebrow' => 'Helpdesk',
|
|
||||||
'title' => 'Create ticket',
|
|
||||||
'subtitle' => 'Raise a new support request with priority, product/service, project, category, team and full details.',
|
|
||||||
])
|
|
||||||
|
|
||||||
<form wire:submit.prevent="save" enctype="multipart/form-data" class="space-y-6">
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
|
|
||||||
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-100">Ticket details</h2>
|
|
||||||
<p class="mt-1 text-sm text-zinc-400">
|
|
||||||
Mandatory fields are marked with <span class="helpdesk-required-star">*</span>.
|
|
||||||
Use the searchable lists to keep the ticket correctly routed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-5 p-6 lg:grid-cols-3 xl:grid-cols-6">
|
|
||||||
<div class="lg:col-span-3 xl:col-span-6">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="subject">
|
|
||||||
Subject <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
id="subject"
|
|
||||||
rows="3"
|
|
||||||
wire:model.defer="subject"
|
|
||||||
class="mt-2 block min-h-[92px] w-full resize-y rounded-xl border-zinc-700 bg-zinc-950 px-4 py-3 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
||||||
data-dusk="ticket-subject"
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
@error('subject')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($companies->isNotEmpty())
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="company_id">
|
|
||||||
Company <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="company_id" wire:model.defer="company_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">Select company</option>
|
|
||||||
@foreach ($companies as $company)
|
|
||||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('company_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="priority_id">
|
|
||||||
Priority <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="priority_id" wire:model.defer="priority_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500" data-dusk="ticket-priority">
|
|
||||||
<option value="">Select priority</option>
|
|
||||||
@foreach ($priorities as $priority)
|
|
||||||
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('priority_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="category_id">
|
|
||||||
Category <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="category_id" wire:model.defer="category_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">Select category</option>
|
|
||||||
@foreach ($categories as $category)
|
|
||||||
<option value="{{ $category->id }}">{{ $category->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('category_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="project_id">
|
|
||||||
Project <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="project_id" wire:model.defer="project_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">Select project</option>
|
|
||||||
@foreach ($projects as $project)
|
|
||||||
<option value="{{ $project->id }}">{{ $project->title ?? $project->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('project_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="team_id">
|
|
||||||
Team <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="team_id" wire:model.defer="team_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">Select team</option>
|
|
||||||
@foreach ($teams as $team)
|
|
||||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('team_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="due_at">Due date</label>
|
|
||||||
|
|
||||||
<input id="due_at" type="date" wire:model.defer="due_at" class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
|
|
||||||
@error('due_at')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-3 xl:col-span-6">
|
|
||||||
<x-helpdesk::html-editor dusk="ticket-body-editor" wire:model="body" label="Details *" rows="10" placeholder="Describe the issue, expected outcome, steps to reproduce and any useful context..." />
|
|
||||||
|
|
||||||
@error('body')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-3 xl:col-span-6">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="attachments">Attachments</label>
|
|
||||||
|
|
||||||
<input id="attachments" type="file" wire:model="attachments" multiple class="mt-2 block w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 shadow-sm">
|
|
||||||
|
|
||||||
@error('attachments')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
@error('attachments.*')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-zinc-700 bg-zinc-900 px-6 py-4">
|
|
||||||
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
data-dusk="ticket-create-submit"
|
|
||||||
wire:loading.attr="disabled"
|
|
||||||
>
|
|
||||||
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
|
|
||||||
<span wire:loading.remove>Create Ticket</span>
|
|
||||||
<span wire:loading>Creating...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@include('helpdesk::partials.select2-loader')
|
|
||||||
</div>
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
<div class="space-y-6">
|
|
||||||
<style>
|
|
||||||
.helpdesk-required-star {
|
|
||||||
color: #ef4444 !important;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select2-container--default .select2-selection--single {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select2-container--default .select2-selection--single .select2-selection__clear {
|
|
||||||
position: absolute !important;
|
|
||||||
top: 50% !important;
|
|
||||||
right: 2.25rem !important;
|
|
||||||
left: auto !important;
|
|
||||||
float: none !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: #f87171 !important;
|
|
||||||
font-weight: 800 !important;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@include('helpdesk::partials.page-header', [
|
|
||||||
'eyebrow' => 'Helpdesk',
|
|
||||||
'title' => 'Edit Ticket',
|
|
||||||
'subtitle' => 'Update ownership, assignment, workflow status, product/service, project, team, category, priority and ticket details.',
|
|
||||||
])
|
|
||||||
|
|
||||||
<form wire:submit.prevent="save" class="space-y-6">
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-700 bg-zinc-900 shadow-sm">
|
|
||||||
<div class="border-b border-zinc-700 bg-zinc-800/80 px-6 py-4">
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-100">
|
|
||||||
{{ $ticket->reference }} · Ticket controls
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<span class="inline-flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-200">
|
|
||||||
<i class="fa-regular fa-user text-zinc-400" aria-hidden="true"></i>
|
|
||||||
Owner:
|
|
||||||
<span class="font-semibold text-white">{{ $ticket->owner?->name ?? 'Unknown' }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-2 text-sm text-zinc-400">
|
|
||||||
Mandatory fields are marked with <span class="helpdesk-required-star">*</span>.
|
|
||||||
Assignment and status updates trigger configured workflow messages.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
|
|
||||||
Back to ticket
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-5 p-6 lg:grid-cols-3 xl:grid-cols-6">
|
|
||||||
<div class="lg:col-span-3 xl:col-span-6">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="subject">
|
|
||||||
Subject <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
id="subject"
|
|
||||||
rows="3"
|
|
||||||
wire:model.defer="subject"
|
|
||||||
class="mt-2 block min-h-[92px] w-full resize-y rounded-xl border-zinc-700 bg-zinc-950 px-4 py-3 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
|
||||||
data-dusk="ticket-edit-subject"
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
@error('subject')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if($companies->isNotEmpty())
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="company_id">
|
|
||||||
Company <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="company_id" wire:model.defer="company_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">Select company</option>
|
|
||||||
@foreach ($companies as $company)
|
|
||||||
<option value="{{ $company->id }}">{{ $company->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('company_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="status_id">
|
|
||||||
Status <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="status_id" wire:model.defer="status_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500" data-dusk="ticket-edit-status">
|
|
||||||
@foreach ($statuses as $status)
|
|
||||||
<option value="{{ $status->id }}">{{ $status->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('status_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="priority_id">
|
|
||||||
Priority <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="priority_id" wire:model.defer="priority_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500" data-dusk="ticket-edit-priority">
|
|
||||||
@foreach ($priorities as $priority)
|
|
||||||
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('priority_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="agent_id">Assigned agent</label>
|
|
||||||
|
|
||||||
<select id="agent_id" wire:model.defer="agent_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500" data-dusk="ticket-edit-agent">
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
@foreach ($agents as $agent)
|
|
||||||
<option value="{{ $agent->id }}">{{ $agent->name }} · {{ $agent->email }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('agent_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="category_id">
|
|
||||||
Category <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="category_id" wire:model.defer="category_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">No category</option>
|
|
||||||
@foreach ($categories as $category)
|
|
||||||
<option value="{{ $category->id }}">{{ $category->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('category_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="project_id">
|
|
||||||
Project <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="project_id" wire:model.defer="project_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">No project</option>
|
|
||||||
@foreach ($projects as $project)
|
|
||||||
<option value="{{ $project->id }}">{{ $project->title ?? $project->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('project_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="team_id">
|
|
||||||
Team <span class="helpdesk-required-star">*</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select id="team_id" wire:model.defer="team_id" data-helpdesk-select2 class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
<option value="">No team</option>
|
|
||||||
@foreach ($teams as $team)
|
|
||||||
<option value="{{ $team->id }}">{{ $team->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
|
|
||||||
@error('team_id')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="xl:col-span-2">
|
|
||||||
<label class="block text-sm font-semibold text-zinc-200" for="due_at">Due date</label>
|
|
||||||
|
|
||||||
<input id="due_at" type="date" wire:model.defer="due_at" class="mt-2 block w-full rounded-xl border-zinc-700 bg-zinc-950 text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
|
||||||
|
|
||||||
@error('due_at')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:col-span-3 xl:col-span-6">
|
|
||||||
<x-helpdesk::html-editor dusk="ticket-edit-body-editor" wire:model="body" label="Details *" rows="10" placeholder="Update the ticket details..." />
|
|
||||||
|
|
||||||
@error('body')
|
|
||||||
<p class="mt-2 text-sm text-red-500">{{ $message }}</p>
|
|
||||||
@enderror
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-zinc-700 bg-zinc-900 px-6 py-4">
|
|
||||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
data-dusk="ticket-edit-submit"
|
|
||||||
wire:loading.attr="disabled"
|
|
||||||
>
|
|
||||||
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
|
|
||||||
<span wire:loading.remove>Save Ticket</span>
|
|
||||||
<span wire:loading>Saving...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@include('helpdesk::partials.select2-loader')
|
|
||||||
</div>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<div class="space-y-6" dusk="helpdesk-ticket-index">
|
|
||||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Ticket management</p>
|
|
||||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Active tickets</h1>
|
|
||||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Search, triage and manage support requests across companies, projects, teams, priorities and statuses.</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<flux:button :href="route('helpdesk.index')" wire:navigate variant="ghost" icon="chart-bar">Dashboard</flux:button>
|
|
||||||
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate icon="plus" variant="primary">New ticket</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="grid gap-4 md:grid-cols-4">
|
|
||||||
<flux:input wire:model.live.debounce.350ms="search" icon="magnifying-glass" placeholder="Search reference, subject or body" />
|
|
||||||
|
|
||||||
<flux:select wire:model.live="scope">
|
|
||||||
<option value="open">Open tickets</option>
|
|
||||||
<option value="closed">Closed tickets</option>
|
|
||||||
<option value="all">All tickets</option>
|
|
||||||
</flux:select>
|
|
||||||
|
|
||||||
<flux:select wire:model.live="status">
|
|
||||||
<option value="">All statuses</option>
|
|
||||||
@foreach ($statuses as $status)
|
|
||||||
<option value="{{ $status->id }}">{{ $status->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
|
|
||||||
<flux:select wire:model.live="priority">
|
|
||||||
<option value="">All priorities</option>
|
|
||||||
@foreach ($priorities as $priority)
|
|
||||||
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div wire:loading.flex class="items-center gap-2 border-b border-zinc-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-zinc-700 dark:bg-blue-950 dark:text-blue-200">
|
|
||||||
<flux:icon.arrow-path class="size-4 animate-spin" /> Refreshing tickets...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
|
||||||
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3">Reference</th>
|
|
||||||
<th class="px-4 py-3">Subject</th>
|
|
||||||
<th class="px-4 py-3">Status</th>
|
|
||||||
<th class="px-4 py-3">Priority</th>
|
|
||||||
<th class="px-4 py-3">Agent</th>
|
|
||||||
<th class="px-4 py-3">Last activity</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
|
||||||
@forelse ($tickets as $ticket)
|
|
||||||
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-800/70">
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs">
|
|
||||||
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="font-semibold text-blue-700 hover:underline dark:text-blue-300">
|
|
||||||
{{ $ticket->reference }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</div>
|
|
||||||
<div class="text-xs text-zinc-500">{{ $ticket->category?->name ?? 'No category' }} @if($ticket->project) · {{ $ticket->project->title }} @endif @if($ticket->team) · {{ $ticket->team->name }} @endif</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3"><flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge></td>
|
|
||||||
<td class="px-4 py-3"><flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge></td>
|
|
||||||
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $ticket->agent?->name ?? 'Unassigned' }}</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-zinc-500">{{ $ticket->last_activity_at?->diffForHumans() ?? $ticket->created_at->diffForHumans() }}</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-4 py-12 text-center text-zinc-500">No tickets matched your filters.</td>
|
|
||||||
</tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">
|
|
||||||
{{ $tickets->links() }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<div class="space-y-6" dusk="helpdesk-ticket-show">
|
|
||||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
|
||||||
<span class="font-mono text-xs text-blue-100">{{ $ticket->reference }}</span>
|
|
||||||
<flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge>
|
|
||||||
<flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl font-semibold tracking-tight">{{ $ticket->subject }}</h1>
|
|
||||||
<p class="mt-2 text-sm text-blue-100/90">Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="inline-flex items-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">
|
|
||||||
<i class="fa-solid fa-arrow-left mr-2" aria-hidden="true"></i> Back
|
|
||||||
</a>
|
|
||||||
@can('update', $ticket)
|
|
||||||
<a href="{{ route('helpdesk.tickets.edit', $ticket) }}" wire:navigate class="inline-flex items-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-700">
|
|
||||||
<i class="fa-solid fa-pen-to-square mr-2" aria-hidden="true"></i> Edit
|
|
||||||
</a>
|
|
||||||
@endcan
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-3">
|
|
||||||
<div class="space-y-6 lg:col-span-2">
|
|
||||||
<article class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div class="prose max-w-none dark:prose-invert">
|
|
||||||
{!! $ticket->body !!}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
@if ($ticket->attachments->isNotEmpty())
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket attachments</h2>
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
|
||||||
@foreach ($ticket->attachments as $attachment)
|
|
||||||
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-xl border border-zinc-200 p-3 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
|
|
||||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $attachment->original_name }}</div>
|
|
||||||
<div class="text-xs text-zinc-500">{{ Number::fileSize($attachment->size) }}</div>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<section class="space-y-4">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Conversation</h2>
|
|
||||||
|
|
||||||
@if ($ticket->comments->isNotEmpty())
|
|
||||||
@include('helpdesk::partials.comment-thread', ['comments' => $ticket->comments, 'level' => 0])
|
|
||||||
@else
|
|
||||||
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">No responses yet.</div>
|
|
||||||
@endif
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form wire:submit="addComment" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
|
|
||||||
@if ($cannedResponses->isNotEmpty())
|
|
||||||
<div class="mb-4 grid gap-3 md:grid-cols-[1fr_auto]">
|
|
||||||
<flux:select wire:model="canned_response_id" label="Canned response">
|
|
||||||
<option value="">Choose a saved response...</option>
|
|
||||||
@foreach ($cannedResponses as $response)
|
|
||||||
<option value="{{ $response->id }}">{{ $response->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<flux:button type="button" wire:click="applyCannedResponse" variant="ghost" icon="document-duplicate">Insert response</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-helpdesk::html-editor dusk="ticket-comment-editor" wire:model="commentBody" label="Comment" rows="5" placeholder="Write a professional response..." />
|
|
||||||
|
|
||||||
@can('update', $ticket)
|
|
||||||
<div class="mt-4">
|
|
||||||
<flux:checkbox wire:model="internal" label="Internal note only" />
|
|
||||||
</div>
|
|
||||||
@endcan
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Attach files</label>
|
|
||||||
<input wire:model="attachments" type="file" multiple class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" />
|
|
||||||
@error('attachments.*') <div class="mt-2 text-sm text-red-600">{{ $message }}</div> @enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 flex justify-end">
|
|
||||||
<flux:button dusk="post-comment" type="submit" variant="primary" icon="chat-bubble-left-right" class="!bg-emerald-600 !text-white hover:!bg-emerald-700" wire:loading.attr="disabled">
|
|
||||||
<span wire:loading.remove>Post reply</span>
|
|
||||||
<span wire:loading>Posting...</span>
|
|
||||||
</flux:button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="space-y-6">
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket details</h2>
|
|
||||||
<dl class="space-y-3 text-sm">
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Product/service</dt><dd>{{ $ticket->product?->name ?? 'None' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Team</dt><dd>{{ $ticket->team?->name ?? 'None' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Created</dt><dd>{{ $ticket->created_at->format('d M Y H:i') }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Due</dt><dd>{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">SLA metrics</h2>
|
|
||||||
<div class="grid gap-3 text-sm">
|
|
||||||
<div class="rounded-xl border {{ $ticket->sla_first_response_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100' }} p-3">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide">First response</div>
|
|
||||||
<div class="mt-1">{{ $ticket->first_response_at?->format('d M Y H:i') ?? 'Awaiting response' }}</div>
|
|
||||||
<div class="mt-1 text-xs opacity-75">Due {{ $ticket->sla_first_response_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl border {{ $ticket->sla_resolution_breached ? 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100' : 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100' }} p-3">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide">Resolution</div>
|
|
||||||
<div class="mt-1">{{ $ticket->completed_at?->format('d M Y H:i') ?? 'Open' }}</div>
|
|
||||||
<div class="mt-1 text-xs opacity-75">Due {{ $ticket->sla_resolution_due_at?->format('d M Y H:i') ?? 'Not set' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2 text-center">
|
|
||||||
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><div class="text-xl font-semibold">{{ $ticket->public_replies_count }}</div><div class="text-xs text-zinc-500">Public replies</div></div>
|
|
||||||
<div class="rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"><div class="text-xl font-semibold">{{ $ticket->time_spent_minutes }}</div><div class="text-xs text-zinc-500">Minutes logged</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Requester profile</h2>
|
|
||||||
<dl class="space-y-3 text-sm">
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Name</dt><dd>{{ $ticket->owner?->name ?? 'Unknown' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Email</dt><dd class="truncate">{{ $ticket->owner?->email ?? 'None' }}</dd></div>
|
|
||||||
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Company</dt><dd>{{ $ticket->owner?->company?->name ?? 'Current company' }}</dd></div>
|
|
||||||
</dl>
|
|
||||||
@if ($ticket->tags->isNotEmpty())
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
|
||||||
@foreach ($ticket->tags as $tag)
|
|
||||||
<span class="rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ \Modules\Helpdesk\Support\Color::css($tag->color) }}">{{ $tag->name }}</span>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($ticket->checklistItems->isNotEmpty())
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
@php($completedChecks = $ticket->checklistItems->where('is_completed', true)->count())
|
|
||||||
<div class="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<h2 class="text-base font-semibold text-zinc-900 dark:text-white">Checklist</h2>
|
|
||||||
<flux:badge color="blue">{{ $completedChecks }}/{{ $ticket->checklistItems->count() }}</flux:badge>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@foreach ($ticket->checklistItems as $item)
|
|
||||||
<label class="flex items-start gap-3 rounded-xl border border-zinc-200 p-3 text-sm dark:border-zinc-700">
|
|
||||||
<input type="checkbox" wire:click="toggleChecklistItem({{ $item->id }})" @checked($item->is_completed) class="mt-1 rounded border-zinc-300">
|
|
||||||
<span class="{{ $item->is_completed ? 'text-zinc-500 line-through' : 'text-zinc-800 dark:text-zinc-100' }}">{{ $item->title }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Git commits</h2>
|
|
||||||
<div class="space-y-2">
|
|
||||||
@forelse ($ticket->gitCommits as $commit)
|
|
||||||
<a href="{{ $commit->url ?: '#' }}" target="_blank" class="block rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
|
|
||||||
<div class="mt-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $commit->message }}</div>
|
|
||||||
</a>
|
|
||||||
@empty
|
|
||||||
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">No commits linked.</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@can('update', $ticket)
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Agent actions</h2>
|
|
||||||
<flux:select wire:model="status_id" label="Status">
|
|
||||||
@foreach ($statuses as $status)
|
|
||||||
<option value="{{ $status->id }}">{{ $status->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
<flux:button dusk="update-status" wire:click="updateStatus" class="mt-4 w-full" variant="primary" icon="check">Update status</flux:button>
|
|
||||||
<div class="mt-5">
|
|
||||||
<flux:select wire:model="priority_id" label="Priority">
|
|
||||||
@foreach ($priorities as $priority)
|
|
||||||
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
<flux:button dusk="update-priority" wire:click="updatePriority" class="mt-4 w-full" variant="ghost" icon="flag">Update priority</flux:button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endcan
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
@props(['comments', 'level' => 0])
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
@foreach ($comments as $comment)
|
|
||||||
@php
|
|
||||||
$commentUser = $comment->relationLoaded('user') ? $comment->getRelation('user') : null;
|
|
||||||
$commentAttachments = $comment->relationLoaded('attachments') ? $comment->getRelation('attachments') : collect();
|
|
||||||
$commentReplies = $comment->relationLoaded('replies') ? $comment->getRelation('replies') : collect();
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-zinc-900 dark:text-zinc-100">
|
|
||||||
{{ $commentUser?->name ?? 'Unknown user' }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-zinc-500">
|
|
||||||
{{ $comment->created_at?->format('d M Y H:i') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($comment->is_internal)
|
|
||||||
<flux:badge color="amber">Internal</flux:badge>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prose prose-sm mt-3 max-w-none dark:prose-invert">
|
|
||||||
{!! $comment->body !!}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($commentAttachments->isNotEmpty())
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
|
||||||
@foreach ($commentAttachments as $attachment)
|
|
||||||
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800">
|
|
||||||
{{ $attachment->original_name }}
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<flux:button size="sm" variant="ghost" wire:click="startReply({{ $comment->id }})">Reply</flux:button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (($reply_parent_id ?? null) === $comment->id)
|
|
||||||
<form wire:submit="addReply" class="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40">
|
|
||||||
<x-helpdesk::html-editor wire:key="reply-editor-{{ $comment->id }}" wire:model="replyBody" label="Reply" rows="4" placeholder="Write a reply..." />
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end gap-2">
|
|
||||||
<flux:button type="button" wire:click="cancelReply" variant="ghost">Cancel</flux:button>
|
|
||||||
<flux:button type="submit" variant="primary" class="!bg-emerald-600 !text-white hover:!bg-emerald-700">Post reply</flux:button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($commentReplies->isNotEmpty())
|
|
||||||
<div class="mt-4">
|
|
||||||
@include('helpdesk::partials.comment-thread', [
|
|
||||||
'comments' => $commentReplies,
|
|
||||||
'level' => ((int) $level) + 1,
|
|
||||||
])
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<div class="mb-8 overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-sm">
|
|
||||||
<div class="px-6 py-8 text-white">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-blue-300">
|
|
||||||
{{ $eyebrow ?? 'Helpdesk' }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h1 class="mt-3 text-3xl font-bold tracking-tight">
|
|
||||||
{{ $title ?? 'Helpdesk' }}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
@if(! empty($subtitle))
|
|
||||||
<p class="mt-2 max-w-3xl text-sm text-slate-300">
|
|
||||||
{{ $subtitle }}
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@isset($actions)
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
{{ $actions }}
|
|
||||||
</div>
|
|
||||||
@endisset
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
@once
|
|
||||||
<style>
|
|
||||||
.select2-container { width: 100% !important; }
|
|
||||||
.select2-container--default .select2-selection--single {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border-color: rgb(63 63 70);
|
|
||||||
background: rgb(9 9 11);
|
|
||||||
color: rgb(244 244 245);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.select2-container--default .select2-selection--single .select2-selection__rendered {
|
|
||||||
color: rgb(244 244 245);
|
|
||||||
line-height: 2.75rem;
|
|
||||||
padding-left: 0.85rem;
|
|
||||||
}
|
|
||||||
.select2-container--default .select2-selection--single .select2-selection__arrow {
|
|
||||||
height: 2.75rem;
|
|
||||||
}
|
|
||||||
.select2-dropdown {
|
|
||||||
border-color: rgb(63 63 70);
|
|
||||||
background: rgb(24 24 27);
|
|
||||||
color: rgb(244 244 245);
|
|
||||||
}
|
|
||||||
.select2-container--default .select2-results__option--highlighted[aria-selected] {
|
|
||||||
background: rgb(37 99 235);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.select2-container--default .select2-results__option[aria-selected=true] {
|
|
||||||
background: rgb(39 39 42);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.select2-search__field {
|
|
||||||
background: rgb(9 9 11);
|
|
||||||
color: rgb(244 244 245);
|
|
||||||
border-color: rgb(63 63 70) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@endonce
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
function loadScript(src, callback) {
|
|
||||||
const existing = document.querySelector('script[src="' + src + '"]');
|
|
||||||
if (existing) {
|
|
||||||
existing.addEventListener('load', callback, { once: true });
|
|
||||||
if (existing.dataset.loaded === '1') callback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = src;
|
|
||||||
script.onload = function () {
|
|
||||||
script.dataset.loaded = '1';
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadStyle(href) {
|
|
||||||
if (document.querySelector('link[href="' + href + '"]')) return;
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = href;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialiseHelpdeskSelects() {
|
|
||||||
if (!window.jQuery || !window.jQuery.fn || !window.jQuery.fn.select2) return;
|
|
||||||
|
|
||||||
window.jQuery('[data-helpdesk-select2]').each(function () {
|
|
||||||
const select = window.jQuery(this);
|
|
||||||
|
|
||||||
if (select.data('select2')) {
|
|
||||||
select.select2('destroy');
|
|
||||||
}
|
|
||||||
|
|
||||||
select.select2({
|
|
||||||
width: '100%',
|
|
||||||
placeholder: this.querySelector('option[value=""]')?.textContent || 'Select option',
|
|
||||||
allowClear: true
|
|
||||||
});
|
|
||||||
|
|
||||||
select.off('change.helpdesk').on('change.helpdesk', function () {
|
|
||||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bootHelpdeskSelects() {
|
|
||||||
loadStyle('https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css');
|
|
||||||
|
|
||||||
const bootSelect2 = function () {
|
|
||||||
if (window.jQuery && window.jQuery.fn && window.jQuery.fn.select2) {
|
|
||||||
initialiseHelpdeskSelects();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadScript('https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js', initialiseHelpdeskSelects);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (window.jQuery) {
|
|
||||||
bootSelect2();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('Helpdesk Select2 requires global jQuery from resources/js/app.js. Run npm install jquery and npm run build.');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', bootHelpdeskSelects);
|
|
||||||
document.addEventListener('livewire:navigated', bootHelpdeskSelects);
|
|
||||||
document.addEventListener('livewire:updated', bootHelpdeskSelects);
|
|
||||||
bootHelpdeskSelects();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
@php
|
|
||||||
$helpdeskEnabled = Route::has('helpdesk.index');
|
|
||||||
|
|
||||||
if (class_exists(\Nwidart\Modules\Facades\Module::class)) {
|
|
||||||
$helpdeskModule = \Nwidart\Modules\Facades\Module::find('Helpdesk');
|
|
||||||
$helpdeskEnabled = $helpdeskModule !== null && $helpdeskModule->isEnabled() && Route::has('helpdesk.index');
|
|
||||||
}
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if($helpdeskEnabled)
|
|
||||||
<div class="wms-sidebar-group">
|
|
||||||
<div class="wms-sidebar-heading" data-wms-sidebar-label>{{ __('Support') }}</div>
|
|
||||||
|
|
||||||
<a href="{{ route('helpdesk.index') }}" wire:navigate class="wms-sidebar-link @if(request()->routeIs('helpdesk.index')) is-active @endif" title="{{ __('Helpdesk') }}">
|
|
||||||
<i class="fa-solid fa-headset wms-sidebar-icon" aria-hidden="true"></i>
|
|
||||||
<span data-wms-sidebar-label>{{ __('Helpdesk') }}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
@if(Route::has('helpdesk.tickets.index'))
|
|
||||||
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="wms-sidebar-link @if(request()->routeIs('helpdesk.tickets.*')) is-active @endif" title="{{ __('Tickets') }}">
|
|
||||||
<i class="fa-solid fa-ticket wms-sidebar-icon" aria-hidden="true"></i>
|
|
||||||
<span data-wms-sidebar-label>{{ __('Tickets') }}</span>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(Route::has('helpdesk.kanban'))
|
|
||||||
<a href="{{ route('helpdesk.kanban') }}" wire:navigate class="wms-sidebar-link @if(request()->routeIs('helpdesk.kanban')) is-active @endif" title="{{ __('Kanban') }}">
|
|
||||||
<i class="fa-solid fa-table-columns wms-sidebar-icon" aria-hidden="true"></i>
|
|
||||||
<span data-wms-sidebar-label>{{ __('Kanban') }}</span>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if(Route::has('helpdesk.settings'))
|
|
||||||
<a href="{{ route('helpdesk.settings') }}" wire:navigate class="wms-sidebar-link @if(request()->routeIs('helpdesk.settings')) is-active @endif" title="{{ __('Support Settings') }}">
|
|
||||||
<i class="fa-solid fa-gears wms-sidebar-icon" aria-hidden="true"></i>
|
|
||||||
<span data-wms-sidebar-label>{{ __('Support Settings') }}</span>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\GitWebhookController;
|
|
||||||
|
|
||||||
Route::get('/status', static fn (): array => [
|
|
||||||
'module' => 'helpdesk',
|
|
||||||
'status' => 'ok',
|
|
||||||
])->name('status');
|
|
||||||
|
|
||||||
Route::post('/git/webhook/{repository}', GitWebhookController::class)
|
|
||||||
->whereNumber('repository')
|
|
||||||
->name('git.webhook');
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\TicketStoreController;
|
|
||||||
use Modules\Helpdesk\Http\Controllers\WorkflowOpenController;
|
|
||||||
use Modules\Helpdesk\Livewire\KanbanBoard;
|
|
||||||
use Modules\Helpdesk\Livewire\SettingsManager;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketCreate;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketEdit;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketIndex;
|
|
||||||
use Modules\Helpdesk\Livewire\TicketShow;
|
|
||||||
|
|
||||||
Route::get('/workflow/open/{token}', WorkflowOpenController::class)->name('workflow.open');
|
|
||||||
|
|
||||||
Route::get('/', HelpdeskController::class)->name('index');
|
|
||||||
|
|
||||||
Route::get('/settings', SettingsManager::class)->name('settings');
|
|
||||||
Route::get('/kanban', KanbanBoard::class)->name('kanban');
|
|
||||||
|
|
||||||
Route::get('/tickets', TicketIndex::class)->name('tickets.index');
|
|
||||||
Route::post('/tickets', TicketStoreController::class)->name('tickets.store');
|
|
||||||
Route::get('/tickets/create', TicketCreate::class)->name('tickets.create');
|
|
||||||
Route::get('/tickets/{ticket}/edit', TicketEdit::class)->name('tickets.edit');
|
|
||||||
Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Seeders;
|
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use Modules\Helpdesk\Database\Seeders\HelpdeskDatabaseSeeder as DatabaseHelpdeskDatabaseSeeder;
|
|
||||||
|
|
||||||
final class HelpdeskDatabaseSeeder extends Seeder
|
|
||||||
{
|
|
||||||
/** @return void */
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
$this->call(DatabaseHelpdeskDatabaseSeeder::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Services;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Modules\Helpdesk\Models\Category;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
|
|
||||||
final class AgentAssignmentService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Select the least-loaded available agent for a category.
|
|
||||||
*/
|
|
||||||
public function selectAgentId(?int $categoryId): ?int
|
|
||||||
{
|
|
||||||
$query = User::query()
|
|
||||||
->select('users.*')
|
|
||||||
->where(static function (Builder $query): void {
|
|
||||||
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
|
|
||||||
})
|
|
||||||
->selectSub(
|
|
||||||
Ticket::query()
|
|
||||||
->selectRaw('count(*)')
|
|
||||||
->whereColumn('helpdesk_tickets.agent_id', 'users.id')
|
|
||||||
->whereNull('helpdesk_tickets.completed_at'),
|
|
||||||
'open_helpdesk_tickets_count'
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($categoryId !== null) {
|
|
||||||
$category = Category::query()->find($categoryId);
|
|
||||||
$agentIds = $category?->agents()->pluck('users.id')->all() ?? [];
|
|
||||||
|
|
||||||
if ($agentIds !== []) {
|
|
||||||
$query->whereIn('id', $agentIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var User|null $agent */
|
|
||||||
$agent = $query->orderBy('open_helpdesk_tickets_count')->orderBy('id')->first();
|
|
||||||
|
|
||||||
return $agent?->id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Modules\Helpdesk\Services;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Modules\Helpdesk\Models\Attachment;
|
|
||||||
use Modules\Helpdesk\Models\Comment;
|
|
||||||
use Modules\Helpdesk\Models\Ticket;
|
|
||||||
|
|
||||||
final class AttachmentService
|
|
||||||
{
|
|
||||||
public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void
|
|
||||||
{
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$disk = (string) config('helpdesk.disk', 'public');
|
|
||||||
|
|
||||||
$originalName = $file->getClientOriginalName();
|
|
||||||
$mimeType = $file->getClientMimeType();
|
|
||||||
$size = (int) ($file->getSize() ?: 0);
|
|
||||||
|
|
||||||
$path = $file->store('tickets/'.$ticket->id, $disk);
|
|
||||||
|
|
||||||
if ($size === 0 && $path !== false) {
|
|
||||||
$size = (int) Storage::disk($disk)->size($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Attachment::query()->create([
|
|
||||||
'ticket_id' => $ticket->id,
|
|
||||||
'comment_id' => $comment?->id,
|
|
||||||
'user_id' => $userId,
|
|
||||||
'disk' => $disk,
|
|
||||||
'path' => $path,
|
|
||||||
'original_name' => $originalName,
|
|
||||||
'mime_type' => $mimeType,
|
|
||||||
'size' => $size,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a stored attachment and its backing file.
|
|
||||||
*/
|
|
||||||
public function delete(Attachment $attachment): void
|
|
||||||
{
|
|
||||||
Storage::disk($attachment->disk)->delete($attachment->path);
|
|
||||||
$attachment->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user