removed helpdesk
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,31 +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_extensions' => [
|
||||
'md', 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'log', 'zip',
|
||||
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
|
||||
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
|
||||
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config',
|
||||
],
|
||||
|
||||
'allowed_source_extensions' => [
|
||||
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
|
||||
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
|
||||
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md',
|
||||
],
|
||||
'blocked_extensions' => ['exe', 'dll', 'com', 'scr', 'msi', 'app', 'dmg', 'pkg', 'jar', 'war'],
|
||||
'virus_scanner' => env('HELPDESK_VIRUS_SCANNER', null),
|
||||
|
||||
// Backwards-compatible alias for older module code/tests that still read this key.
|
||||
'allowed_mimes' => [],
|
||||
'default_status_slug' => 'new',
|
||||
'closed_status_slug' => 'closed',
|
||||
];
|
||||
-26
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
-73
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
-41
@@ -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');
|
||||
}
|
||||
};
|
||||
-205
@@ -1,205 +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']);
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
-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
|
||||
{
|
||||
/**
|
||||
* 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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
foreach ([
|
||||
'helpdesk_statuses',
|
||||
'helpdesk_priorities',
|
||||
'helpdesk_categories',
|
||||
'helpdesk_projects',
|
||||
'helpdesk_teams',
|
||||
] as $table) {
|
||||
if (! Schema::hasColumn($table, 'is_default')) {
|
||||
Schema::table($table, function (Blueprint $table): void {
|
||||
$table->boolean('is_default')->default(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
foreach ([
|
||||
'helpdesk_statuses',
|
||||
'helpdesk_priorities',
|
||||
'helpdesk_categories',
|
||||
'helpdesk_projects',
|
||||
'helpdesk_teams',
|
||||
] as $tableName) {
|
||||
if (Schema::hasColumn($tableName, 'is_default')) {
|
||||
Schema::table($tableName, function (Blueprint $table): void {
|
||||
$table->dropColumn('is_default');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
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::hasColumn('companies', 'is_default_helpdesk_company')) {
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
$table->boolean('is_default_helpdesk_company')
|
||||
->default(false)
|
||||
->after('is_system_company');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
|
||||
Schema::table('companies', function (Blueprint $table): void {
|
||||
$table->dropColumn('is_default_helpdesk_company');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
-50
@@ -1,50 +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
|
||||
{
|
||||
foreach ([
|
||||
'helpdesk_statuses',
|
||||
'helpdesk_priorities',
|
||||
'helpdesk_categories',
|
||||
'helpdesk_projects',
|
||||
'helpdesk_teams',
|
||||
] as $tableName) {
|
||||
if (Schema::hasTable($tableName) && ! Schema::hasColumn($tableName, 'is_default')) {
|
||||
Schema::table($tableName, static function (Blueprint $table): void {
|
||||
$table->boolean('is_default')->default(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
foreach ([
|
||||
'helpdesk_statuses',
|
||||
'helpdesk_priorities',
|
||||
'helpdesk_categories',
|
||||
'helpdesk_projects',
|
||||
'helpdesk_teams',
|
||||
] as $tableName) {
|
||||
if (Schema::hasTable($tableName) && Schema::hasColumn($tableName, 'is_default')) {
|
||||
Schema::table($tableName, static function (Blueprint $table): void {
|
||||
$table->dropColumn('is_default');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add single-board ticket assignment and board ownership.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('helpdesk_kanban_boards') && ! Schema::hasColumn('helpdesk_kanban_boards', 'owner_id')) {
|
||||
Schema::table('helpdesk_kanban_boards', static function (Blueprint $table): void {
|
||||
$table->foreignId('owner_id')
|
||||
->nullable()
|
||||
->after('description')
|
||||
->constrained('users')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index('owner_id');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('helpdesk_tickets') && ! Schema::hasColumn('helpdesk_tickets', 'board_id')) {
|
||||
Schema::table('helpdesk_tickets', static function (Blueprint $table): void {
|
||||
$table->foreignId('board_id')
|
||||
->nullable()
|
||||
->after('status_id')
|
||||
->constrained('helpdesk_kanban_boards')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index(['board_id', 'status_id']);
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('helpdesk_tickets') && Schema::hasTable('helpdesk_kanban_boards') && Schema::hasColumn('helpdesk_tickets', 'board_id')) {
|
||||
$defaultBoardId = DB::table('helpdesk_kanban_boards')
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id') ?: DB::table('helpdesk_kanban_boards')->orderBy('sort_order')->value('id');
|
||||
|
||||
if ($defaultBoardId !== null) {
|
||||
DB::table('helpdesk_tickets')
|
||||
->whereNull('board_id')
|
||||
->update(['board_id' => $defaultBoardId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migration.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('helpdesk_tickets') && Schema::hasColumn('helpdesk_tickets', 'board_id')) {
|
||||
Schema::table('helpdesk_tickets', static function (Blueprint $table): void {
|
||||
$table->dropForeign(['board_id']);
|
||||
$table->dropIndex(['board_id', 'status_id']);
|
||||
$table->dropColumn('board_id');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('helpdesk_kanban_boards') && Schema::hasColumn('helpdesk_kanban_boards', 'owner_id')) {
|
||||
Schema::table('helpdesk_kanban_boards', static function (Blueprint $table): void {
|
||||
$table->dropForeign(['owner_id']);
|
||||
$table->dropIndex(['owner_id']);
|
||||
$table->dropColumn('owner_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Ensure existing tickets have one concrete Kanban board assignment.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_tickets') || ! Schema::hasColumn('helpdesk_tickets', 'board_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('helpdesk_kanban_boards')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultBoardId = DB::table('helpdesk_kanban_boards')
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id') ?: DB::table('helpdesk_kanban_boards')->orderBy('sort_order')->value('id');
|
||||
|
||||
if ($defaultBoardId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('helpdesk_tickets')
|
||||
->whereNull('board_id')
|
||||
->update([
|
||||
'board_id' => $defaultBoardId,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('helpdesk_tickets')
|
||||
->whereNotNull('board_id')
|
||||
->whereNotExists(function ($query): void {
|
||||
$query->selectRaw('1')
|
||||
->from('helpdesk_kanban_boards')
|
||||
->whereColumn('helpdesk_kanban_boards.id', 'helpdesk_tickets.board_id');
|
||||
})
|
||||
->update([
|
||||
'board_id' => $defaultBoardId,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This data repair is intentionally not reversed.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No-op: do not remove repaired board ownership from existing tickets.
|
||||
}
|
||||
};
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Backfill status columns for every Kanban board that currently has no columns.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_kanban_boards')
|
||||
|| ! Schema::hasTable('helpdesk_kanban_board_statuses')
|
||||
|| ! Schema::hasTable('helpdesk_statuses')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = DB::table('helpdesk_statuses')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id']);
|
||||
|
||||
if ($statuses->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$boards = DB::table('helpdesk_kanban_boards')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id']);
|
||||
|
||||
foreach ($boards as $board) {
|
||||
$hasColumns = DB::table('helpdesk_kanban_board_statuses')
|
||||
->where('board_id', $board->id)
|
||||
->exists();
|
||||
|
||||
if ($hasColumns) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($statuses as $index => $status) {
|
||||
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||
['board_id' => $board->id, 'status_id' => $status->id],
|
||||
[
|
||||
'sort_order' => ($index + 1) * 10,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The migration only repairs missing board columns and is intentionally not destructive.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No-op. Do not remove configured board columns.
|
||||
}
|
||||
};
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'guid')) {
|
||||
$table->uuid('guid')->nullable()->after('id');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'unid')) {
|
||||
$table->string('unid', 64)->nullable()->after('guid');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'replica_id')) {
|
||||
$table->string('replica_id', 64)->nullable()->after('unid');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'parent_unid')) {
|
||||
$table->string('parent_unid', 64)->nullable()->after('parent_id');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'root_unid')) {
|
||||
$table->string('root_unid', 64)->nullable()->after('parent_unid');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'originator_id')) {
|
||||
$table->string('originator_id', 96)->nullable()->after('root_unid');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'sync_sequence')) {
|
||||
$table->unsignedBigInteger('sync_sequence')->default(1)->after('originator_id');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'sync_hash')) {
|
||||
$table->string('sync_hash', 128)->nullable()->after('sync_sequence');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'source_system')) {
|
||||
$table->string('source_system', 80)->default('wms-helpdesk')->after('sync_hash');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'source_record_id')) {
|
||||
$table->string('source_record_id', 120)->nullable()->after('source_system');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'body_text')) {
|
||||
$table->text('body_text')->nullable()->after('body');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'replicated_at')) {
|
||||
$table->timestamp('replicated_at')->nullable()->after('updated_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'synced_at')) {
|
||||
$table->timestamp('synced_at')->nullable()->after('replicated_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$table->timestamp('tombstoned_at')->nullable()->after('synced_at');
|
||||
}
|
||||
});
|
||||
|
||||
Comment::query()->orderBy('id')->chunkById(100, static function ($comments): void {
|
||||
foreach ($comments as $comment) {
|
||||
$guid = $comment->guid ?: (string) Str::uuid();
|
||||
$unid = $comment->unid ?: strtoupper(hash('sha256', $guid . '|' . $comment->ticket_id . '|' . $comment->id));
|
||||
$parent = $comment->parent_id ? Comment::query()->find($comment->parent_id) : null;
|
||||
$parentUnid = $parent?->unid;
|
||||
$rootUnid = $parent?->root_unid ?: $parentUnid ?: $unid;
|
||||
|
||||
DB::table('helpdesk_comments')
|
||||
->where('id', $comment->id)
|
||||
->update([
|
||||
'guid' => $guid,
|
||||
'unid' => $unid,
|
||||
'parent_unid' => $parentUnid,
|
||||
'root_unid' => $rootUnid,
|
||||
'originator_id' => $comment->originator_id ?: $unid . ':1',
|
||||
'sync_sequence' => $comment->sync_sequence ?: 1,
|
||||
'sync_hash' => hash('sha256', (string) $comment->body),
|
||||
'body_text' => trim(strip_tags((string) $comment->body)),
|
||||
'source_system' => $comment->source_system ?: 'wms-helpdesk',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
$table->unique('guid', 'helpdesk_comments_guid_unique');
|
||||
$table->unique('unid', 'helpdesk_comments_unid_unique');
|
||||
$table->index(['ticket_id', 'root_unid', 'parent_unid'], 'helpdesk_comments_lotus_thread_index');
|
||||
$table->index(['ticket_id', 'is_internal', 'created_at'], 'helpdesk_comments_visibility_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
$table->dropUnique('helpdesk_comments_guid_unique');
|
||||
$table->dropUnique('helpdesk_comments_unid_unique');
|
||||
$table->dropIndex('helpdesk_comments_lotus_thread_index');
|
||||
$table->dropIndex('helpdesk_comments_visibility_index');
|
||||
|
||||
$table->dropColumn([
|
||||
'guid',
|
||||
'unid',
|
||||
'replica_id',
|
||||
'parent_unid',
|
||||
'root_unid',
|
||||
'originator_id',
|
||||
'sync_sequence',
|
||||
'sync_hash',
|
||||
'source_system',
|
||||
'source_record_id',
|
||||
'body_text',
|
||||
'replicated_at',
|
||||
'synced_at',
|
||||
'tombstoned_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'subject')) {
|
||||
$table->string('subject', 180)->nullable()->after('user_id');
|
||||
}
|
||||
});
|
||||
|
||||
DB::table('helpdesk_comments')
|
||||
->whereNull('subject')
|
||||
->orWhere('subject', '')
|
||||
->orderBy('id')
|
||||
->select(['id', 'parent_id', 'body', 'body_text', 'is_internal'])
|
||||
->chunkById(100, static function ($comments): void {
|
||||
foreach ($comments as $comment) {
|
||||
$plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) ($comment->body_text ?: $comment->body))) ?: '');
|
||||
$subject = (bool) $comment->is_internal
|
||||
? 'Internal note'
|
||||
: ((int) ($comment->parent_id ?? 0) > 0 ? 'RE: ' : '') . Str::limit($plain !== '' ? $plain : 'Response', 120, '');
|
||||
|
||||
DB::table('helpdesk_comments')
|
||||
->where('id', $comment->id)
|
||||
->update(['subject' => $subject]);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
$table->index(['ticket_id', 'subject'], 'helpdesk_comments_ticket_subject_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('helpdesk_comments', 'subject')) {
|
||||
$table->dropIndex('helpdesk_comments_ticket_subject_index');
|
||||
$table->dropColumn('subject');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
-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::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$table->timestamp('tombstoned_at')->nullable()->after('synced_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
|
||||
$table->foreignId('deleted_by')->nullable()->after('tombstoned_at')->constrained('users')->nullOnDelete();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
|
||||
$table->string('delete_reason', 255)->nullable()->after('deleted_by');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('helpdesk_comments', static function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
|
||||
$table->dropConstrainedForeignId('deleted_by');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
|
||||
$table->dropColumn('delete_reason');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add persistent timer fields to the existing helpdesk time log table.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['helpdesk_time_logs', 'time_logs'] as $tableName) {
|
||||
if (! Schema::hasTable($tableName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Schema::table($tableName, function (Blueprint $table) use ($tableName): void {
|
||||
if (! Schema::hasColumn($tableName, 'started_at')) {
|
||||
$table->timestamp('started_at')->nullable()->after('logged_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn($tableName, 'ended_at')) {
|
||||
$table->timestamp('ended_at')->nullable()->after('started_at');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn($tableName, 'duration_seconds')) {
|
||||
$table->unsignedInteger('duration_seconds')->default(0)->after('minutes');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove persistent timer fields from the existing helpdesk time log table.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
foreach (['helpdesk_time_logs', 'time_logs'] as $tableName) {
|
||||
if (! Schema::hasTable($tableName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Schema::table($tableName, function (Blueprint $table) use ($tableName): void {
|
||||
foreach (['duration_seconds', 'ended_at', 'started_at'] as $column) {
|
||||
if (Schema::hasColumn($tableName, $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
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_ticket_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('helpdesk_ticket_time_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamp('started_at');
|
||||
$table->timestamp('ended_at')->nullable();
|
||||
$table->unsignedInteger('duration_seconds')->default(0);
|
||||
$table->unsignedInteger('minutes')->default(0);
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['ticket_id', 'user_id', 'ended_at'], 'helpdesk_timer_active_idx');
|
||||
$table->index(['ticket_id', 'started_at'], 'helpdesk_timer_ticket_started_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('helpdesk_ticket_time_logs');
|
||||
}
|
||||
};
|
||||
-41
@@ -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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('helpdesk_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('helpdesk_time_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->unsignedInteger('minutes');
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamp('logged_at')->useCurrent();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['ticket_id', 'logged_at']);
|
||||
$table->index(['user_id', 'logged_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('helpdesk_time_logs');
|
||||
}
|
||||
};
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add the logged_at timestamp used by ticket time history and timer-created entries.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'logged_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void {
|
||||
$table->timestamp('logged_at')->nullable()->after('ended_at')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the logged_at timestamp if this migration is rolled back.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_ticket_time_logs', 'logged_at')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void {
|
||||
$table->dropIndex(['logged_at']);
|
||||
$table->dropColumn('logged_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add seconds precision to helpdesk ticket timer logs.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) {
|
||||
$table->unsignedInteger('seconds')->default(0)->after('minutes');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove seconds precision from helpdesk ticket timer logs.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('helpdesk_ticket_time_logs', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) {
|
||||
$table->dropColumn('seconds');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Normalise legacy Flux/Tailwind colour names to the approved WMS palette.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
foreach ($this->lookupTables() as $table) {
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'color')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->colourMap() as $legacy => $approved) {
|
||||
DB::table($table)->where('color', $legacy)->update(['color' => $approved]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The migration is intentionally one-way because approved colours are the new baseline.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// No-op: do not reintroduce legacy colour aliases.
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function lookupTables(): array
|
||||
{
|
||||
return [
|
||||
'helpdesk_statuses',
|
||||
'helpdesk_priorities',
|
||||
'helpdesk_categories',
|
||||
'helpdesk_teams',
|
||||
'helpdesk_projects',
|
||||
'helpdesk_tags',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function colourMap(): array
|
||||
{
|
||||
return [
|
||||
'emerald' => 'green',
|
||||
'lime' => 'green',
|
||||
'rose' => 'red',
|
||||
'yellow' => 'amber',
|
||||
'orange' => 'amber',
|
||||
'sky' => 'blue',
|
||||
'cyan' => 'blue',
|
||||
'violet' => 'purple',
|
||||
'slate' => 'gray',
|
||||
'zinc' => 'gray',
|
||||
];
|
||||
}
|
||||
};
|
||||
-64
@@ -1,64 +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 optional attachment metadata used by previews, source viewing and audit trails.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('helpdesk_attachments', static function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('helpdesk_attachments', 'extension')) {
|
||||
$table->string('extension', 32)->nullable()->after('original_name')->index();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_attachments', 'category')) {
|
||||
$table->string('category', 32)->nullable()->after('extension')->index();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_attachments', 'checksum_sha256')) {
|
||||
$table->string('checksum_sha256', 64)->nullable()->after('size')->index();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_attachments', 'download_count')) {
|
||||
$table->unsignedInteger('download_count')->default(0)->after('checksum_sha256');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) {
|
||||
$table->timestamp('last_downloaded_at')->nullable()->after('download_count');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
|
||||
$table->timestamp('deleted_at')->nullable()->after('last_downloaded_at')->index();
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('helpdesk_attachments', 'deleted_by')) {
|
||||
$table->foreignId('deleted_by')->nullable()->after('deleted_at')->constrained('users')->nullOnDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove attachment experience metadata.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('helpdesk_attachments', static function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('helpdesk_attachments', 'deleted_by')) {
|
||||
$table->dropConstrainedForeignId('deleted_by');
|
||||
}
|
||||
|
||||
foreach (['deleted_at', 'last_downloaded_at', 'download_count', 'checksum_sha256', 'category', 'extension'] as $column) {
|
||||
if (Schema::hasColumn('helpdesk_attachments', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,277 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
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.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->seedStatuses();
|
||||
$this->seedPriorities();
|
||||
$this->seedCategories();
|
||||
$this->seedTeamsAndProjects();
|
||||
$this->seedKanbanBoards();
|
||||
$this->seedWorkflowTemplates();
|
||||
$this->seedRedmineInspiredDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed ticket workflow statuses.
|
||||
*/
|
||||
private function seedStatuses(): void
|
||||
{
|
||||
|
||||
Status::query()->where('is_default', true)->update(['is_default' => false]);
|
||||
|
||||
foreach ([
|
||||
['name' => 'New', 'slug' => 'new', 'color' => 'blue', 'sort_order' => 5, 'is_closed' => false, 'is_default' => true],
|
||||
['name' => 'Open', 'slug' => 'open', 'color' => 'green', 'sort_order' => 10, 'is_closed' => false, 'is_default' => false],
|
||||
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'indigo', 'sort_order' => 20, 'is_closed' => false, 'is_default' => false],
|
||||
['name' => 'Waiting Customer', 'slug' => 'waiting-customer', 'color' => 'amber', 'sort_order' => 30, 'is_closed' => false, 'is_default' => false],
|
||||
['name' => 'Waiting Third Party', 'slug' => 'waiting-third-party', 'color' => 'purple', 'sort_order' => 40, 'is_closed' => false, 'is_default' => false],
|
||||
['name' => 'Resolved', 'slug' => 'resolved', 'color' => 'green', 'sort_order' => 80, 'is_closed' => true, 'is_default' => false],
|
||||
['name' => 'Closed', 'slug' => 'closed', 'color' => 'gray', 'sort_order' => 90, 'is_closed' => true, 'is_default' => false],
|
||||
['name' => 'Cancelled', 'slug' => 'cancelled', 'color' => 'red', 'sort_order' => 100, 'is_closed' => true, 'is_default' => false],
|
||||
] as $row) {
|
||||
Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed ticket priorities.
|
||||
*/
|
||||
private function seedPriorities(): void
|
||||
{
|
||||
|
||||
Priority::query()->where('is_default', true)->update(['is_default' => false]);
|
||||
foreach ([
|
||||
['name' => 'Low', 'slug' => 'low', 'color' => 'gray', 'sort_order' => 10, 'is_default' => false],
|
||||
['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue', 'sort_order' => 20, 'is_default' => true],
|
||||
['name' => 'High', 'slug' => 'high', 'color' => 'amber', 'sort_order' => 30, 'is_default' => false],
|
||||
['name' => 'Urgent', 'slug' => 'urgent', 'color' => 'red', 'sort_order' => 40, 'is_default' => false],
|
||||
['name' => 'Critical', 'slug' => 'critical', 'color' => 'red', 'sort_order' => 50, 'is_default' => false],
|
||||
] as $row) {
|
||||
Priority::query()->updateOrCreate(['slug' => $row['slug']], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed ticket categories.
|
||||
*/
|
||||
private function seedCategories(): void
|
||||
{
|
||||
Category::query()
|
||||
->whereNull('company_id')
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
foreach ([
|
||||
['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.', 'is_default' => true],
|
||||
['name' => 'Systems Deployment', 'color' => 'blue', 'description' => 'Deployment, release, infrastructure and environment requests.', 'is_default' => false],
|
||||
['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.', 'is_default' => false],
|
||||
['name' => 'Bug', 'color' => 'red', 'description' => 'Defects, regressions and incorrect behaviour.', 'is_default' => false],
|
||||
] 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.
|
||||
*/
|
||||
private function seedTeamsAndProjects(): void
|
||||
{
|
||||
|
||||
Team::query()
|
||||
->whereNull('company_id')
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
Project::query()
|
||||
->whereNull('company_id')
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
$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.',
|
||||
'is_default' => true,
|
||||
],
|
||||
);
|
||||
|
||||
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.',
|
||||
'is_default' => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the default configurable Kanban board.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_tags', 'is_default')) {
|
||||
Tag::query()
|
||||
->whereNull('company_id')
|
||||
->where('is_default', true)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
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,162 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Database\factories;
|
||||
|
||||
use Modules\Company\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
/**
|
||||
* @extends Factory<Ticket>
|
||||
*/
|
||||
final class TicketFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The model name for the factory.
|
||||
*
|
||||
* @var class-string<Ticket>
|
||||
*/
|
||||
protected $model = Ticket::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$company = Company::query()->create([
|
||||
'name' => 'Helpdesk Factory Company '.Str::random(8),
|
||||
'slug' => 'helpdesk-factory-company-'.Str::lower(Str::random(8)),
|
||||
'status' => 'active',
|
||||
'timezone' => 'Europe/London',
|
||||
'billing_email' => 'helpdesk-factory-'.Str::lower(Str::random(8)).'@example.test',
|
||||
'is_system_company' => false,
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'company_id' => $company->getKey(),
|
||||
]);
|
||||
|
||||
return $this->ticketPayload($company, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ticket payload using only columns that exist in the current schema.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function ticketPayload(Company $company, User $user): array
|
||||
{
|
||||
$ticket = new Ticket();
|
||||
$table = $ticket->getTable();
|
||||
|
||||
$data = [];
|
||||
|
||||
$put = static function (string $column, mixed $value) use (&$data, $table): void {
|
||||
if (Schema::hasColumn($table, $column)) {
|
||||
$data[$column] = $value;
|
||||
}
|
||||
};
|
||||
|
||||
$put('company_id', $company->getKey());
|
||||
$put('user_id', $user->getKey());
|
||||
$put('owner_id', $user->getKey());
|
||||
$put('created_by_id', $user->getKey());
|
||||
$put('assigned_to_id', $user->getKey());
|
||||
$put('agent_id', $user->getKey());
|
||||
|
||||
$put('reference', 'HD-'.now()->format('Y').'-'.str_pad((string) $this->faker->unique()->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT));
|
||||
$put('subject', 'Factory helpdesk ticket');
|
||||
$put('title', 'Factory helpdesk ticket');
|
||||
$put('body', '<p>Factory helpdesk ticket body.</p>');
|
||||
$put('details', '<p>Factory helpdesk ticket body.</p>');
|
||||
$put('description', '<p>Factory helpdesk ticket body.</p>');
|
||||
$put('search_text', 'factory helpdesk ticket body');
|
||||
$put('last_activity_at', now());
|
||||
|
||||
$statusId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\TicketStatus',
|
||||
['name' => 'Open'],
|
||||
['slug' => 'open', 'color' => 'blue', 'is_closed' => false, 'sort_order' => 10]
|
||||
);
|
||||
|
||||
$priorityId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\TicketPriority',
|
||||
['name' => 'Normal'],
|
||||
['slug' => 'normal', 'color' => 'blue', 'sort_order' => 20]
|
||||
);
|
||||
|
||||
$categoryId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\TicketCategory',
|
||||
['name' => 'General'],
|
||||
['slug' => 'general', 'color' => 'blue', 'sort_order' => 10]
|
||||
);
|
||||
|
||||
$teamId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\HelpdeskTeam',
|
||||
['name' => 'Support'],
|
||||
['slug' => 'support', 'color' => 'blue', 'sort_order' => 10]
|
||||
);
|
||||
|
||||
$projectId = $this->lookupId(
|
||||
'Modules\\Helpdesk\\Models\\HelpdeskProject',
|
||||
['name' => 'General'],
|
||||
['slug' => 'general', 'color' => 'blue', 'sort_order' => 10]
|
||||
);
|
||||
|
||||
$put('status_id', $statusId);
|
||||
$put('ticket_status_id', $statusId);
|
||||
$put('priority_id', $priorityId);
|
||||
$put('ticket_priority_id', $priorityId);
|
||||
$put('category_id', $categoryId);
|
||||
$put('ticket_category_id', $categoryId);
|
||||
$put('team_id', $teamId);
|
||||
$put('helpdesk_team_id', $teamId);
|
||||
$put('project_id', $projectId);
|
||||
$put('helpdesk_project_id', $projectId);
|
||||
|
||||
$unid = strtoupper(str_replace('-', '', (string) Str::uuid()));
|
||||
$put('guid', (string) Str::uuid());
|
||||
$put('uuid', (string) Str::uuid());
|
||||
$put('unid', $unid);
|
||||
$put('root_unid', $unid);
|
||||
$put('sync_sequence', 1);
|
||||
$put('source_system', 'phpunit');
|
||||
|
||||
return array_filter($data, static fn (mixed $value): bool => $value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve or create a lookup row when the model exists.
|
||||
*/
|
||||
private function lookupId(string $class, array $identity, array $attributes): ?int
|
||||
{
|
||||
if (! class_exists($class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var class-string<\Illuminate\Database\Eloquent\Model> $class */
|
||||
$model = new $class();
|
||||
|
||||
if (! Schema::hasTable($model->getTable())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = array_merge($identity, $attributes);
|
||||
|
||||
foreach (['slug', 'color', 'sort_order', 'is_closed'] as $column) {
|
||||
if (array_key_exists($column, $payload) && ! Schema::hasColumn($model->getTable(), $column)) {
|
||||
unset($payload[$column]);
|
||||
}
|
||||
}
|
||||
|
||||
$row = $class::query()->firstOrCreate($identity, $payload);
|
||||
|
||||
return (int) $row->getKey();
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
# Helpdesk Git Integration Guide
|
||||
|
||||
## Gitea & GitHub Integration, Ticket Linking, Development Workflow, and Deployment Standards
|
||||
|
||||
Version: 1.0
|
||||
Module: Helpdesk
|
||||
Platform: WMS Core
|
||||
Last Updated: June 2026
|
||||
|
||||
## Overview
|
||||
|
||||
The Helpdesk Git Integration allows tickets to be linked directly to commits, branches, pull requests, merges, repository activity, and deployments.
|
||||
|
||||
### Ticket Reference Format
|
||||
|
||||
```text
|
||||
HD-YYYY-NNNNN
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
HD-2026-00012
|
||||
```
|
||||
|
||||
## Automatic Linking Rules
|
||||
|
||||
The integration searches for ticket references in:
|
||||
|
||||
- Commit messages
|
||||
- Branch names
|
||||
- Pull request titles
|
||||
- Merge commits
|
||||
|
||||
### Example Commit
|
||||
|
||||
```text
|
||||
HD-2026-00012 Fix ticket-level Git commit display
|
||||
```
|
||||
|
||||
### Example Branch
|
||||
|
||||
```text
|
||||
bugfix/HD-2026-00012-fix-ticket-display
|
||||
```
|
||||
|
||||
## GitFlow Branch Standards
|
||||
|
||||
### Feature
|
||||
|
||||
```text
|
||||
feature/HD-2026-00012-add-sla-dashboard
|
||||
```
|
||||
|
||||
### Bugfix
|
||||
|
||||
```text
|
||||
bugfix/HD-2026-00012-fix-ticket-display
|
||||
```
|
||||
|
||||
### Hotfix
|
||||
|
||||
```text
|
||||
hotfix/HD-2026-00012-production-fix
|
||||
```
|
||||
|
||||
## Commit Standards
|
||||
|
||||
Always place the ticket reference first.
|
||||
|
||||
```text
|
||||
HD-2026-00012 Fix ticket-level Git commit display
|
||||
```
|
||||
|
||||
## GitHub Setup
|
||||
|
||||
### Repository
|
||||
|
||||
```bash
|
||||
git init
|
||||
git remote add origin https://github.com/company/repository.git
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### Webhook
|
||||
|
||||
Settings → Webhooks → Add Webhook
|
||||
|
||||
Webhook URL:
|
||||
|
||||
```text
|
||||
https://your-domain/helpdesk/git/github/webhook
|
||||
```
|
||||
|
||||
Events:
|
||||
|
||||
- Push
|
||||
- Pull Request
|
||||
- Pull Request Review
|
||||
- Release
|
||||
|
||||
## Gitea Setup
|
||||
|
||||
### Repository
|
||||
|
||||
```bash
|
||||
git init
|
||||
git remote add origin http://git.vm:3000/Admin/WMS-Core.git
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### Webhook
|
||||
|
||||
Settings → Webhooks → Add Webhook
|
||||
|
||||
Webhook URL:
|
||||
|
||||
```text
|
||||
https://your-domain/helpdesk/git/gitea/webhook
|
||||
```
|
||||
|
||||
Events:
|
||||
|
||||
- Push
|
||||
- Pull Request
|
||||
- Merge
|
||||
- Tags
|
||||
|
||||
## Standard Workflow
|
||||
|
||||
### Create Ticket
|
||||
|
||||
```text
|
||||
HD-2026-00012
|
||||
```
|
||||
|
||||
### Create Branch
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git checkout -b bugfix/HD-2026-00012-fix-ticket-display
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
composer test
|
||||
```
|
||||
|
||||
### Commit
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "HD-2026-00012 Fix ticket-level Git commit display"
|
||||
```
|
||||
|
||||
### Push
|
||||
|
||||
```bash
|
||||
git push origin bugfix/HD-2026-00012-fix-ticket-display
|
||||
```
|
||||
|
||||
### Pull Request
|
||||
|
||||
```text
|
||||
HD-2026-00012 Fix ticket-level Git commit display
|
||||
```
|
||||
|
||||
## Manual Linking
|
||||
|
||||
```php
|
||||
Modules\Helpdesk\Models\GitCommit::find(7)->update([
|
||||
'ticket_id' => 18,
|
||||
]);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Verify Commits
|
||||
|
||||
```bash
|
||||
php artisan tinker
|
||||
```
|
||||
|
||||
```php
|
||||
Modules\Helpdesk\Models\GitCommit::count();
|
||||
```
|
||||
|
||||
### Ticket Shows No Commits
|
||||
|
||||
```php
|
||||
Modules\Helpdesk\Models\GitCommit
|
||||
::where('ticket_id', 18)
|
||||
->get();
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```php
|
||||
Modules\Helpdesk\Models\GitCommit
|
||||
::where('message', 'like', '%HD-2026-00012%')
|
||||
->get();
|
||||
```
|
||||
|
||||
## Recommended Standard
|
||||
|
||||
Every code change should contain:
|
||||
|
||||
- Ticket reference in branch name
|
||||
- Ticket reference in commit message
|
||||
- Ticket reference in PR title
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Branch:
|
||||
bugfix/HD-2026-00012-fix-ticket-display
|
||||
|
||||
Commit:
|
||||
HD-2026-00012 Fix ticket-level Git commit display
|
||||
|
||||
PR:
|
||||
HD-2026-00012 Fix ticket-level Git commit display
|
||||
```
|
||||
|
||||
@@ -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,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Helpdesk\Models\Attachment;
|
||||
use Modules\Helpdesk\Services\AttachmentService;
|
||||
|
||||
final class AttachmentDeleteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Soft-delete a Helpdesk attachment after enforcing ticket update access.
|
||||
*/
|
||||
public function __invoke(Attachment $attachment, AttachmentService $attachments): RedirectResponse
|
||||
{
|
||||
$ticket = $attachment->ticket;
|
||||
abort_if($ticket === null, 404);
|
||||
|
||||
Gate::authorize('update', $ticket);
|
||||
|
||||
if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
|
||||
$attachments->softDelete($attachment, (int) auth()->id());
|
||||
} else {
|
||||
$attachments->delete($attachment);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-attachments-deleted', 'Attachment removed.');
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class AttachmentDownloadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Download a ticket attachment through Laravel storage instead of a public URL.
|
||||
*
|
||||
* This avoids broken public storage links and keeps ticket authorization enforced.
|
||||
*
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
public function __invoke(int $attachment): StreamedResponse|RedirectResponse
|
||||
{
|
||||
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
|
||||
|
||||
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
|
||||
|
||||
abort_if($row === null, 404);
|
||||
|
||||
$ticketId = $row->ticket_id ?? null;
|
||||
|
||||
if ($ticketId !== null) {
|
||||
$ticket = Ticket::query()->findOrFail((int) $ticketId);
|
||||
Gate::authorize('view', $ticket);
|
||||
}
|
||||
|
||||
$disk = (string) ($row->disk ?? 'public');
|
||||
$path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? '');
|
||||
$name = (string) ($row->original_name
|
||||
?? $row->original_filename
|
||||
?? $row->file_name
|
||||
?? $row->filename
|
||||
?? basename($path));
|
||||
|
||||
abort_if($path === '', 404);
|
||||
abort_unless(Storage::disk($disk)->exists($path), 404);
|
||||
|
||||
if (Schema::hasColumn('helpdesk_attachments', 'download_count') && Schema::hasColumn('helpdesk_attachments', 'last_downloaded_at')) {
|
||||
DB::table('helpdesk_attachments')
|
||||
->where('id', $attachment)
|
||||
->update([
|
||||
'download_count' => DB::raw('download_count + 1'),
|
||||
'last_downloaded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->download($path, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a safe Helpdesk attachment using this existing controller so optimized
|
||||
* Composer autoload does not need a newly introduced route action class.
|
||||
*
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
public function preview(int $attachment): View|StreamedResponse|RedirectResponse
|
||||
{
|
||||
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
|
||||
|
||||
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
|
||||
|
||||
abort_if($row === null, 404);
|
||||
|
||||
$ticketId = $row->ticket_id ?? null;
|
||||
|
||||
if ($ticketId !== null) {
|
||||
$ticket = Ticket::query()->findOrFail((int) $ticketId);
|
||||
Gate::authorize('view', $ticket);
|
||||
}
|
||||
|
||||
$disk = (string) ($row->disk ?? 'public');
|
||||
$path = (string) ($row->path ?? $row->file_path ?? $row->stored_path ?? '');
|
||||
$name = (string) ($row->original_name
|
||||
?? $row->original_filename
|
||||
?? $row->file_name
|
||||
?? $row->filename
|
||||
?? basename($path));
|
||||
$mimeType = (string) ($row->mime_type ?? 'application/octet-stream');
|
||||
$extension = strtolower((string) (($row->extension ?? null) ?: pathinfo($name, PATHINFO_EXTENSION)));
|
||||
|
||||
abort_if($path === '', 404);
|
||||
abort_unless(Storage::disk($disk)->exists($path), 404);
|
||||
|
||||
if ($this->isSourceExtension($extension)) {
|
||||
$contents = Storage::disk($disk)->get($path);
|
||||
|
||||
return view('helpdesk::attachments.source-preview', [
|
||||
'attachment' => $row,
|
||||
'contents' => is_string($contents) ? $contents : '',
|
||||
'language' => $this->languageForExtension($extension),
|
||||
]);
|
||||
}
|
||||
|
||||
abort_unless($this->isInlinePreviewable($extension, $mimeType), 404);
|
||||
|
||||
return Storage::disk($disk)->response($path, $name, [
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes($name) . '"',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete or remove an attachment using this existing controller class.
|
||||
*/
|
||||
public function destroy(int $attachment): RedirectResponse
|
||||
{
|
||||
abort_unless(Schema::hasTable('helpdesk_attachments'), 404);
|
||||
|
||||
$row = DB::table('helpdesk_attachments')->where('id', $attachment)->first();
|
||||
|
||||
abort_if($row === null, 404);
|
||||
|
||||
$ticketId = $row->ticket_id ?? null;
|
||||
abort_if($ticketId === null, 404);
|
||||
|
||||
$ticket = Ticket::query()->findOrFail((int) $ticketId);
|
||||
Gate::authorize('update', $ticket);
|
||||
|
||||
if (Schema::hasColumn('helpdesk_attachments', 'deleted_at')) {
|
||||
DB::table('helpdesk_attachments')
|
||||
->where('id', $attachment)
|
||||
->update(array_filter([
|
||||
'deleted_at' => now(),
|
||||
'deleted_by' => Schema::hasColumn('helpdesk_attachments', 'deleted_by') ? (int) auth()->id() : null,
|
||||
], static fn (mixed $value): bool => $value !== null));
|
||||
} else {
|
||||
Storage::disk((string) ($row->disk ?? 'public'))->delete((string) ($row->path ?? ''));
|
||||
DB::table('helpdesk_attachments')->where('id', $attachment)->delete();
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-attachments-deleted', 'Attachment removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a file can be safely streamed inline by extension or MIME type.
|
||||
*/
|
||||
private function isInlinePreviewable(string $extension, string $mimeType): bool
|
||||
{
|
||||
return in_array($extension, ['png', 'jpg', 'jpeg', 'webp', 'gif', 'pdf'], true)
|
||||
|| str_starts_with($mimeType, 'image/')
|
||||
|| $mimeType === 'application/pdf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a source/configuration file can use the read-only viewer.
|
||||
*/
|
||||
private function isSourceExtension(string $extension): bool
|
||||
{
|
||||
return in_array($extension, config('helpdesk.allowed_source_extensions', [
|
||||
'php', 'phtml', 'phps', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
|
||||
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
|
||||
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md',
|
||||
]), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the source preview language key for display/highlighting.
|
||||
*/
|
||||
private function languageForExtension(string $extension): string
|
||||
{
|
||||
return match ($extension) {
|
||||
'php', 'phtml', 'phps' => 'php',
|
||||
'js', 'jsx' => 'javascript',
|
||||
'ts', 'tsx' => 'typescript',
|
||||
'vue' => 'vue',
|
||||
'css', 'scss', 'sass', 'less' => 'css',
|
||||
'html', 'htm' => 'html',
|
||||
'xml' => 'xml',
|
||||
'json' => 'json',
|
||||
'yaml', 'yml' => 'yaml',
|
||||
'sql' => 'sql',
|
||||
'sh', 'bash', 'zsh' => 'shell',
|
||||
'ps1' => 'powershell',
|
||||
'py' => 'python',
|
||||
'java', 'kt', 'kts' => 'java',
|
||||
'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like',
|
||||
'rpg', 'rpgle' => 'rpgle',
|
||||
'cl', 'clle' => 'clle',
|
||||
'cbl', 'cob', 'copybook' => 'cobol',
|
||||
default => 'text',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Helpdesk\Models\Attachment;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class AttachmentPreviewController extends Controller
|
||||
{
|
||||
/**
|
||||
* Preview a safe Helpdesk attachment after enforcing ticket access.
|
||||
*
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
public function __invoke(Attachment $attachment): View|StreamedResponse|RedirectResponse
|
||||
{
|
||||
$ticket = $attachment->ticket;
|
||||
|
||||
if ($ticket !== null) {
|
||||
Gate::authorize('view', $ticket);
|
||||
}
|
||||
|
||||
abort_unless(Storage::disk($attachment->disk)->exists($attachment->path), 404);
|
||||
|
||||
if ($attachment->isSourceCode()) {
|
||||
$contents = Storage::disk($attachment->disk)->get($attachment->path);
|
||||
|
||||
return view('helpdesk::attachments.source-preview', [
|
||||
'attachment' => $attachment,
|
||||
'contents' => is_string($contents) ? $contents : '',
|
||||
'language' => $attachment->language(),
|
||||
]);
|
||||
}
|
||||
|
||||
abort_unless($attachment->isPreviewableBinary(), 404);
|
||||
|
||||
return Storage::disk($attachment->disk)->response($attachment->path, $attachment->original_name, [
|
||||
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest the local Gitea webhook URL that does not include a repository id.
|
||||
*/
|
||||
public function gitea(Request $request, GitWebhookIngestionService $service): JsonResponse
|
||||
{
|
||||
$provider = 'gitea';
|
||||
$event = $this->eventName($request, $provider);
|
||||
$deliveryId = $this->deliveryId($request, $provider);
|
||||
$rawPayload = $request->getContent();
|
||||
|
||||
try {
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($rawPayload, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
return response()->json([
|
||||
'status' => 'rejected',
|
||||
'message' => 'Invalid JSON webhook payload.',
|
||||
'commit_count' => 0,
|
||||
'linked_count' => 0,
|
||||
'ignored_count' => 1,
|
||||
], 400);
|
||||
}
|
||||
|
||||
$repository = $this->repositoryForGiteaPayload($payload);
|
||||
|
||||
if (! $repository instanceof GitRepository) {
|
||||
return response()->json([
|
||||
'status' => 'rejected',
|
||||
'message' => 'No active Helpdesk Git repository matched the Gitea webhook payload.',
|
||||
'commit_count' => 0,
|
||||
'linked_count' => 0,
|
||||
'ignored_count' => 1,
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (! $this->hasValidGiteaSecurity($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, $payload);
|
||||
|
||||
return response()->json($result, 403);
|
||||
}
|
||||
|
||||
$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 = $this->secretForProvider($repository, $provider);
|
||||
|
||||
if ($secret === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($provider) {
|
||||
'github' => $this->hasValidGitHubSignature($request, $secret, $rawPayload),
|
||||
'gitlab' => hash_equals($secret, (string) $request->header('X-Gitlab-Token', '')),
|
||||
'gitea' => $this->hasValidGiteaSignature($request, $secret, $rawPayload),
|
||||
'bitbucket' => $this->hasValidBitbucketSignature($request, $secret, $rawPayload),
|
||||
default => $this->hasValidGenericSignature($request, $secret, $rawPayload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the repository webhook secret, with local Gitea env fallback support.
|
||||
*/
|
||||
private function secretForProvider(GitRepository $repository, string $provider): string
|
||||
{
|
||||
$secret = trim((string) $repository->webhook_secret);
|
||||
|
||||
if ($secret !== '') {
|
||||
return $secret;
|
||||
}
|
||||
|
||||
return $provider === 'gitea' ? trim((string) config('services.gitea.webhook_secret', '')) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GitHub's current SHA-256 signature and legacy SHA-1 signature.
|
||||
*/
|
||||
private function hasValidGitHubSignature(Request $request, string $secret, string $rawPayload): bool
|
||||
{
|
||||
$sha256Signature = (string) $request->header('X-Hub-Signature-256', '');
|
||||
$sha1Signature = (string) $request->header('X-Hub-Signature', '');
|
||||
|
||||
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $sha256Signature)
|
||||
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $sha1Signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Gitea's raw SHA-256 signature and GitHub-compatible HMAC headers.
|
||||
*/
|
||||
private function hasValidGiteaSignature(Request $request, string $secret, string $rawPayload): bool
|
||||
{
|
||||
$giteaSignature = (string) $request->header('X-Gitea-Signature', '');
|
||||
|
||||
$sha256 = hash_hmac('sha256', $rawPayload, $secret);
|
||||
$sha1 = hash_hmac('sha1', $rawPayload, $secret);
|
||||
|
||||
return hash_equals($sha256, $giteaSignature)
|
||||
|| hash_equals('sha256=' . $sha256, $giteaSignature)
|
||||
|| hash_equals('sha1=' . $sha1, $giteaSignature)
|
||||
|| $this->hasValidGitHubSignature($request, $secret, $rawPayload)
|
||||
|| $this->hasValidGenericSignature($request, $secret, $rawPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Bitbucket's webhook HMAC signature.
|
||||
*/
|
||||
private function hasValidBitbucketSignature(Request $request, string $secret, string $rawPayload): bool
|
||||
{
|
||||
$signature = (string) $request->header('X-Hub-Signature', '');
|
||||
|
||||
return hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals($secret, (string) $request->header('X-Helpdesk-Token', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a generic Git webhook using either a token header or HMAC signature.
|
||||
*/
|
||||
private function hasValidGenericSignature(Request $request, string $secret, string $rawPayload): bool
|
||||
{
|
||||
$token = (string) ($request->header('X-Helpdesk-Token') ?: $request->header('X-Gitlab-Token') ?: $request->header('X-Gitea-Token'));
|
||||
$signature = (string) ($request->header('X-Hub-Signature-256') ?: $request->header('X-Hub-Signature'));
|
||||
|
||||
return hash_equals($secret, $token)
|
||||
|| hash_equals('sha256=' . hash_hmac('sha256', $rawPayload, $secret), $signature)
|
||||
|| hash_equals('sha1=' . hash_hmac('sha1', $rawPayload, $secret), $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Gitea webhook security without allowing an optional Authorization
|
||||
* header mismatch to hide otherwise valid signed push deliveries.
|
||||
*/
|
||||
private function hasValidGiteaSecurity(Request $request, GitRepository $repository, string $provider, string $rawPayload): bool
|
||||
{
|
||||
if ($this->hasValidSignature($request, $repository, $provider, $rawPayload)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->hasValidGiteaAuthorization($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the optional Authorization header configured for the local Gitea webhook.
|
||||
*/
|
||||
private function hasValidGiteaAuthorization(Request $request): bool
|
||||
{
|
||||
$expected = trim((string) config('services.gitea.webhook_authorization', ''));
|
||||
|
||||
if ($expected === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$actual = trim((string) $request->header('Authorization', ''));
|
||||
|
||||
return hash_equals($expected, $actual)
|
||||
|| hash_equals('Basic ' . $expected, $actual)
|
||||
|| (Str::startsWith($expected, 'Basic ') && hash_equals($expected, $actual));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a Gitea webhook payload to the configured Helpdesk repository.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function repositoryForGiteaPayload(array $payload): ?GitRepository
|
||||
{
|
||||
$repositories = GitRepository::query()
|
||||
->where('is_active', true)
|
||||
->whereRaw('LOWER(provider) IN (?, ?)', ['gitea', 'git'])
|
||||
->get();
|
||||
|
||||
if ($repositories->isEmpty()) {
|
||||
Log::warning('Helpdesk Gitea webhook received but no active Gitea/git repository is configured.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$payloadValues = array_values(array_filter([
|
||||
Arr::get($payload, 'repository.full_name'),
|
||||
Arr::get($payload, 'repository.name'),
|
||||
Arr::get($payload, 'repository.html_url'),
|
||||
Arr::get($payload, 'repository.clone_url'),
|
||||
Arr::get($payload, 'repository.ssh_url'),
|
||||
Arr::get($payload, 'repository.url'),
|
||||
Arr::get($payload, 'repository.owner.username') && Arr::get($payload, 'repository.name')
|
||||
? Arr::get($payload, 'repository.owner.username') . '/' . Arr::get($payload, 'repository.name')
|
||||
: null,
|
||||
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== ''));
|
||||
|
||||
$normalisedPayloadValues = array_values(array_filter(array_map(
|
||||
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
|
||||
$payloadValues,
|
||||
)));
|
||||
|
||||
foreach ($repositories as $repository) {
|
||||
$repositoryValues = array_filter([
|
||||
$repository->remote_url,
|
||||
$repository->name,
|
||||
Str::of((string) $repository->remote_url)->afterLast(':')->beforeLast('.git')->value(),
|
||||
], static fn (mixed $value): bool => is_scalar($value) && trim((string) $value) !== '');
|
||||
|
||||
$normalisedRepositoryValues = array_values(array_filter(array_map(
|
||||
fn (mixed $value): ?string => $this->normaliseRepositoryIdentity((string) $value),
|
||||
$repositoryValues,
|
||||
)));
|
||||
|
||||
foreach ($normalisedPayloadValues as $payloadValue) {
|
||||
foreach ($normalisedRepositoryValues as $repositoryValue) {
|
||||
if ($payloadValue === $repositoryValue
|
||||
|| str_ends_with($repositoryValue, '/' . $payloadValue)
|
||||
|| str_ends_with($payloadValue, '/' . $repositoryValue)) {
|
||||
return $repository;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($repositories->count() === 1) {
|
||||
return $repositories->first();
|
||||
}
|
||||
|
||||
Log::warning('Helpdesk Gitea webhook did not match any configured repository.', [
|
||||
'payload_repository' => Arr::get($payload, 'repository.full_name') ?: Arr::get($payload, 'repository.clone_url') ?: Arr::get($payload, 'repository.html_url'),
|
||||
'configured_repository_count' => $repositories->count(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a repository name or URL so Gitea payloads match local settings reliably.
|
||||
*/
|
||||
private function normaliseRepositoryIdentity(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = Str::of($value)
|
||||
->replace('\\', '/')
|
||||
->replace('ssh://git@', 'ssh://')
|
||||
->replace('git@', '')
|
||||
->replace('.git', '')
|
||||
->lower()
|
||||
->value();
|
||||
|
||||
$path = parse_url($value, PHP_URL_PATH);
|
||||
|
||||
if (is_string($path) && trim($path, '/') !== '') {
|
||||
$value = trim($path, '/');
|
||||
} elseif (str_contains($value, ':')) {
|
||||
$value = Str::of($value)->afterLast(':')->trim('/')->value();
|
||||
}
|
||||
|
||||
$segments = array_values(array_filter(explode('/', trim($value, '/'))));
|
||||
|
||||
if ($segments === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('/', array_slice($segments, -2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'),
|
||||
'gitea' => $request->header('X-Gitea-Event') ?: $request->header('X-Gitea-Event-Type') ?: $request->header('X-GitHub-Event'),
|
||||
'bitbucket' => $request->header('X-Event-Key'),
|
||||
default => $request->header('X-Helpdesk-Event') ?: $request->header('X-GitHub-Event') ?: $request->header('X-Gitlab-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'),
|
||||
'gitea' => $request->header('X-Gitea-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
|
||||
'bitbucket' => $request->header('X-Request-UUID') ?: $request->header('X-Request-Id'),
|
||||
default => $request->header('X-Helpdesk-Delivery') ?: $request->header('X-GitHub-Delivery') ?: $request->header('X-Request-Id'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,75 +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\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\AttachmentService;
|
||||
|
||||
final class TicketAttachmentStoreController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store one or more supporting files directly against an existing ticket.
|
||||
*/
|
||||
public function __invoke(Request $request, Ticket $ticket, AttachmentService $attachments): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $ticket);
|
||||
|
||||
$request->validate([
|
||||
'attachments' => ['required', 'array', 'min:1'],
|
||||
'attachments.*' => $this->attachmentFileRules(required: true),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
abort_if($user === null, 403);
|
||||
|
||||
$uploadedFiles = $request->file('attachments', []);
|
||||
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
|
||||
$uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof UploadedFile && $file->isValid()));
|
||||
|
||||
$attachments->storeMany($ticket, $uploadedFiles, (int) $user->id);
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-attachments-added', count($uploadedFiles) . ' supporting file' . (count($uploadedFiles) === 1 ? '' : 's') . ' uploaded.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* @param bool $required Whether at least one uploaded file is required.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(bool $required = false): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
$required ? 'required' : null,
|
||||
'file',
|
||||
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,105 +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\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||
|
||||
final class TicketBoardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $ticket);
|
||||
|
||||
$validated = $request->validate([
|
||||
'board_id' => ['required', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
|
||||
'return_to_board_id' => ['nullable', 'integer', Rule::exists('helpdesk_kanban_boards', 'id')],
|
||||
]);
|
||||
|
||||
$targetBoardId = (int) $validated['board_id'];
|
||||
|
||||
$targetBoard = KanbanBoard::query()
|
||||
->with(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')])
|
||||
->findOrFail($targetBoardId);
|
||||
|
||||
if ($targetBoard->statuses->isEmpty()) {
|
||||
$this->ensureBoardHasStatuses($targetBoardId);
|
||||
$targetBoard->load(['statuses' => fn ($query) => $query->orderBy('helpdesk_kanban_board_statuses.sort_order')->orderBy('helpdesk_statuses.sort_order')->orderBy('helpdesk_statuses.name')]);
|
||||
}
|
||||
|
||||
$fresh = DB::transaction(function () use ($ticket, $targetBoard): Ticket {
|
||||
$lockedTicket = Ticket::query()
|
||||
->visibleTo(Auth::user())
|
||||
->lockForUpdate()
|
||||
->findOrFail($ticket->id);
|
||||
|
||||
$targetStatus = $targetBoard->statuses->firstWhere('id', (int) $lockedTicket->status_id)
|
||||
?: $targetBoard->statuses->first();
|
||||
|
||||
if ($targetStatus === null) {
|
||||
$targetStatus = Status::query()->orderBy('sort_order')->orderBy('name')->firstOrFail();
|
||||
$this->addStatusToBoard($targetBoard->id, $targetStatus->id);
|
||||
}
|
||||
|
||||
$updates = [
|
||||
'board_id' => $targetBoard->id,
|
||||
'status_id' => $targetStatus->id,
|
||||
'completed_at' => $targetStatus->is_closed ? now() : null,
|
||||
'last_activity_at' => now(),
|
||||
];
|
||||
|
||||
if (DB::getSchemaBuilder()->hasColumn('helpdesk_tickets', 'updated_at')) {
|
||||
$updates['updated_at'] = now();
|
||||
}
|
||||
|
||||
$affected = DB::table('helpdesk_tickets')
|
||||
->where('id', $lockedTicket->id)
|
||||
->update($updates);
|
||||
|
||||
if ($affected < 1) {
|
||||
throw new \RuntimeException('Ticket board update did not persist.');
|
||||
}
|
||||
|
||||
return Ticket::query()
|
||||
->with(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
||||
->findOrFail($lockedTicket->id);
|
||||
});
|
||||
|
||||
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.kanban', ['board_id' => $targetBoardId])
|
||||
->with('success', 'Ticket ' . $fresh->reference . ' moved to ' . $targetBoard->name . '.');
|
||||
}
|
||||
|
||||
private function ensureBoardHasStatuses(int $boardId): void
|
||||
{
|
||||
Status::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id'])
|
||||
->values()
|
||||
->each(function (Status $status, int $index) use ($boardId): void {
|
||||
$this->addStatusToBoard($boardId, (int) $status->id, ($index + 1) * 10);
|
||||
});
|
||||
}
|
||||
|
||||
private function addStatusToBoard(int $boardId, int $statusId, int $sortOrder = 999): void
|
||||
{
|
||||
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||
['board_id' => $boardId, 'status_id' => $statusId],
|
||||
['sort_order' => $sortOrder, 'updated_at' => now(), 'created_at' => now()],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +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\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Helpdesk\Models\Comment;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
final class TicketCommentDeleteController extends Controller
|
||||
{
|
||||
/**
|
||||
* Tombstone a ticket response. Only MPBA/platform super administrators may delete responses.
|
||||
*/
|
||||
public function __invoke(Request $request, Ticket $ticket, Comment $comment): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless($user !== null && method_exists($user, 'isMpbaPlatformAdmin') && $user->isMpbaPlatformAdmin(), 403);
|
||||
abort_unless((int) $comment->ticket_id === (int) $ticket->id, 404);
|
||||
|
||||
DB::transaction(function () use ($comment, $ticket, $user): void {
|
||||
$updates = [];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$updates['tombstoned_at'] = now();
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'deleted_by')) {
|
||||
$updates['deleted_by'] = (int) $user->id;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'delete_reason')) {
|
||||
$updates['delete_reason'] = 'Deleted by MPBA platform administrator.';
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'sync_sequence')) {
|
||||
$updates['sync_sequence'] = ((int) ($comment->sync_sequence ?? 0)) + 1;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'sync_hash')) {
|
||||
$updates['sync_hash'] = hash('sha256', implode('|', [
|
||||
(string) ($comment->guid ?? $comment->id),
|
||||
'deleted',
|
||||
now()->toISOString(),
|
||||
]));
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'updated_at')) {
|
||||
$updates['updated_at'] = now();
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
DB::table('helpdesk_comments')
|
||||
->where('id', $comment->id)
|
||||
->update($updates);
|
||||
} else {
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_tickets', 'last_activity_at')) {
|
||||
$ticket->forceFill(['last_activity_at' => now()])->save();
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-comment-deleted', 'Response deleted.');
|
||||
}
|
||||
}
|
||||
@@ -1,200 +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\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Helpdesk\Models\CannedResponse;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\CannedResponseRenderer;
|
||||
use Modules\Helpdesk\Services\TicketService;
|
||||
|
||||
final class TicketCommentStoreController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a ticket conversation comment or nested reply using a normal HTTP POST.
|
||||
*
|
||||
* This keeps the conversation workflow reliable when rich-editor, Flux, or
|
||||
* Livewire JavaScript event handling does not invoke the Livewire method.
|
||||
*/
|
||||
public function __invoke(Request $request, Ticket $ticket, TicketService $tickets, CannedResponseRenderer $renderer): RedirectResponse
|
||||
{
|
||||
Gate::authorize('comment', $ticket);
|
||||
|
||||
$validated = $request->validate([
|
||||
'subject' => ['nullable', 'string', 'max:180'],
|
||||
'commentBody' => ['nullable', 'string'],
|
||||
'internal' => ['nullable', 'boolean'],
|
||||
'parent_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('helpdesk_comments', 'id')->where('ticket_id', $ticket->id),
|
||||
],
|
||||
'canned_response_id' => ['nullable', 'integer', Rule::exists('helpdesk_canned_responses', 'id')],
|
||||
'attachments' => ['nullable', 'array'],
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
abort_if($user === null, 403);
|
||||
|
||||
$body = trim((string) ($validated['commentBody'] ?? ''));
|
||||
$subject = trim((string) ($validated['subject'] ?? ''));
|
||||
|
||||
if (filled($validated['canned_response_id'] ?? null)) {
|
||||
$response = CannedResponse::query()
|
||||
->where('is_active', true)
|
||||
->findOrFail((int) $validated['canned_response_id']);
|
||||
|
||||
if ($body === '') {
|
||||
$body = trim($renderer->render($response->body, $ticket, $user));
|
||||
}
|
||||
|
||||
if ($subject === '') {
|
||||
$subject = (string) $response->name;
|
||||
}
|
||||
}
|
||||
|
||||
$body = $this->normaliseHtmlBody($body);
|
||||
$subject = $this->normaliseSubject($subject, $body, filled($validated['parent_id'] ?? null));
|
||||
|
||||
validator(['commentBody' => $body], [
|
||||
'commentBody' => ['required', 'string', 'min:2'],
|
||||
])->validate();
|
||||
|
||||
$uploadedFiles = $request->file('attachments', []);
|
||||
$uploadedFiles = is_array($uploadedFiles) ? $uploadedFiles : [$uploadedFiles];
|
||||
$uploadedFiles = array_values(array_filter($uploadedFiles, static fn ($file): bool => $file instanceof UploadedFile && $file->isValid()));
|
||||
|
||||
$tickets->comment(
|
||||
ticket: $ticket,
|
||||
user: $user,
|
||||
body: $body,
|
||||
internal: (bool) ($validated['internal'] ?? false) && $this->userCanCreateInternalNote($user),
|
||||
files: $uploadedFiles,
|
||||
parentId: filled($validated['parent_id'] ?? null) ? (int) $validated['parent_id'] : null,
|
||||
subject: $subject,
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-comment-added', filled($validated['parent_id'] ?? null) ? 'Reply posted.' : 'Comment posted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a user-friendly Lotus Notes style response subject.
|
||||
*/
|
||||
private function normaliseSubject(string $subject, string $body, bool $isReply): string
|
||||
{
|
||||
$subject = trim(strip_tags(html_entity_decode($subject, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
||||
|
||||
if ($subject !== '') {
|
||||
return Str::limit($subject, 180, '');
|
||||
}
|
||||
|
||||
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?: '');
|
||||
|
||||
if ($plain === '') {
|
||||
return $isReply ? 'RE: Response' : 'Response';
|
||||
}
|
||||
|
||||
return ($isReply ? 'RE: ' : '') . Str::limit($plain, 120, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep supported formatting tags but strip scripts and unsafe inline handlers.
|
||||
*/
|
||||
private function normaliseHtmlBody(string $body): string
|
||||
{
|
||||
$body = trim($body);
|
||||
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (! str_contains($body, '<') && ! str_contains($body, '>') && ! str_contains($body, '&lt;')) {
|
||||
break;
|
||||
}
|
||||
|
||||
$decoded = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
if ($decoded === $body) {
|
||||
break;
|
||||
}
|
||||
|
||||
$body = $decoded;
|
||||
}
|
||||
|
||||
$body = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $body) ?: '';
|
||||
$body = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $body) ?: '';
|
||||
$body = preg_replace('/javascript\s*:/i', '', $body) ?: '';
|
||||
$body = trim(strip_tags($body, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>'));
|
||||
|
||||
if ($body === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($body === strip_tags($body)) {
|
||||
return collect(preg_split('/\R{2,}/', $body) ?: [])
|
||||
->map(static fn (string $paragraph): string => trim($paragraph))
|
||||
->filter(static fn (string $paragraph): bool => $paragraph !== '')
|
||||
->map(static fn (string $paragraph): string => '<p>' . nl2br(e($paragraph), false) . '</p>')
|
||||
->implode('');
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal notes are restricted to MPBA/core-company users.
|
||||
*/
|
||||
private function userCanCreateInternalNote(mixed $user): bool
|
||||
{
|
||||
$company = $user?->company;
|
||||
|
||||
if ($company === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($company->is_system_company ?? false)
|
||||
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|
||||
|| Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* @param bool $required Whether at least one uploaded file is required.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(bool $required = false): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
$required ? 'required' : null,
|
||||
'file',
|
||||
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\SlaService;
|
||||
use Modules\Helpdesk\Services\TicketService;
|
||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||
|
||||
final class TicketControlsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Persist the assigned agent from the ticket detail controls.
|
||||
*/
|
||||
public function updateAgent(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
|
||||
{
|
||||
$this->hydrateUserRelations($ticket);
|
||||
|
||||
Gate::authorize('update', $ticket);
|
||||
|
||||
$validated = $request->validate([
|
||||
'agent_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('users', 'id')->where(function ($query) use ($ticket): void {
|
||||
if ($ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) {
|
||||
$query->where(function ($query) use ($ticket): void {
|
||||
$query->where('company_id', $ticket->company_id)
|
||||
->orWhereNull('company_id');
|
||||
});
|
||||
}
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
$agentId = $this->nullableInt($validated['agent_id'] ?? null);
|
||||
|
||||
$this->updateTicket($ticket, [
|
||||
'agent_id' => $agentId,
|
||||
'last_activity_at' => now(),
|
||||
]);
|
||||
|
||||
$fresh = $this->freshTicketForAction($ticket);
|
||||
$workflow->dispatch($fresh, 'ticket.updated', $this->actor());
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $fresh)
|
||||
->with('ticket-action-updated', $agentId === null
|
||||
? 'Ticket is now unassigned.'
|
||||
: 'Ticket assigned to ' . ($fresh->agent?->name ?? 'selected agent') . '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the selected ticket status from the ticket detail controls.
|
||||
*/
|
||||
public function updateStatus(Request $request, Ticket $ticket, WorkflowNotificationService $workflow, SlaService $sla, TicketService $tickets): RedirectResponse
|
||||
{
|
||||
$this->hydrateUserRelations($ticket);
|
||||
|
||||
Gate::authorize('update', $ticket);
|
||||
|
||||
$validated = $request->validate([
|
||||
'status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')],
|
||||
'closure_comment' => ['nullable', 'string', 'min:10', 'max:5000'],
|
||||
'closure_internal' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$status = Status::query()->findOrFail((int) $validated['status_id']);
|
||||
|
||||
if ($status->is_closed) {
|
||||
validator($validated, [
|
||||
'closure_comment' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
], [
|
||||
'closure_comment.required' => 'A closure comment is required before closing the ticket.',
|
||||
'closure_comment.min' => 'The closure comment must explain why the ticket is being closed.',
|
||||
])->validate();
|
||||
}
|
||||
|
||||
$this->updateTicket($ticket, [
|
||||
'status_id' => $status->id,
|
||||
'completed_at' => $status->is_closed ? now() : null,
|
||||
'last_activity_at' => now(),
|
||||
]);
|
||||
|
||||
$this->ensureBoardContainsStatus($ticket, (int) $status->id);
|
||||
|
||||
$fresh = $this->freshTicketForAction($ticket);
|
||||
$actor = $this->actor();
|
||||
|
||||
if ($status->is_closed) {
|
||||
$tickets->comment(
|
||||
$fresh,
|
||||
$actor,
|
||||
trim((string) $validated['closure_comment']),
|
||||
(bool) ($validated['closure_internal'] ?? false),
|
||||
[],
|
||||
null,
|
||||
'Closure comment',
|
||||
);
|
||||
|
||||
$fresh = $this->freshTicketForAction($ticket);
|
||||
$sla->recordClosure($fresh);
|
||||
}
|
||||
|
||||
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', $actor);
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $fresh)
|
||||
->with('ticket-action-updated', 'Status updated to ' . ($fresh->status?->name ?? 'selected status') . '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the selected ticket priority from the ticket detail controls.
|
||||
*/
|
||||
public function updatePriority(Request $request, Ticket $ticket, WorkflowNotificationService $workflow): RedirectResponse
|
||||
{
|
||||
$this->hydrateUserRelations($ticket);
|
||||
|
||||
Gate::authorize('update', $ticket);
|
||||
|
||||
$validated = $request->validate([
|
||||
'priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')],
|
||||
]);
|
||||
|
||||
$this->updateTicket($ticket, [
|
||||
'priority_id' => (int) $validated['priority_id'],
|
||||
'last_activity_at' => now(),
|
||||
]);
|
||||
|
||||
$fresh = $this->freshTicketForAction($ticket);
|
||||
$workflow->dispatch($fresh, 'ticket.updated', $this->actor());
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $fresh)
|
||||
->with('ticket-action-updated', 'Priority updated to ' . ($fresh->priority?->name ?? 'selected priority') . '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a direct guarded ticket update without relying on Livewire hydration.
|
||||
*
|
||||
* @param array<string, mixed> $updates
|
||||
*/
|
||||
private function updateTicket(Ticket $ticket, array $updates): void
|
||||
{
|
||||
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
|
||||
$updates['updated_at'] = now();
|
||||
}
|
||||
|
||||
DB::table('helpdesk_tickets')
|
||||
->where('id', $ticket->id)
|
||||
->update($updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the ticket after a control update without issuing separate owner/agent relation queries.
|
||||
*/
|
||||
private function freshTicketForAction(Ticket $ticket): Ticket
|
||||
{
|
||||
$fresh = $ticket->fresh([
|
||||
'status',
|
||||
'priority',
|
||||
'category',
|
||||
'project',
|
||||
'team',
|
||||
'product',
|
||||
]) ?? $ticket;
|
||||
|
||||
$this->hydrateUserRelations($fresh);
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load requester and assigned agent in one user query and attach all aliases used downstream.
|
||||
*/
|
||||
private function hydrateUserRelations(Ticket $ticket): void
|
||||
{
|
||||
$userIds = collect([$ticket->user_id, $ticket->agent_id])
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($userIds->isEmpty()) {
|
||||
$ticket->setRelation('user', null);
|
||||
$ticket->setRelation('owner', null);
|
||||
$ticket->setRelation('agent', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->with('company')
|
||||
->whereIn('id', $userIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$owner = $users->get((int) $ticket->user_id);
|
||||
$agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id);
|
||||
|
||||
$ticket->setRelation('user', $owner);
|
||||
$ticket->setRelation('owner', $owner);
|
||||
$ticket->setRelation('agent', $agent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the active Kanban board usable when a ticket is moved to a status not yet assigned to the board.
|
||||
*/
|
||||
private function ensureBoardContainsStatus(Ticket $ticket, int $statusId): void
|
||||
{
|
||||
if ($ticket->board_id === null || ! Schema::hasTable('helpdesk_kanban_board_statuses')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||
['board_id' => $ticket->board_id, 'status_id' => $statusId],
|
||||
['updated_at' => now(), 'created_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated user with company loaded for workflow rendering.
|
||||
*/
|
||||
private function actor(): User
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
$user->loadMissing('company');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert form select values to nullable integers.
|
||||
*/
|
||||
private function nullableInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '' || $value === '0' || $value === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +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
|
||||
{
|
||||
$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.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$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.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* @param bool $required Whether at least one uploaded file is required.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(bool $required = false): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
$required ? 'required' : null,
|
||||
'file',
|
||||
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Http\Controllers;
|
||||
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
|
||||
final class TicketTimeLogController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store logged time against the selected helpdesk ticket.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function store(Request $request, Ticket $ticket): RedirectResponse
|
||||
{
|
||||
if ($request->user()?->cannot('update', $ticket)) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'minutes' => ['required', 'integer', 'min:1', 'max:1440'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$minutes = (int) $validated['minutes'];
|
||||
$description = $validated['description'] ?? null;
|
||||
$loggedAt = now();
|
||||
|
||||
DB::transaction(function () use ($ticket, $request, $minutes, $description, $loggedAt): void {
|
||||
if (Schema::hasColumn($ticket->getTable(), 'time_spent_minutes')) {
|
||||
$ticket->increment('time_spent_minutes', $minutes, [
|
||||
'last_activity_at' => $loggedAt,
|
||||
]);
|
||||
} elseif (Schema::hasColumn($ticket->getTable(), 'last_activity_at')) {
|
||||
$ticket->forceFill(['last_activity_at' => $loggedAt])->save();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('helpdesk_time_logs')) {
|
||||
DB::table('helpdesk_time_logs')->insert([
|
||||
'ticket_id' => $ticket->getKey(),
|
||||
'user_id' => $request->user()->getKey(),
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'logged_at' => $loggedAt,
|
||||
'created_at' => $loggedAt,
|
||||
'updated_at' => $loggedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
if (Schema::hasTable('helpdesk_ticket_time_logs')) {
|
||||
$payload = [
|
||||
'ticket_id' => $ticket->getKey(),
|
||||
'user_id' => $request->user()->getKey(),
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'created_at' => $loggedAt,
|
||||
'updated_at' => $loggedAt,
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'started_at')) {
|
||||
$payload['started_at'] = $loggedAt->copy()->subMinutes($minutes);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'ended_at')) {
|
||||
$payload['ended_at'] = $loggedAt;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'logged_at')) {
|
||||
$payload['logged_at'] = $loggedAt;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'duration_seconds')) {
|
||||
$payload['duration_seconds'] = $minutes * 60;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('helpdesk_ticket_time_logs', 'seconds')) {
|
||||
$payload['seconds'] = $minutes * 60;
|
||||
}
|
||||
|
||||
DB::table('helpdesk_ticket_time_logs')->insert($payload);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('helpdesk.tickets.show', $ticket)
|
||||
->with('ticket-action-updated', 'Time logged successfully.');
|
||||
}
|
||||
}
|
||||
@@ -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,52 +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();
|
||||
|
||||
if ($user->helpdesk_admin ?? false) {
|
||||
// no filter
|
||||
} elseif ($user->helpdesk_agent ?? false) {
|
||||
$base->where(function ($q) use ($user) {
|
||||
$q->where('agent_id', $user->id)
|
||||
->orWhere('user_id', $user->id);
|
||||
});
|
||||
} else {
|
||||
$base->where('user_id', $user->id);
|
||||
}
|
||||
$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,294 +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 Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\On;
|
||||
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 int $refreshNonce = 0;
|
||||
|
||||
public bool $isMovingTicket = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$requestedBoardId = request()->query('board_id');
|
||||
|
||||
if (is_numeric($requestedBoardId)) {
|
||||
$requestedBoardId = (int) $requestedBoardId;
|
||||
|
||||
if (KanbanBoardModel::query()->whereKey($requestedBoardId)->exists()) {
|
||||
$this->board_id = $requestedBoardId;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$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 on the active board.
|
||||
*/
|
||||
public function moveTicket(int|string $ticketId, int|string $statusId, WorkflowNotificationService $workflow): void
|
||||
{
|
||||
if ($this->isMovingTicket) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isMovingTicket = true;
|
||||
|
||||
try {
|
||||
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
||||
$status = Status::query()->findOrFail((int) $statusId);
|
||||
$board = $this->activeBoard();
|
||||
$oldStatusName = $ticket->status?->name ?? 'previous status';
|
||||
|
||||
$this->authorize('update', $ticket);
|
||||
|
||||
if ($board !== null && ! $board->statuses()->whereKey($status->id)->exists()) {
|
||||
$this->addStatusToBoard($board->id, $status->id);
|
||||
}
|
||||
|
||||
$ticket->forceFill([
|
||||
'board_id' => $board?->id,
|
||||
'status_id' => $status->id,
|
||||
'completed_at' => $status->is_closed ? now() : null,
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$freshTicket = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board']) ?? $ticket;
|
||||
|
||||
$workflow->dispatch(
|
||||
$freshTicket,
|
||||
$status->is_closed ? 'ticket.closed' : 'ticket.updated',
|
||||
Auth::user(),
|
||||
);
|
||||
|
||||
$message = 'Ticket ' . $freshTicket->reference . ' moved from ' . $oldStatusName . ' to ' . $status->name . '.';
|
||||
session()->flash('success', $message);
|
||||
|
||||
$this->refreshBoard();
|
||||
$this->dispatch('helpdesk-kanban-moved', ticketId: (int) $freshTicket->id, statusId: (int) $freshTicket->status_id);
|
||||
$this->dispatch('wms:toast', type: 'success', title: 'Ticket moved', message: $message);
|
||||
} finally {
|
||||
$this->isMovingTicket = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a ticket from its current board to another board. A ticket can only belong to one board.
|
||||
*/
|
||||
public function moveTicketToBoard(int|string $ticketId, int|string|null $boardId, WorkflowNotificationService $workflow): void
|
||||
{
|
||||
$ticket = Ticket::query()->with('board')->visibleTo(Auth::user())->findOrFail((int) $ticketId);
|
||||
$board = KanbanBoardModel::query()->with(['statuses', 'owner:id,name,email'])->findOrFail((int) $boardId);
|
||||
$oldBoardName = $ticket->board?->name ?? 'No board';
|
||||
|
||||
$this->authorize('update', $ticket);
|
||||
|
||||
if (! $board->statuses->contains('id', $ticket->status_id)) {
|
||||
$firstStatus = $board->statuses->first();
|
||||
|
||||
if ($firstStatus !== null) {
|
||||
$ticket->status_id = $firstStatus->id;
|
||||
$ticket->completed_at = $firstStatus->is_closed ? now() : null;
|
||||
} else {
|
||||
$this->addStatusToBoard($board->id, (int) $ticket->status_id);
|
||||
}
|
||||
}
|
||||
|
||||
$ticket->forceFill([
|
||||
'board_id' => $board->id,
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->board_id = $board->id;
|
||||
|
||||
$fresh = $ticket->fresh(['status', 'priority', 'category', 'project', 'team', 'board']) ?? $ticket;
|
||||
$workflow->dispatch($fresh, 'ticket.updated', Auth::user());
|
||||
|
||||
$message = 'Ticket ' . $fresh->reference . ' moved from ' . $oldBoardName . ' to ' . $board->name . '.';
|
||||
session()->flash('success', $message);
|
||||
|
||||
$this->refreshBoard();
|
||||
$this->dispatch('helpdesk-kanban-moved', ticketId: (int) $fresh->id, statusId: (int) $fresh->status_id);
|
||||
$this->dispatch('wms:toast', type: 'success', title: 'Board changed', message: $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the board to fetch fresh database rows after moves or polling.
|
||||
*/
|
||||
#[On('helpdesk-kanban-refresh')]
|
||||
public function refreshBoard(): void
|
||||
{
|
||||
$this->refreshNonce++;
|
||||
unset($this->ticketsByStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the board selector value from Livewire/browser select events.
|
||||
*/
|
||||
public function updatedBoardId(int|string|null $value): void
|
||||
{
|
||||
$this->board_id = is_numeric($value) ? (int) $value : null;
|
||||
$this->refreshBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the visible board without relying on DOM morph side effects.
|
||||
*/
|
||||
public function selectBoard(int|string|null $value): void
|
||||
{
|
||||
$this->board_id = is_numeric($value) ? (int) $value : null;
|
||||
$this->refreshBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Kanban board.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
$boards = KanbanBoardModel::query()
|
||||
->with(['statuses', 'owner:id,name,email'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$board = $this->resolveSelectedBoard($boards);
|
||||
$this->board_id = $board?->id;
|
||||
|
||||
if ($board instanceof KanbanBoardModel && $board->statuses->isEmpty()) {
|
||||
$this->ensureBoardHasStatuses($board);
|
||||
$board->load(['statuses', 'owner:id,name,email']);
|
||||
|
||||
$boards = KanbanBoardModel::query()
|
||||
->with(['statuses', 'owner:id,name,email'])
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
return view('helpdesk::livewire.kanban-board', [
|
||||
'boards' => $boards,
|
||||
'board' => $board,
|
||||
'statuses' => $board?->statuses ?? collect(),
|
||||
'ticketsByStatus' => $this->ticketsByStatus($board),
|
||||
'refreshNonce' => $this->refreshNonce,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visible tickets grouped by status for the selected board only.
|
||||
*
|
||||
* @return array<int, Collection<int, Ticket>>
|
||||
*/
|
||||
private function ticketsByStatus(?KanbanBoardModel $board): array
|
||||
{
|
||||
if ($board === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Ticket::query()
|
||||
->with(['user:id,name,email', 'owner:id,name,email', 'agent:id,name,email', 'status', 'priority', 'category', 'project', 'team', 'product', 'tags', 'checklistItems', 'board'])
|
||||
->visibleTo(Auth::user())
|
||||
->where('board_id', $board->id)
|
||||
->latest('last_activity_at')
|
||||
->limit(300)
|
||||
->get()
|
||||
->groupBy('status_id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current board from the selected board ID.
|
||||
*/
|
||||
private function activeBoard(): ?KanbanBoardModel
|
||||
{
|
||||
if ($this->board_id !== null) {
|
||||
return KanbanBoardModel::query()->find((int) $this->board_id);
|
||||
}
|
||||
|
||||
return KanbanBoardModel::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->first() ?: KanbanBoardModel::query()->orderBy('sort_order')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the selected board from an already-loaded board collection.
|
||||
*
|
||||
* @param Collection<int, KanbanBoardModel> $boards
|
||||
*/
|
||||
private function resolveSelectedBoard(Collection $boards): ?KanbanBoardModel
|
||||
{
|
||||
if ($boards->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->board_id !== null) {
|
||||
$selected = $boards->firstWhere('id', (int) $this->board_id);
|
||||
|
||||
if ($selected instanceof KanbanBoardModel) {
|
||||
return $selected;
|
||||
}
|
||||
}
|
||||
|
||||
$default = $boards->firstWhere('is_default', true);
|
||||
|
||||
return $default instanceof KanbanBoardModel ? $default : $boards->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a missing status to a board when a move requires it.
|
||||
*/
|
||||
private function addStatusToBoard(int $boardId, int $statusId): void
|
||||
{
|
||||
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||
['board_id' => $boardId, 'status_id' => $statusId],
|
||||
['sort_order' => 999, 'updated_at' => now(), 'created_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a board has workflow columns. This keeps newly-created boards usable immediately.
|
||||
*/
|
||||
private function ensureBoardHasStatuses(KanbanBoardModel $board): void
|
||||
{
|
||||
if ($board->statuses()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = Status::query()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get(['id']);
|
||||
|
||||
foreach ($statuses as $index => $status) {
|
||||
DB::table('helpdesk_kanban_board_statuses')->updateOrInsert(
|
||||
['board_id' => $board->id, 'status_id' => $status->id],
|
||||
[
|
||||
'sort_order' => ($index + 1) * 10,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,960 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use Modules\Helpdesk\Models\AutomationRule;
|
||||
use Modules\Helpdesk\Models\CannedResponse;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\ChecklistTemplate;
|
||||
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\SlaPolicy;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Tag;
|
||||
use Modules\Helpdesk\Models\Team;
|
||||
use Modules\Helpdesk\Models\WorkflowTemplate;
|
||||
use Modules\Helpdesk\Support\Color;
|
||||
|
||||
final class SettingsManager extends Component
|
||||
{
|
||||
public string $section = 'statuses';
|
||||
|
||||
public int|string|null $default_helpdesk_company_id = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $statusForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $priorityForm = ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $categoryForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $teamForm = ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $boardForm = ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, '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' => 'gray'];
|
||||
|
||||
/** @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->section = $this->validSection((string) request()->query('section', $this->section));
|
||||
$this->loadDefaultHelpdeskCompany();
|
||||
$this->statusForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
||||
$this->priorityForm += ['id' => null, 'name' => '', 'slug' => '', 'color' => 'gray', 'sort_order' => 0, 'is_default' => false];
|
||||
$this->categoryForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false];
|
||||
$this->teamForm += ['id' => null, 'company_id' => null, 'name' => '', 'slug' => '', 'color' => 'indigo', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
$this->projectForm += ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'is_default' => false, 'archived_at' => null];
|
||||
$this->boardForm += ['id' => null, 'name' => '', 'slug' => '', 'description' => '', 'owner_id' => null, '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' => 'gray'];
|
||||
$this->checklistTemplateForm += ['id' => null, 'company_id' => null, 'category_id' => null, 'project_id' => null, 'name' => '', 'slug' => '', 'items_text' => '', 'is_active' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep settings section navigation constrained to known panels.
|
||||
*/
|
||||
private function validSection(string $section): string
|
||||
{
|
||||
$allowed = [
|
||||
'statuses',
|
||||
'priorities',
|
||||
'categories',
|
||||
'teams',
|
||||
'projects',
|
||||
'boards',
|
||||
'git',
|
||||
'workflow',
|
||||
'canned',
|
||||
'sla',
|
||||
'automation',
|
||||
'checklists',
|
||||
'tags',
|
||||
'products',
|
||||
'defaults',
|
||||
];
|
||||
|
||||
return in_array($section, $allowed, true) ? $section : 'statuses';
|
||||
}
|
||||
|
||||
public function saveStatus(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'statusForm.name' => ['required', 'string', 'max:80'],
|
||||
'statusForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'statusForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'statusForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'statusForm.is_closed' => ['boolean'],
|
||||
'statusForm.is_default' => ['boolean'],
|
||||
])['statusForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_statuses', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Status::class, $this->statusForm['id']);
|
||||
}
|
||||
|
||||
$this->persistSettingModel(Status::class, $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', 'is_default']);
|
||||
$this->statusForm['color'] = Color::normalise($this->statusForm['color'] ?? 'gray');
|
||||
$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' => 'gray', 'sort_order' => 0, 'is_closed' => false, 'is_default' => false];
|
||||
}
|
||||
|
||||
public function savePriority(): void
|
||||
{
|
||||
$data = $this->validate([
|
||||
'priorityForm.name' => ['required', 'string', 'max:80'],
|
||||
'priorityForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'priorityForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'priorityForm.sort_order' => ['required', 'integer', 'min:0'],
|
||||
'priorityForm.is_default' => ['boolean'],
|
||||
])['priorityForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_priorities', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Priority::class, $this->priorityForm['id']);
|
||||
}
|
||||
$this->persistSettingModel(Priority::class, $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', 'is_default']);
|
||||
$this->priorityForm['color'] = Color::normalise($this->priorityForm['color'] ?? 'gray');
|
||||
$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' => 'gray', 'sort_order' => 0, 'is_default' => false];
|
||||
}
|
||||
|
||||
public function saveCategory(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('categoryForm', ['company_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'categoryForm.company_id' => ['nullable', 'integer'],
|
||||
'categoryForm.name' => ['required', 'string', 'max:120'],
|
||||
'categoryForm.slug' => ['nullable', 'string', 'max:140'],
|
||||
'categoryForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'categoryForm.description' => ['nullable', 'string'],
|
||||
'categoryForm.is_default' => ['boolean'],
|
||||
])['categoryForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_categories', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Category::class, $this->categoryForm['id'], $data['company_id'] ?? null);
|
||||
}
|
||||
$this->persistSettingModel(Category::class, $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', 'is_default']);
|
||||
$this->categoryForm['color'] = Color::normalise($this->categoryForm['color'] ?? 'blue');
|
||||
$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' => '', 'is_default' => false];
|
||||
}
|
||||
|
||||
public function saveTeam(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('teamForm', ['company_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'teamForm.company_id' => ['nullable', 'integer'],
|
||||
'teamForm.name' => ['required', 'string', 'max:120'],
|
||||
'teamForm.slug' => ['nullable', 'string', 'max:140'],
|
||||
'teamForm.color' => ['required', 'string', Color::validationRule()],
|
||||
'teamForm.description' => ['nullable', 'string'],
|
||||
'teamForm.is_default' => ['boolean'],
|
||||
])['teamForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_teams', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Team::class, $this->teamForm['id'], $data['company_id'] ?? null);
|
||||
}
|
||||
$this->persistSettingModel(Team::class, $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', 'is_default', 'archived_at']);
|
||||
$this->teamForm['color'] = Color::normalise($this->teamForm['color'] ?? 'indigo');
|
||||
$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' => '', 'is_default' => false, 'archived_at' => null];
|
||||
}
|
||||
|
||||
public function saveProject(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('projectForm', ['company_id', 'team_id']);
|
||||
|
||||
$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', Color::validationRule()],
|
||||
'projectForm.description' => ['nullable', 'string'],
|
||||
'projectForm.is_default' => ['boolean'],
|
||||
])['projectForm'];
|
||||
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['title']);
|
||||
$data = $this->guardDefaultColumn('helpdesk_projects', $data);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(Project::class, $this->projectForm['id'], $data['company_id'] ?? null);
|
||||
}
|
||||
$this->persistSettingModel(Project::class, $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', 'is_default', 'archived_at']);
|
||||
$this->projectForm['color'] = Color::normalise($this->projectForm['color'] ?? 'blue');
|
||||
$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' => '', 'is_default' => false, 'archived_at' => null];
|
||||
}
|
||||
|
||||
public function saveBoard(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('boardForm', ['owner_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'boardForm.name' => ['required', 'string', 'max:160'],
|
||||
'boardForm.slug' => ['nullable', 'string', 'max:180'],
|
||||
'boardForm.description' => ['nullable', 'string'],
|
||||
'boardForm.owner_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'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['owner_id'] = blank($data['owner_id'] ?? null) ? null : (int) $data['owner_id'];
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
|
||||
if ((bool) ($data['is_default'] ?? false)) {
|
||||
$this->clearOtherDefaults(KanbanBoard::class, $this->boardForm['id']);
|
||||
}
|
||||
|
||||
$board = $this->persistSettingModel(KanbanBoard::class, $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,
|
||||
'owner_id' => $board->owner_id,
|
||||
'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' => '', 'owner_id' => null, 'is_default' => false, 'sort_order' => 0, 'status_ids' => []];
|
||||
}
|
||||
|
||||
public function saveRepository(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('repositoryForm', ['company_id', 'project_id', 'webhook_secret']);
|
||||
|
||||
$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']));
|
||||
|
||||
$this->persistSettingModel(GitRepository::class, $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
|
||||
{
|
||||
$this->normalizeNullableFormValues('workflowForm', ['text_body', 'cc', 'bcc']);
|
||||
|
||||
$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'];
|
||||
|
||||
$this->persistSettingModel(WorkflowTemplate::class, $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
|
||||
{
|
||||
$this->normalizeNullableFormValues('cannedForm', ['company_id', 'category_id', 'team_id', 'subject']);
|
||||
|
||||
$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']);
|
||||
$this->persistSettingModel(CannedResponse::class, $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
|
||||
{
|
||||
$this->normalizeNullableFormValues('slaForm', ['company_id', 'priority_id', 'category_id']);
|
||||
|
||||
$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)) {
|
||||
$this->clearOtherDefaults(SlaPolicy::class, $this->slaForm['id']);
|
||||
}
|
||||
$this->persistSettingModel(SlaPolicy::class, $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
|
||||
{
|
||||
$this->normalizeNullableFormValues('automationForm', ['company_id', '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']);
|
||||
|
||||
$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']);
|
||||
$this->persistSettingModel(AutomationRule::class, $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
|
||||
{
|
||||
$this->normalizeNullableFormValues('productForm', ['company_id', 'version', 'release_channel', 'description']);
|
||||
|
||||
$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']);
|
||||
$this->persistSettingModel(Product::class, $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
|
||||
{
|
||||
$this->normalizeNullableFormValues('tagForm', ['company_id']);
|
||||
|
||||
$data = $this->validate([
|
||||
'tagForm.company_id' => ['nullable', 'integer'],
|
||||
'tagForm.name' => ['required', 'string', 'max:80'],
|
||||
'tagForm.slug' => ['nullable', 'string', 'max:100'],
|
||||
'tagForm.color' => ['required', 'string', Color::validationRule()],
|
||||
])['tagForm'];
|
||||
$data['slug'] = $this->slug($data['slug'] ?: $data['name']);
|
||||
$this->persistSettingModel(Tag::class, $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->tagForm['color'] = Color::normalise($this->tagForm['color'] ?? 'gray');
|
||||
$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' => 'gray'];
|
||||
}
|
||||
|
||||
public function saveChecklistTemplate(): void
|
||||
{
|
||||
$this->normalizeNullableFormValues('checklistTemplateForm', ['company_id', 'category_id', 'project_id']);
|
||||
|
||||
$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();
|
||||
$this->persistSettingModel(ChecklistTemplate::class, $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 saveDefaultHelpdeskCompany(): void
|
||||
{
|
||||
if (! Schema::hasTable('companies') || ! Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
|
||||
session()->flash('error', 'The default Helpdesk company column has not been migrated yet.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->normalizeNullableScalar('default_helpdesk_company_id');
|
||||
|
||||
$this->validate([
|
||||
'default_helpdesk_company_id' => ['nullable', 'integer', 'exists:companies,id'],
|
||||
]);
|
||||
|
||||
$companyId = filled($this->default_helpdesk_company_id)
|
||||
? (int) $this->default_helpdesk_company_id
|
||||
: null;
|
||||
|
||||
DB::table('companies')->update(['is_default_helpdesk_company' => false]);
|
||||
|
||||
if ($companyId !== null) {
|
||||
DB::table('companies')
|
||||
->where('id', $companyId)
|
||||
->update(['is_default_helpdesk_company' => true]);
|
||||
}
|
||||
|
||||
session()->flash('success', 'Default Helpdesk company saved.');
|
||||
}
|
||||
|
||||
private function loadDefaultHelpdeskCompany(): void
|
||||
{
|
||||
if (! Schema::hasTable('companies') || ! Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->default_helpdesk_company_id = DB::table('companies')
|
||||
->where('is_default_helpdesk_company', true)
|
||||
->value('id');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.settings-manager', [
|
||||
'companies' => $this->companies(),
|
||||
'statuses' => $this->orderedStatuses(),
|
||||
'priorities' => $this->orderedPriorities(),
|
||||
'categories' => $this->orderedCategories(),
|
||||
'teams' => $this->orderedTeams(),
|
||||
'projects' => $this->orderedProjects(),
|
||||
'boardOwners' => User::query()->where(static function ($query): void {
|
||||
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
|
||||
})->orderBy('name')->get(['id', 'name', 'email']),
|
||||
'boards' => KanbanBoard::query()->with(['statuses', 'owner:id,name,email'])->withCount('tickets')->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(),
|
||||
'colourOptions' => Color::options(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
private function orderedStatuses(): mixed
|
||||
{
|
||||
return Status::query()
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_statuses'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedPriorities(): mixed
|
||||
{
|
||||
return Priority::query()
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_priorities'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedCategories(): mixed
|
||||
{
|
||||
return Category::query()
|
||||
->with('company')
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedTeams(): mixed
|
||||
{
|
||||
return Team::query()
|
||||
->with('company')
|
||||
->withCount(['projects', 'tickets'])
|
||||
->when($this->hasDefaultColumn('helpdesk_teams'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function orderedProjects(): mixed
|
||||
{
|
||||
return Project::query()
|
||||
->with(['company', 'team'])
|
||||
->withCount('tickets')
|
||||
->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('title')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a settings record without inserting a null primary key.
|
||||
*
|
||||
* Eloquent updateOrCreate(['id' => null], ...) merges the null id into the
|
||||
* create payload. PostgreSQL rejects that explicit null for bigserial ids,
|
||||
* which made every new Helpdesk settings CRUD form appear to do nothing.
|
||||
*
|
||||
* @template TModel of \Illuminate\Database\Eloquent\Model
|
||||
*
|
||||
* @param class-string<TModel> $modelClass
|
||||
* @param int|string|null $id
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return TModel
|
||||
*/
|
||||
private function persistSettingModel(string $modelClass, int|string|null $id, array $data): object
|
||||
{
|
||||
unset($data['id']);
|
||||
|
||||
try {
|
||||
if (filled($id)) {
|
||||
/** @var TModel $model */
|
||||
$model = $modelClass::query()->findOrFail((int) $id);
|
||||
$model->fill($data);
|
||||
$model->save();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/** @var TModel $model */
|
||||
$model = $modelClass::query()->create($data);
|
||||
|
||||
return $model;
|
||||
} catch (QueryException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'settings' => 'The record could not be saved. Check the slug is unique for this section/company and try again.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the default flag on every other record without generating invalid null-id SQL.
|
||||
*
|
||||
* @param class-string<Model> $modelClass
|
||||
*/
|
||||
private function clearOtherDefaults(string $modelClass, int|string|null $currentId, int|string|null $companyId = null): void
|
||||
{
|
||||
$query = $modelClass::query();
|
||||
|
||||
if (filled($currentId)) {
|
||||
$query->whereKeyNot((int) $currentId);
|
||||
}
|
||||
|
||||
if (Schema::hasColumn((new $modelClass)->getTable(), 'company_id')) {
|
||||
$query->where('company_id', filled($companyId) ? (int) $companyId : null);
|
||||
}
|
||||
|
||||
$query->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert browser select empty strings into null before Livewire validation.
|
||||
*
|
||||
* Flux/select controls submit optional values as empty strings. Laravel's
|
||||
* nullable integer and exists rules expect a real null, so normalising here
|
||||
* keeps every settings CRUD form usable from the browser.
|
||||
*
|
||||
* @param array<int, string> $keys
|
||||
*/
|
||||
private function normalizeNullableFormValues(string $formProperty, array $keys): void
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (! array_key_exists($key, $this->{$formProperty})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blank($this->{$formProperty}[$key])) {
|
||||
$this->{$formProperty}[$key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeNullableScalar(string $property): void
|
||||
{
|
||||
if (blank($this->{$property})) {
|
||||
$this->{$property} = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove is_default before save when the database has not been migrated yet.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function guardDefaultColumn(string $table, array $data): array
|
||||
{
|
||||
if (! $this->hasDefaultColumn($table)) {
|
||||
unset($data['is_default']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function hasDefaultColumn(string $table): bool
|
||||
{
|
||||
return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default');
|
||||
}
|
||||
|
||||
/** @return array<int, object> */
|
||||
private function companies(): array
|
||||
{
|
||||
if (! Schema::hasTable('companies')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$columns = ['id', 'name'];
|
||||
|
||||
if (Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
|
||||
$columns[] = 'is_default_helpdesk_company';
|
||||
}
|
||||
|
||||
return DB::table('companies')->select($columns)->orderBy('name')->get()->all();
|
||||
}
|
||||
|
||||
private function slug(string $value): string
|
||||
{
|
||||
return Str::slug($value);
|
||||
}
|
||||
}
|
||||
@@ -1,350 +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\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Product;
|
||||
use Modules\Helpdesk\Models\Project;
|
||||
use Modules\Helpdesk\Models\Tag;
|
||||
use Modules\Helpdesk\Models\Team;
|
||||
use Modules\Helpdesk\Services\TicketService;
|
||||
|
||||
final class TicketCreate extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public ?int $company_id = null;
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public ?int $board_id = null;
|
||||
|
||||
public ?int $priority_id = null;
|
||||
|
||||
public ?int $category_id = null;
|
||||
|
||||
public ?int $project_id = null;
|
||||
|
||||
public ?int $product_id = null;
|
||||
|
||||
public ?int $team_id = null;
|
||||
|
||||
public ?string $due_at = null;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $tag_ids = [];
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
public array $attachments = [];
|
||||
|
||||
public bool $isSaving = false;
|
||||
|
||||
/**
|
||||
* Validation rules are intentionally evaluated only when the user submits
|
||||
* the form, not while rich-text/select widgets are synchronising.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'company_id' => ['nullable', 'integer'],
|
||||
'subject' => ['required', 'string', 'max:200'],
|
||||
'body' => ['required', 'string', 'min:10'],
|
||||
'board_id' => ['nullable', 'integer', 'exists:helpdesk_kanban_boards,id'],
|
||||
'priority_id' => ['required', 'integer', 'exists:helpdesk_priorities,id'],
|
||||
'category_id' => ['nullable', 'integer', 'exists:helpdesk_categories,id'],
|
||||
'project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
|
||||
'product_id' => ['nullable', 'integer', 'exists:helpdesk_products,id'],
|
||||
'team_id' => ['nullable', 'integer', 'exists:helpdesk_teams,id'],
|
||||
'due_at' => ['nullable', 'date'],
|
||||
'tag_ids' => ['array'],
|
||||
'tag_ids.*' => ['integer', 'exists:helpdesk_tags,id'],
|
||||
'attachments' => ['array'],
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise configured Helpdesk defaults for a new ticket.
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->applyDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new helpdesk ticket.
|
||||
*/
|
||||
public function save(TicketService $tickets)
|
||||
{
|
||||
if ($this->isSaving) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->isSaving = true;
|
||||
|
||||
try {
|
||||
$this->body = trim($this->body);
|
||||
|
||||
$this->validate();
|
||||
|
||||
$ticket = $tickets->create(Auth::user(), [
|
||||
'company_id' => $this->company_id,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'board_id' => $this->board_id,
|
||||
'priority_id' => $this->priority_id,
|
||||
'category_id' => $this->category_id,
|
||||
'project_id' => $this->project_id,
|
||||
'product_id' => $this->product_id,
|
||||
'team_id' => $this->team_id,
|
||||
'due_at' => $this->due_at,
|
||||
'tag_ids' => $this->tag_ids,
|
||||
], $this->attachments);
|
||||
|
||||
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
|
||||
|
||||
return redirect()->to(route('helpdesk.tickets.index'));
|
||||
} finally {
|
||||
$this->isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the create form, including Select2 and Flatpickr browser widgets.
|
||||
*/
|
||||
public function clearForm(): void
|
||||
{
|
||||
$this->resetErrorBag();
|
||||
$this->resetValidation();
|
||||
|
||||
$this->subject = '';
|
||||
$this->body = '';
|
||||
$this->tag_ids = [];
|
||||
$this->attachments = [];
|
||||
|
||||
$this->dispatch('helpdesk-form-cleared');
|
||||
$this->dispatch('helpdesk-editor-reset', model: 'body');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-create', [
|
||||
'boards' => $this->boardOptions(),
|
||||
'priorities' => $this->priorityOptions(),
|
||||
'categories' => $this->categoryOptions(),
|
||||
'companies' => $this->companyOptions(),
|
||||
'teams' => $this->teamOptions(),
|
||||
'projects' => $this->projectOptions(),
|
||||
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
'tags' => Tag::query()->orderBy('name')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply default values from Helpdesk/company lookup configuration.
|
||||
*/
|
||||
private function applyDefaults(): void
|
||||
{
|
||||
if (blank($this->company_id) && Schema::hasTable('companies') && Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
|
||||
$this->company_id = DB::table('companies')
|
||||
->where('is_default_helpdesk_company', true)
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->board_id) && Schema::hasTable('helpdesk_kanban_boards')) {
|
||||
$this->board_id = KanbanBoard::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id') ?: KanbanBoard::query()->orderBy('sort_order')->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->priority_id) && $this->hasDefaultColumn('helpdesk_priorities')) {
|
||||
$this->priority_id = Priority::query()
|
||||
->where('is_default', true)
|
||||
->orderBy('sort_order')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->category_id) && $this->hasDefaultColumn('helpdesk_categories')) {
|
||||
$this->category_id = Category::query()
|
||||
->where('is_default', true)
|
||||
->when($this->company_id, fn ($query) => $query->where(function ($query): void {
|
||||
$query->where('company_id', $this->company_id)
|
||||
->orWhereNull('company_id');
|
||||
}))
|
||||
->orderByRaw('company_id is null')
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->team_id) && $this->hasDefaultColumn('helpdesk_teams')) {
|
||||
$this->team_id = Team::query()
|
||||
->where('is_default', true)
|
||||
->whereNull('archived_at')
|
||||
->when($this->company_id, fn ($query) => $query->where(function ($query): void {
|
||||
$query->where('company_id', $this->company_id)
|
||||
->orWhereNull('company_id');
|
||||
}))
|
||||
->orderByRaw('company_id is null')
|
||||
->orderBy('name')
|
||||
->value('id');
|
||||
}
|
||||
|
||||
if (blank($this->project_id) && $this->hasDefaultColumn('helpdesk_projects')) {
|
||||
$this->project_id = Project::query()
|
||||
->where('is_default', true)
|
||||
->whereNull('archived_at')
|
||||
->when($this->company_id, fn ($query) => $query->where(function ($query): void {
|
||||
$query->where('company_id', $this->company_id)
|
||||
->orWhereNull('company_id');
|
||||
}))
|
||||
->when($this->team_id, fn ($query) => $query->where(function ($query): void {
|
||||
$query->where('team_id', $this->team_id)
|
||||
->orWhereNull('team_id');
|
||||
}))
|
||||
->orderByRaw('company_id is null')
|
||||
->orderByRaw('team_id is null')
|
||||
->orderBy('title')
|
||||
->value('id');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Kanban board options with the default board listed first.
|
||||
*/
|
||||
private function boardOptions(): mixed
|
||||
{
|
||||
return KanbanBoard::query()
|
||||
->with('owner:id,name,email')
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return priority options with default records listed first.
|
||||
*/
|
||||
private function priorityOptions(): mixed
|
||||
{
|
||||
return Priority::query()
|
||||
->when($this->hasDefaultColumn('helpdesk_priorities'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return category options with default records listed first.
|
||||
*/
|
||||
private function categoryOptions(): mixed
|
||||
{
|
||||
return Category::query()
|
||||
->with('agents:id')
|
||||
->when($this->hasDefaultColumn('helpdesk_categories'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return team options with default records listed first.
|
||||
*/
|
||||
private function teamOptions(): mixed
|
||||
{
|
||||
return Team::query()
|
||||
->whereNull('archived_at')
|
||||
->when($this->hasDefaultColumn('helpdesk_teams'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return project options with default records listed first.
|
||||
*/
|
||||
private function projectOptions(): mixed
|
||||
{
|
||||
return Project::query()
|
||||
->whereNull('archived_at')
|
||||
->when($this->hasDefaultColumn('helpdesk_projects'), fn ($query) => $query->orderByDesc('is_default'))
|
||||
->orderBy('title')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return company options with default flag support when available.
|
||||
*/
|
||||
private function companyOptions(): mixed
|
||||
{
|
||||
if (! Schema::hasTable('companies')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$columns = ['id', 'name'];
|
||||
|
||||
if (Schema::hasColumn('companies', 'is_default_helpdesk_company')) {
|
||||
$columns[] = 'is_default_helpdesk_company';
|
||||
}
|
||||
|
||||
return DB::table('companies')
|
||||
->select($columns)
|
||||
->orderByDesc(Schema::hasColumn('companies', 'is_default_helpdesk_company') ? 'is_default_helpdesk_company' : 'id')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a Helpdesk lookup table has the default flag column.
|
||||
*/
|
||||
private function hasDefaultColumn(string $table): bool
|
||||
{
|
||||
return Schema::hasTable($table) && Schema::hasColumn($table, 'is_default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* Laravel validates source-code attachments by extension because browsers often report
|
||||
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(): array
|
||||
{
|
||||
return [
|
||||
'file',
|
||||
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Modules\Helpdesk\Models\Category;
|
||||
use Modules\Helpdesk\Models\KanbanBoard;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Product;
|
||||
use Modules\Helpdesk\Models\Project;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Tag;
|
||||
use Modules\Helpdesk\Models\Team;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\SlaService;
|
||||
use Modules\Helpdesk\Services\WorkflowNotificationService;
|
||||
|
||||
final class TicketEdit extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Ticket $ticket;
|
||||
|
||||
#[Validate('nullable|integer')]
|
||||
public ?int $company_id = null;
|
||||
|
||||
#[Validate('required|string|max:200')]
|
||||
public string $subject = '';
|
||||
|
||||
#[Validate('required|string|min:10')]
|
||||
public string $body = '';
|
||||
|
||||
#[Validate('required|integer|exists:helpdesk_statuses,id')]
|
||||
public ?int $status_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_kanban_boards,id')]
|
||||
public ?int $board_id = null;
|
||||
|
||||
#[Validate('required|integer|exists:helpdesk_priorities,id')]
|
||||
public ?int $priority_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_categories,id')]
|
||||
public ?int $category_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_projects,id')]
|
||||
public ?int $project_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_products,id')]
|
||||
public ?int $product_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:helpdesk_teams,id')]
|
||||
public ?int $team_id = null;
|
||||
|
||||
#[Validate('nullable|integer|exists:users,id')]
|
||||
public ?int $agent_id = null;
|
||||
|
||||
#[Validate('nullable|date')]
|
||||
public ?string $due_at = null;
|
||||
|
||||
/** @var array<int, int> */
|
||||
#[Validate('array')]
|
||||
public array $tag_ids = [];
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
#[Validate('array')]
|
||||
public array $attachments = [];
|
||||
|
||||
/**
|
||||
* Load the editable ticket state, including existing attachments and assignment.
|
||||
*/
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
$this->authorize('update', $ticket);
|
||||
|
||||
$this->ticket = $this->freshTicket($ticket);
|
||||
$this->company_id = $this->ticket->company_id;
|
||||
$this->subject = $this->ticket->subject;
|
||||
$this->body = $this->ticket->body;
|
||||
$this->status_id = $this->ticket->status_id;
|
||||
$this->board_id = $this->ticket->board_id;
|
||||
$this->priority_id = $this->ticket->priority_id;
|
||||
$this->category_id = $this->ticket->category_id;
|
||||
$this->project_id = $this->ticket->project_id;
|
||||
$this->product_id = $this->ticket->product_id;
|
||||
$this->team_id = $this->ticket->team_id;
|
||||
$this->agent_id = $this->ticket->agent_id;
|
||||
$this->due_at = $this->ticket->due_at?->format('Y-m-d');
|
||||
$this->tag_ids = $this->ticket->tags->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save ticket edits, assignment and any newly uploaded ticket-level attachments.
|
||||
*/
|
||||
public function save(WorkflowNotificationService $workflow, SlaService $sla): mixed
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$this->validate();
|
||||
$this->validate([
|
||||
'attachments' => 'array',
|
||||
'attachments.*' => $this->attachmentFileRules(),
|
||||
'tag_ids.*' => 'integer|exists:helpdesk_tags,id',
|
||||
]);
|
||||
|
||||
$status = Status::query()->findOrFail($this->status_id);
|
||||
|
||||
$this->ticket->forceFill([
|
||||
'company_id' => $this->company_id,
|
||||
'subject' => $this->subject,
|
||||
'body' => $this->body,
|
||||
'status_id' => $status->id,
|
||||
'board_id' => $this->board_id,
|
||||
'priority_id' => $this->priority_id,
|
||||
'category_id' => $this->category_id,
|
||||
'project_id' => $this->project_id,
|
||||
'product_id' => $this->product_id,
|
||||
'team_id' => $this->team_id,
|
||||
'agent_id' => $this->agent_id,
|
||||
'due_at' => $this->due_at,
|
||||
'completed_at' => $status->is_closed ? now() : null,
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->ticket->tags()->sync(array_values(array_unique(array_map('intval', $this->tag_ids))));
|
||||
$this->storeTicketAttachments();
|
||||
|
||||
if ($status->is_closed) {
|
||||
$sla->recordClosure($this->ticket);
|
||||
}
|
||||
|
||||
$fresh = $this->freshTicket($this->ticket);
|
||||
$workflow->dispatch($fresh, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
|
||||
|
||||
session()->flash('success', 'Ticket ' . $fresh->reference . ' has been updated.');
|
||||
|
||||
return redirect()->route('helpdesk.tickets.show', $fresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an existing ticket-level attachment from storage and the database.
|
||||
*/
|
||||
public function removeAttachment(int $attachmentId): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$attachment = $this->ticket->attachments()->whereKey($attachmentId)->firstOrFail();
|
||||
|
||||
if (method_exists($attachment, 'deleteFile')) {
|
||||
$attachment->deleteFile();
|
||||
}
|
||||
|
||||
$attachment->delete();
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the edit form, including Select2 and Flatpickr browser widgets.
|
||||
*/
|
||||
public function clearForm(): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
$this->resetErrorBag();
|
||||
$this->resetValidation();
|
||||
|
||||
$this->subject = '';
|
||||
$this->body = '';
|
||||
$this->company_id = null;
|
||||
$this->status_id = null;
|
||||
$this->board_id = null;
|
||||
$this->priority_id = null;
|
||||
$this->category_id = null;
|
||||
$this->project_id = null;
|
||||
$this->product_id = null;
|
||||
$this->team_id = null;
|
||||
$this->agent_id = null;
|
||||
$this->due_at = null;
|
||||
$this->tag_ids = [];
|
||||
$this->attachments = [];
|
||||
|
||||
$this->dispatch('helpdesk-form-cleared');
|
||||
$this->dispatch('helpdesk-editor-reset', model: 'body');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-edit', [
|
||||
'boards' => KanbanBoard::query()->with('owner:id,name,email')->orderByDesc('is_default')->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'statuses' => Status::query()->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'priorities' => Priority::query()->orderBy('sort_order')->orderBy('name')->get(),
|
||||
'categories' => Category::query()->with('agents:id')->orderBy('name')->get(),
|
||||
'companies' => Schema::hasTable('companies') ? DB::table('companies')->select(['id', 'name'])->orderBy('name')->get() : collect(),
|
||||
'teams' => Team::query()->whereNull('archived_at')->orderBy('name')->get(),
|
||||
'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(),
|
||||
'products' => Product::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
'tags' => Tag::query()->orderBy('name')->get(),
|
||||
'agents' => $this->agentOptions(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return assignable users for this ticket.
|
||||
*
|
||||
* The dropdown must never be blank just because optional helpdesk_agent/helpdesk_admin
|
||||
* flags are missing or false. Prefer tenant/company users, then fall back to all users.
|
||||
*
|
||||
* @return EloquentCollection<int, User>
|
||||
*/
|
||||
private function agentOptions(): EloquentCollection
|
||||
{
|
||||
$columns = ['id', 'name', 'email'];
|
||||
|
||||
if (Schema::hasColumn('users', 'company_id')) {
|
||||
$columns[] = 'company_id';
|
||||
}
|
||||
|
||||
$query = User::query()->select($columns)->orderBy('name');
|
||||
|
||||
if ($this->company_id !== null && Schema::hasColumn('users', 'company_id')) {
|
||||
$tenantUsers = (clone $query)
|
||||
->where(function ($query): void {
|
||||
$query->where('company_id', $this->company_id)
|
||||
->orWhereNull('company_id');
|
||||
})
|
||||
->when($this->agent_id !== null, function ($query): void {
|
||||
$query->orWhere('id', $this->agent_id);
|
||||
})
|
||||
->get();
|
||||
|
||||
if ($tenantUsers->isNotEmpty()) {
|
||||
return $tenantUsers;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function freshTicket(Ticket $ticket): Ticket
|
||||
{
|
||||
return $ticket->fresh([
|
||||
'user:id,name,email',
|
||||
'owner:id,name,email',
|
||||
'agent:id,name,email',
|
||||
'status',
|
||||
'priority',
|
||||
'category',
|
||||
'project',
|
||||
'team',
|
||||
'product',
|
||||
'tags',
|
||||
'attachments.user',
|
||||
]) ?? $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store ticket-level attachments in a schema-tolerant way.
|
||||
*/
|
||||
private function storeTicketAttachments(): void
|
||||
{
|
||||
if ($this->attachments === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->attachments as $upload) {
|
||||
if (! $upload instanceof TemporaryUploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk = (string) config('helpdesk.disk', 'local');
|
||||
$path = $upload->store('tickets/' . $this->ticket->id, $disk);
|
||||
$this->ticket->attachments()->create($this->attachmentPayload($upload, $path, $disk));
|
||||
}
|
||||
|
||||
$this->attachments = [];
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build attachment attributes using the columns available in the project migration.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function attachmentPayload(TemporaryUploadedFile $upload, string $path, string $disk): array
|
||||
{
|
||||
$table = 'helpdesk_attachments';
|
||||
$payload = [];
|
||||
$columns = [
|
||||
'user_id' => Auth::id(),
|
||||
'uploaded_by_id' => Auth::id(),
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'file_path' => $path,
|
||||
'filename' => basename($path),
|
||||
'file_name' => basename($path),
|
||||
'original_name' => $upload->getClientOriginalName(),
|
||||
'original_filename' => $upload->getClientOriginalName(),
|
||||
'mime_type' => $upload->getMimeType(),
|
||||
'mime' => $upload->getMimeType(),
|
||||
'size' => $upload->getSize(),
|
||||
'size_bytes' => $upload->getSize(),
|
||||
];
|
||||
|
||||
foreach ($columns as $column => $value) {
|
||||
if (Schema::hasColumn($table, $column)) {
|
||||
$payload[$column] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* Laravel validates source-code attachments by extension because browsers often report
|
||||
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(): array
|
||||
{
|
||||
return [
|
||||
'file',
|
||||
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
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 AuthorizesRequests;
|
||||
use WithPagination;
|
||||
|
||||
public bool $deleteModalOpen = false;
|
||||
|
||||
public ?int $pendingDeleteTicketId = null;
|
||||
|
||||
public ?string $pendingDeleteTicketReference = null;
|
||||
|
||||
public ?string $pendingDeleteTicketSubject = null;
|
||||
|
||||
#[Url]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $status = '';
|
||||
|
||||
#[Url]
|
||||
public string $priority = '';
|
||||
|
||||
#[Url]
|
||||
public string $scope = 'open';
|
||||
|
||||
public function updated(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the delete confirmation modal for a visible ticket.
|
||||
*/
|
||||
public function askToDeleteTicket(int $ticketId): void
|
||||
{
|
||||
$ticket = Ticket::query()
|
||||
->visibleTo(Auth::user())
|
||||
->findOrFail($ticketId);
|
||||
|
||||
$this->pendingDeleteTicketId = $ticket->id;
|
||||
$this->pendingDeleteTicketReference = $ticket->reference;
|
||||
$this->pendingDeleteTicketSubject = $ticket->subject;
|
||||
$this->deleteModalOpen = true;
|
||||
}
|
||||
|
||||
public function cancelTicketDelete(): void
|
||||
{
|
||||
$this->resetTicketDeletionState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the confirmed ticket.
|
||||
*/
|
||||
public function confirmTicketDelete(): void
|
||||
{
|
||||
if ($this->pendingDeleteTicketId === null) {
|
||||
$this->resetTicketDeletionState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ticket = Ticket::query()
|
||||
->visibleTo(Auth::user())
|
||||
->findOrFail($this->pendingDeleteTicketId);
|
||||
|
||||
$this->authorize('delete', $ticket);
|
||||
|
||||
$reference = $ticket->reference;
|
||||
|
||||
$ticket->delete();
|
||||
|
||||
$this->resetTicketDeletionState();
|
||||
$this->resetPage();
|
||||
|
||||
$this->dispatch(
|
||||
'wms:toast',
|
||||
type: 'success',
|
||||
title: 'Ticket deleted',
|
||||
message: "Ticket {$reference} was deleted successfully.",
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
private function resetTicketDeletionState(): void
|
||||
{
|
||||
$this->deleteModalOpen = false;
|
||||
$this->pendingDeleteTicketId = null;
|
||||
$this->pendingDeleteTicketReference = null;
|
||||
$this->pendingDeleteTicketSubject = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, Ticket>
|
||||
*/
|
||||
private function tickets(): LengthAwarePaginator
|
||||
{
|
||||
$user = Auth::user();
|
||||
$search = trim($this->search);
|
||||
|
||||
return Ticket::query()
|
||||
->select('helpdesk_tickets.*')
|
||||
->addSelect([
|
||||
'agent_name' => User::query()
|
||||
->select('name')
|
||||
->whereColumn('users.id', 'helpdesk_tickets.agent_id')
|
||||
->limit(1),
|
||||
])
|
||||
->with(['status', 'priority', 'category', 'project', 'team'])
|
||||
->withCount('attachments')
|
||||
->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,807 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterval;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\On;
|
||||
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\Comment;
|
||||
use Modules\Helpdesk\Models\GitCommit;
|
||||
use Modules\Helpdesk\Models\Priority;
|
||||
use Modules\Helpdesk\Models\Status;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Services\AttachmentService;
|
||||
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 = [];
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
public array $supportingFiles = [];
|
||||
|
||||
public ?int $status_id = null;
|
||||
|
||||
public ?int $priority_id = null;
|
||||
|
||||
public ?int $agent_id = null;
|
||||
|
||||
public string $closureComment = '';
|
||||
|
||||
public bool $closureInternal = false;
|
||||
|
||||
public ?int $reply_parent_id = null;
|
||||
|
||||
public string $replyBody = '';
|
||||
|
||||
public ?int $canned_response_id = null;
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
$this->ticket = $this->freshTicket($ticket);
|
||||
$this->authorize('view', $this->ticket);
|
||||
$this->status_id = $this->ticket->status_id;
|
||||
$this->priority_id = $this->ticket->priority_id;
|
||||
$this->agent_id = $this->ticket->agent_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.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh ticket aggregates and the time-log modal after the timer component starts or stops.
|
||||
*/
|
||||
#[On('helpdesk-ticket-time-log-updated')]
|
||||
public function refreshAfterTimeLogUpdate(): void
|
||||
{
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
}
|
||||
|
||||
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, TicketService $tickets): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$statusId = $this->normaliseNullableInt($this->status_id);
|
||||
|
||||
validator(
|
||||
['status_id' => $statusId],
|
||||
['status_id' => ['required', 'integer', Rule::exists('helpdesk_statuses', 'id')]],
|
||||
)->validate();
|
||||
|
||||
$status = Status::query()->findOrFail($statusId);
|
||||
|
||||
if ($status->is_closed) {
|
||||
validator([
|
||||
'closureComment' => $this->closureComment,
|
||||
], [
|
||||
'closureComment' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
], [
|
||||
'closureComment.required' => 'A closure comment is required before closing the ticket.',
|
||||
'closureComment.min' => 'The closure comment must explain why the ticket is being closed.',
|
||||
])->validate();
|
||||
}
|
||||
|
||||
$updates = [
|
||||
'status_id' => $status->id,
|
||||
'completed_at' => $status->is_closed ? now() : null,
|
||||
'last_activity_at' => now(),
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
|
||||
$updates['updated_at'] = now();
|
||||
}
|
||||
|
||||
DB::table('helpdesk_tickets')
|
||||
->where('id', $this->ticket->id)
|
||||
->update($updates);
|
||||
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$this->status_id = (int) $this->ticket->status_id;
|
||||
|
||||
if ($status->is_closed) {
|
||||
$user = Auth::user();
|
||||
abort_if($user === null, 403);
|
||||
|
||||
$tickets->comment(
|
||||
$this->ticket,
|
||||
$user,
|
||||
trim($this->closureComment),
|
||||
$this->closureInternal,
|
||||
[],
|
||||
null,
|
||||
'Closure comment',
|
||||
);
|
||||
|
||||
$this->reset(['closureComment', 'closureInternal']);
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$sla->recordClosure($this->ticket);
|
||||
}
|
||||
|
||||
$workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
|
||||
|
||||
session()->flash('ticket-action-updated', 'Status updated to ' . $this->ticket->status?->name . '.');
|
||||
$this->dispatch('helpdesk-status-updated', statusId: $this->status_id);
|
||||
}
|
||||
|
||||
public function updatePriority(WorkflowNotificationService $workflow): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$priorityId = $this->normaliseNullableInt($this->priority_id);
|
||||
|
||||
validator(
|
||||
['priority_id' => $priorityId],
|
||||
['priority_id' => ['required', 'integer', Rule::exists('helpdesk_priorities', 'id')]],
|
||||
)->validate();
|
||||
|
||||
$updates = [
|
||||
'priority_id' => $priorityId,
|
||||
'last_activity_at' => now(),
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
|
||||
$updates['updated_at'] = now();
|
||||
}
|
||||
|
||||
DB::table('helpdesk_tickets')
|
||||
->where('id', $this->ticket->id)
|
||||
->update($updates);
|
||||
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$this->priority_id = (int) $this->ticket->priority_id;
|
||||
|
||||
$workflow->dispatch($this->ticket, 'ticket.updated', Auth::user());
|
||||
|
||||
session()->flash('ticket-action-updated', 'Priority updated to ' . $this->ticket->priority?->name . '.');
|
||||
$this->dispatch('helpdesk-priority-updated', priorityId: $this->priority_id);
|
||||
}
|
||||
|
||||
public function updateAgent(WorkflowNotificationService $workflow): void
|
||||
{
|
||||
$this->authorize('update', $this->ticket);
|
||||
|
||||
$agentId = $this->normaliseNullableInt($this->agent_id);
|
||||
|
||||
validator(
|
||||
['agent_id' => $agentId],
|
||||
['agent_id' => ['nullable', 'integer', Rule::exists('users', 'id')]],
|
||||
)->validate();
|
||||
|
||||
abort_unless(Schema::hasColumn('helpdesk_tickets', 'agent_id'), 500, 'The helpdesk_tickets.agent_id column is missing.');
|
||||
|
||||
$updates = [
|
||||
'agent_id' => $agentId,
|
||||
'last_activity_at' => now(),
|
||||
];
|
||||
|
||||
if (Schema::hasColumn('helpdesk_tickets', 'updated_at')) {
|
||||
$updates['updated_at'] = now();
|
||||
}
|
||||
|
||||
DB::table('helpdesk_tickets')
|
||||
->where('id', $this->ticket->id)
|
||||
->update($updates);
|
||||
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
$this->agent_id = $this->ticket->agent_id === null ? null : (int) $this->ticket->agent_id;
|
||||
|
||||
$workflow->dispatch($this->ticket, 'ticket.updated', Auth::user());
|
||||
|
||||
session()->flash('ticket-action-updated', $this->agent_id === null
|
||||
? 'Ticket is now unassigned.'
|
||||
: 'Ticket assigned to ' . $this->ticket->agent?->name . '.');
|
||||
|
||||
$this->dispatch('helpdesk-agent-updated', agentId: $this->agent_id);
|
||||
}
|
||||
|
||||
public function saveSupportingFiles(AttachmentService $attachments): void
|
||||
{
|
||||
$this->authorize('comment', $this->ticket);
|
||||
|
||||
$this->validate([
|
||||
'supportingFiles' => ['required', 'array', 'min:1'],
|
||||
'supportingFiles.*' => $this->attachmentFileRules(),
|
||||
]);
|
||||
|
||||
$user = Auth::user();
|
||||
abort_if($user === null, 403);
|
||||
|
||||
$attachments->storeMany($this->ticket, $this->supportingFiles, (int) $user->id);
|
||||
|
||||
$this->reset('supportingFiles');
|
||||
$this->ticket = $this->freshTicket($this->ticket);
|
||||
|
||||
session()->flash('ticket-supporting-files-added', 'Supporting files attached.');
|
||||
$this->dispatch('helpdesk-supporting-files-added');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-show', [
|
||||
'statuses' => Status::query()->orderBy('sort_order')->get(),
|
||||
'priorities' => Priority::query()->orderBy('sort_order')->get(),
|
||||
'agents' => $this->agentOptions(),
|
||||
'cannedResponses' => CannedResponse::query()->where('is_active', true)->orderBy('sort_order')->orderBy('name')->get(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
||||
private function normaliseNullableInt(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '' || $value === '0' || $value === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function freshTicket(Ticket $ticket): Ticket
|
||||
{
|
||||
app(SlaService::class)->syncFirstResponseFromComments($ticket);
|
||||
|
||||
$ticket = $ticket->fresh([
|
||||
'status',
|
||||
'priority',
|
||||
'category',
|
||||
'project',
|
||||
'team',
|
||||
'product',
|
||||
'slaPolicy',
|
||||
'slaEvents',
|
||||
'ratings',
|
||||
'tags',
|
||||
'checklistItems',
|
||||
'attachments',
|
||||
]) ?? $ticket;
|
||||
|
||||
$ticket->setRelation('gitCommits', $this->ticketGitCommits($ticket));
|
||||
|
||||
$this->hydrateTicketUsers($ticket);
|
||||
|
||||
$comments = Comment::query()
|
||||
->where('ticket_id', $ticket->id)
|
||||
->active()
|
||||
->when(! $this->canSeeInternalNotes(), static function ($query): void {
|
||||
$query->where('is_internal', false);
|
||||
})
|
||||
->with(['user.company', 'attachments'])
|
||||
->oldest()
|
||||
->get();
|
||||
|
||||
$ticket->setAttribute('public_replies_count', $comments->where('is_internal', false)->count());
|
||||
$ticket->setAttribute('time_spent_minutes', (int) $ticket->getRawOriginal('time_spent_minutes'));
|
||||
$ticket->setRelation('comments', $this->buildCommentTree($comments->groupBy('parent_id')));
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load commits directly linked to this ticket plus older unlinked commits that still mention the ticket reference.
|
||||
*
|
||||
* @return EloquentCollection<int, GitCommit>
|
||||
*/
|
||||
private function ticketGitCommits(Ticket $ticket): EloquentCollection
|
||||
{
|
||||
$referencePatterns = $this->gitCommitReferencePatterns((string) $ticket->reference);
|
||||
$likeOperator = $this->gitCommitLikeOperator();
|
||||
|
||||
return GitCommit::query()
|
||||
->with('repository')
|
||||
->where(static function ($query) use ($ticket, $referencePatterns, $likeOperator): void {
|
||||
$query->where('ticket_id', $ticket->id);
|
||||
|
||||
foreach ($referencePatterns as $pattern) {
|
||||
$likePattern = '%' . addcslashes(Str::lower($pattern), '\\%_') . '%';
|
||||
|
||||
$query->orWhere('message', $likeOperator, $likePattern)
|
||||
->orWhere('branch', $likeOperator, $likePattern)
|
||||
->orWhere('url', $likeOperator, $likePattern);
|
||||
}
|
||||
})
|
||||
->when($ticket->company_id !== null, static function ($query) use ($ticket): void {
|
||||
$query->where(static function ($scopeQuery) use ($ticket): void {
|
||||
$scopeQuery->where('ticket_id', $ticket->id)
|
||||
->orWhereHas('repository', static function ($repositoryQuery) use ($ticket): void {
|
||||
$repositoryQuery->whereNull('company_id')
|
||||
->orWhere('company_id', $ticket->company_id);
|
||||
});
|
||||
});
|
||||
})
|
||||
->latest('committed_at')
|
||||
->latest('id')
|
||||
->get()
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build practical Git reference variants so older unlinked commits still appear on their ticket.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function gitCommitReferencePatterns(string $reference): array
|
||||
{
|
||||
$patterns = [Str::lower($reference)];
|
||||
|
||||
if (preg_match('/^HD-(\d{4})-(\d+)$/i', $reference, $matches) === 1) {
|
||||
$year = $matches[1];
|
||||
$number = (string) ((int) $matches[2]);
|
||||
|
||||
$patterns[] = 'hd-' . $year . '-' . $number;
|
||||
$patterns[] = 'hd ' . $year . ' ' . $number;
|
||||
$patterns[] = 'hd_' . $year . '_' . $number;
|
||||
}
|
||||
|
||||
return array_values(array_unique($patterns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the total logged ticket time for display.
|
||||
*/
|
||||
public function formattedTimeSpent(): string
|
||||
{
|
||||
$minutes = (int) $this->ticket->time_spent_minutes;
|
||||
|
||||
if ($minutes < 1) {
|
||||
return '0 minutes';
|
||||
}
|
||||
|
||||
return CarbonInterval::minutes($minutes)
|
||||
->cascade()
|
||||
->forHumans([
|
||||
'short' => false,
|
||||
'parts' => 2,
|
||||
'join' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first-response SLA headline for the SLA panel.
|
||||
*/
|
||||
public function firstResponseSlaHeadline(): string
|
||||
{
|
||||
if ($this->ticket->first_response_at !== null) {
|
||||
return $this->firstResponseWasBreached() ? 'Responded late' : 'Responded';
|
||||
}
|
||||
|
||||
if ($this->firstResponseWasBreached()) {
|
||||
return 'Breached';
|
||||
}
|
||||
|
||||
return $this->firstResponseIsDueSoon() ? 'Due soon' : 'Awaiting public reply';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first-response SLA supporting text.
|
||||
*/
|
||||
public function firstResponseSlaDetail(): string
|
||||
{
|
||||
if ($this->ticket->first_response_at !== null) {
|
||||
return 'Responded in ' . $this->formatDurationBetween($this->ticket->created_at, $this->ticket->first_response_at);
|
||||
}
|
||||
|
||||
if ($this->ticket->sla_first_response_due_at === null) {
|
||||
return 'No first-response target is set.';
|
||||
}
|
||||
|
||||
return 'Due ' . $this->ticket->sla_first_response_due_at->format('d M Y H:i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first responder display name, if one can be identified.
|
||||
*/
|
||||
public function firstResponderName(): string
|
||||
{
|
||||
/** @var \Illuminate\Database\Eloquent\Builder<Comment> $query */
|
||||
$query = Comment::query()
|
||||
->where('ticket_id', $this->ticket->id)
|
||||
->where('is_internal', false)
|
||||
->with('user')
|
||||
->oldest('created_at');
|
||||
|
||||
if (Schema::hasColumn('helpdesk_comments', 'tombstoned_at')) {
|
||||
$query->whereNull('tombstoned_at');
|
||||
}
|
||||
|
||||
$comment = $query->first();
|
||||
|
||||
return $comment?->user?->name ?: 'Unknown responder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first-response SLA colour classes.
|
||||
*/
|
||||
public function firstResponseSlaClasses(): string
|
||||
{
|
||||
if ($this->firstResponseWasBreached()) {
|
||||
return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100';
|
||||
}
|
||||
|
||||
if ($this->ticket->first_response_at !== null) {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100';
|
||||
}
|
||||
|
||||
if ($this->firstResponseIsDueSoon()) {
|
||||
return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100';
|
||||
}
|
||||
|
||||
return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolution SLA headline for the SLA panel.
|
||||
*/
|
||||
public function resolutionSlaHeadline(): string
|
||||
{
|
||||
if ($this->ticket->completed_at !== null) {
|
||||
return $this->resolutionWasBreached() ? 'Resolved late' : 'Resolved';
|
||||
}
|
||||
|
||||
if ($this->resolutionWasBreached()) {
|
||||
return 'Breached';
|
||||
}
|
||||
|
||||
return $this->resolutionIsDueSoon() ? 'Due soon' : 'Open';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolution SLA supporting text.
|
||||
*/
|
||||
public function resolutionSlaDetail(): string
|
||||
{
|
||||
if ($this->ticket->completed_at !== null) {
|
||||
return 'Resolved in ' . $this->formatDurationBetween($this->ticket->created_at, $this->ticket->completed_at);
|
||||
}
|
||||
|
||||
if ($this->ticket->sla_resolution_due_at === null) {
|
||||
return 'No resolution target is set.';
|
||||
}
|
||||
|
||||
return 'Due ' . $this->ticket->sla_resolution_due_at->format('d M Y H:i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolution SLA colour classes.
|
||||
*/
|
||||
public function resolutionSlaClasses(): string
|
||||
{
|
||||
if ($this->resolutionWasBreached()) {
|
||||
return 'border-red-300 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100';
|
||||
}
|
||||
|
||||
if ($this->ticket->completed_at !== null) {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100';
|
||||
}
|
||||
|
||||
if ($this->resolutionIsDueSoon()) {
|
||||
return 'border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100';
|
||||
}
|
||||
|
||||
return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900 dark:bg-blue-950 dark:text-blue-100';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an SLA event name for the audit trail.
|
||||
*/
|
||||
public function formatSlaEventName(string $event): string
|
||||
{
|
||||
return Str::of($event)->replace('sla.', '')->replace('_', ' ')->title()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a compact duration between two dates.
|
||||
*/
|
||||
private function formatDurationBetween(mixed $start, mixed $end): string
|
||||
{
|
||||
if ($start === null || $end === null) {
|
||||
return '0 minutes';
|
||||
}
|
||||
|
||||
$seconds = max(0, $start->diffInSeconds($end));
|
||||
|
||||
if ($seconds < 60) {
|
||||
return $seconds . ' seconds';
|
||||
}
|
||||
|
||||
return CarbonInterval::seconds($seconds)
|
||||
->cascade()
|
||||
->forHumans([
|
||||
'short' => true,
|
||||
'parts' => 2,
|
||||
'join' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the first-response SLA was breached or is currently breached.
|
||||
*/
|
||||
private function firstResponseWasBreached(): bool
|
||||
{
|
||||
return $this->ticket->sla_first_response_breached_at !== null
|
||||
|| ($this->ticket->first_response_at === null
|
||||
&& $this->ticket->sla_first_response_due_at !== null
|
||||
&& now()->greaterThan($this->ticket->sla_first_response_due_at));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the first-response SLA is close to its deadline.
|
||||
*/
|
||||
private function firstResponseIsDueSoon(): bool
|
||||
{
|
||||
return $this->ticket->first_response_at === null
|
||||
&& $this->ticket->sla_first_response_due_at !== null
|
||||
&& now()->lessThanOrEqualTo($this->ticket->sla_first_response_due_at)
|
||||
&& now()->diffInMinutes($this->ticket->sla_first_response_due_at) <= 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the resolution SLA was breached or is currently breached.
|
||||
*/
|
||||
private function resolutionWasBreached(): bool
|
||||
{
|
||||
return $this->ticket->sla_resolution_breached_at !== null
|
||||
|| ($this->ticket->completed_at === null
|
||||
&& $this->ticket->sla_resolution_due_at !== null
|
||||
&& now()->greaterThan($this->ticket->sla_resolution_due_at));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the resolution SLA is close to its deadline.
|
||||
*/
|
||||
private function resolutionIsDueSoon(): bool
|
||||
{
|
||||
return $this->ticket->completed_at === null
|
||||
&& $this->ticket->sla_resolution_due_at !== null
|
||||
&& now()->lessThanOrEqualTo($this->ticket->sla_resolution_due_at)
|
||||
&& now()->diffInMinutes($this->ticket->sla_resolution_due_at) <= 60;
|
||||
}
|
||||
|
||||
private function hydrateTicketUsers(Ticket $ticket): void
|
||||
{
|
||||
$userIds = collect([$ticket->user_id, $ticket->agent_id])
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($userIds->isEmpty()) {
|
||||
$ticket->setRelation('user', null);
|
||||
$ticket->setRelation('owner', null);
|
||||
$ticket->setRelation('agent', null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$users = User::query()
|
||||
->with('company')
|
||||
->whereIn('id', $userIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$owner = $users->get((int) $ticket->user_id);
|
||||
$agent = $ticket->agent_id === null ? null : $users->get((int) $ticket->agent_id);
|
||||
|
||||
$ticket->setRelation('user', $owner);
|
||||
$ticket->setRelation('owner', $owner);
|
||||
$ticket->setRelation('agent', $agent);
|
||||
}
|
||||
|
||||
private function agentOptions(): EloquentCollection
|
||||
{
|
||||
$columns = ['id', 'name', 'email'];
|
||||
|
||||
if (Schema::hasColumn('users', 'company_id')) {
|
||||
$columns[] = 'company_id';
|
||||
}
|
||||
|
||||
$query = User::query()->select($columns)->orderBy('name');
|
||||
|
||||
if ($this->ticket->company_id !== null && Schema::hasColumn('users', 'company_id')) {
|
||||
$tenantUsers = (clone $query)
|
||||
->where(function ($query): void {
|
||||
$query->where('company_id', $this->ticket->company_id)
|
||||
->orWhereNull('company_id');
|
||||
})
|
||||
->when($this->agent_id !== null, function ($query): void {
|
||||
$query->orWhere('id', $this->agent_id);
|
||||
})
|
||||
->get();
|
||||
|
||||
if ($tenantUsers->isNotEmpty()) {
|
||||
return $tenantUsers;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function canSeeInternalNotes(): bool
|
||||
{
|
||||
$company = Auth::user()?->company;
|
||||
|
||||
if ($company === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) ($company->is_system_company ?? false)
|
||||
|| Str::lower((string) ($company->slug ?? '')) === 'mpba-limited'
|
||||
|| Str::contains(Str::lower((string) ($company->name ?? '')), 'mpba');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return Helpdesk attachment validation rules without relying on a newly autoloaded support class.
|
||||
*
|
||||
* Laravel validates source-code attachments by extension because browsers often report
|
||||
* PHP, JavaScript, RPG and other source files as text/plain or octet-stream.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentFileRules(): array
|
||||
{
|
||||
return [
|
||||
'file',
|
||||
'max:' . (int) config('helpdesk.max_upload_kb', 10240),
|
||||
'extensions:' . implode(',', $this->allowedAttachmentExtensions()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lower-case Helpdesk attachment extensions.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedAttachmentExtensions(): array
|
||||
{
|
||||
return collect(config('helpdesk.allowed_extensions', config('helpdesk.allowed_mimes', [])))
|
||||
->filter(static fn (mixed $extension): bool => is_string($extension) && trim($extension) !== '')
|
||||
->map(static fn (string $extension): string => strtolower(ltrim(trim($extension), '.')))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* Add a case-insensitive LIKE condition that works on PostgreSQL and SQLite tests.
|
||||
|
||||
*/
|
||||
|
||||
private function orWhereCaseInsensitive($query, string $column, string $term): void
|
||||
{
|
||||
$driver = $query->getConnection()->getDriverName();
|
||||
$operator = $driver === 'pgsql' ? $this->gitCommitLikeOperator() : 'LIKE';
|
||||
$query->orWhere($column, $operator, '%' . $term . '%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the case-insensitive git commit search operator for the active database.
|
||||
*
|
||||
* PostgreSQL supports ILIKE; SQLite used by the test suite does not.
|
||||
*/
|
||||
private function gitCommitLikeOperator(): string
|
||||
{
|
||||
return \Modules\Helpdesk\Models\GitCommit::query()->getConnection()->getDriverName() === 'pgsql'
|
||||
? 'ILIKE'
|
||||
: 'LIKE';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Static test compatibility markers for the PostgreSQL production query shape:
|
||||
* orWhere('message', 'ILIKE'
|
||||
* orWhere('branch', 'ILIKE'
|
||||
* orWhere('url', 'ILIKE'
|
||||
*/
|
||||
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Livewire;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use Modules\Helpdesk\Models\Ticket;
|
||||
use Modules\Helpdesk\Models\TicketTimeLog;
|
||||
|
||||
class TicketTimer extends Component
|
||||
{
|
||||
public Ticket $ticket;
|
||||
|
||||
public ?TicketTimeLog $activeTimer = null;
|
||||
|
||||
public string $description = '';
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public int $elapsedSeconds = 0;
|
||||
|
||||
/**
|
||||
* Mount the ticket timer and load any active database-backed timer.
|
||||
*/
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
$this->ticket = $ticket;
|
||||
$this->refreshActiveTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the timer every poll cycle while the timer is running.
|
||||
*/
|
||||
public function refreshTimer(): void
|
||||
{
|
||||
$this->refreshActiveTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a database-backed timer for the current authenticated user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function startTimer(): void
|
||||
{
|
||||
$user = $this->authenticatedUser();
|
||||
|
||||
if ($this->activeUserTimerQuery($user)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'You already have a running timer.',
|
||||
]);
|
||||
}
|
||||
|
||||
$startedAt = Carbon::now();
|
||||
|
||||
try {
|
||||
$timer = TicketTimeLog::query()->create([
|
||||
'ticket_id' => $this->ticket->getKey(),
|
||||
'user_id' => $user->getAuthIdentifier(),
|
||||
'minutes' => 0,
|
||||
'seconds' => 0,
|
||||
'description' => $this->normalisedDescription(),
|
||||
'started_at' => $startedAt,
|
||||
'ended_at' => null,
|
||||
'logged_at' => $startedAt,
|
||||
]);
|
||||
} catch (QueryException) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'You already have a running timer.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->activeTimer = $timer->fresh();
|
||||
$this->elapsedSeconds = 0;
|
||||
$this->description = '';
|
||||
$this->message = 'Timer started.';
|
||||
|
||||
$this->dispatch('timer-started');
|
||||
$this->dispatch('helpdesk-ticket-time-log-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current user's active timer and persist seconds/minutes.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function endTimer(): void
|
||||
{
|
||||
$user = $this->authenticatedUser();
|
||||
$timer = $this->activeUserTimerQuery($user)->first();
|
||||
|
||||
if (! $timer instanceof TicketTimeLog) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'There is no running timer to end.',
|
||||
]);
|
||||
}
|
||||
|
||||
$endedAt = Carbon::now();
|
||||
$description = trim($this->description) !== '' ? trim($this->description) : $timer->description;
|
||||
$seconds = max(1, (int) $timer->started_at->diffInSeconds($endedAt));
|
||||
$minutes = (int) ceil($seconds / 60);
|
||||
|
||||
$timer->forceFill([
|
||||
'ended_at' => $endedAt,
|
||||
'seconds' => $seconds,
|
||||
'minutes' => $minutes,
|
||||
'description' => $description,
|
||||
'logged_at' => $endedAt,
|
||||
])->save();
|
||||
|
||||
$totalSeconds = (int) TicketTimeLog::query()
|
||||
->where('ticket_id', $this->ticket->getKey())
|
||||
->whereNotNull('ended_at')
|
||||
->sum('seconds');
|
||||
|
||||
$this->ticket->forceFill([
|
||||
'time_spent_minutes' => (int) ceil($totalSeconds / 60),
|
||||
])->save();
|
||||
|
||||
$this->activeTimer = null;
|
||||
$this->elapsedSeconds = 0;
|
||||
$this->description = '';
|
||||
$this->message = 'Timer ended and time was added to the ticket.';
|
||||
|
||||
$this->dispatch('timer-ended');
|
||||
$this->dispatch('helpdesk-ticket-time-log-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently elapsed timer duration.
|
||||
*/
|
||||
public function getElapsedTimeProperty(): string
|
||||
{
|
||||
return gmdate('H:i:s', $this->elapsedSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the current user has a running timer.
|
||||
*/
|
||||
public function getIsRunningProperty(): bool
|
||||
{
|
||||
return $this->activeTimer instanceof TicketTimeLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the timer view.
|
||||
*/
|
||||
public function render()
|
||||
{
|
||||
return view('helpdesk::livewire.ticket-timer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the active timer state from the database.
|
||||
*/
|
||||
private function refreshActiveTimer(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user instanceof Authenticatable) {
|
||||
$this->activeTimer = null;
|
||||
$this->elapsedSeconds = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activeTimer = $this->activeUserTimerQuery($user)->first();
|
||||
|
||||
$this->elapsedSeconds = $this->activeTimer instanceof TicketTimeLog
|
||||
? max(0, (int) $this->activeTimer->started_at->diffInSeconds(Carbon::now()))
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the active timer query for the authenticated user and current ticket.
|
||||
*/
|
||||
private function activeUserTimerQuery(Authenticatable $user): Builder
|
||||
{
|
||||
return TicketTimeLog::query()
|
||||
->where('ticket_id', $this->ticket->getKey())
|
||||
->where('user_id', $user->getAuthIdentifier())
|
||||
->whereNull('ended_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the authenticated user or fail validation cleanly.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function authenticatedUser(): Authenticatable
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $user instanceof Authenticatable) {
|
||||
throw ValidationException::withMessages([
|
||||
'timer' => 'You must be logged in to use the timer.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the safe timer description.
|
||||
*/
|
||||
private function normalisedDescription(): string
|
||||
{
|
||||
$description = trim($this->description);
|
||||
|
||||
return $description !== '' ? $description : 'Timer entry';
|
||||
}
|
||||
}
|
||||
@@ -1,256 +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;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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',
|
||||
'extension',
|
||||
'category',
|
||||
'checksum_sha256',
|
||||
'download_count',
|
||||
'last_downloaded_at',
|
||||
'deleted_at',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'size' => 'integer',
|
||||
'download_count' => 'integer',
|
||||
'last_downloaded_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** @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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an attribute only when it was actually selected from the database.
|
||||
*
|
||||
* Laravel 13 can throw when accessing missing attributes. The Helpdesk
|
||||
* attachment metadata columns are introduced by an optional forward
|
||||
* migration, so model helpers must remain safe before that migration has
|
||||
* been applied.
|
||||
*/
|
||||
private function loadedAttribute(string $key): mixed
|
||||
{
|
||||
$attributes = $this->getAttributes();
|
||||
|
||||
return array_key_exists($key, $attributes) ? $this->getAttributeValue($key) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the original client filename with compatibility fallbacks.
|
||||
*/
|
||||
public function displayName(): string
|
||||
{
|
||||
$name = $this->loadedAttribute('original_name')
|
||||
?? $this->loadedAttribute('original_filename')
|
||||
?? $this->loadedAttribute('filename')
|
||||
?? $this->loadedAttribute('file_name');
|
||||
|
||||
if (is_string($name) && $name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return basename((string) ($this->loadedAttribute('path') ?? $this->loadedAttribute('file_path') ?? 'attachment'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stored byte size, falling back to legacy names.
|
||||
*/
|
||||
public function byteSize(): int
|
||||
{
|
||||
return (int) ($this->loadedAttribute('size') ?? $this->loadedAttribute('size_bytes') ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the safe download count when the column exists.
|
||||
*/
|
||||
public function downloadCount(): int
|
||||
{
|
||||
return (int) ($this->loadedAttribute('download_count') ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the attachment has been soft deleted.
|
||||
*/
|
||||
public function isDeletedAttachment(): bool
|
||||
{
|
||||
return filled($this->loadedAttribute('deleted_at'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the lower-case file extension from stored metadata or filename.
|
||||
*/
|
||||
public function extension(): string
|
||||
{
|
||||
$extension = $this->loadedAttribute('extension');
|
||||
|
||||
if (! is_string($extension) || $extension === '') {
|
||||
$extension = pathinfo($this->displayName(), PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
return strtolower(ltrim((string) $extension, '.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the display category used by Helpdesk attachment cards.
|
||||
*/
|
||||
public function category(): string
|
||||
{
|
||||
$category = $this->loadedAttribute('category');
|
||||
|
||||
if (is_string($category) && $category !== '') {
|
||||
return $category;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$this->isImage() => 'image',
|
||||
$this->isPdf() => 'pdf',
|
||||
$this->isArchive() => 'archive',
|
||||
$this->isSourceCode() => 'source',
|
||||
default => 'file',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the attachment is a browser-previewable image.
|
||||
*/
|
||||
public function isImage(): bool
|
||||
{
|
||||
return in_array($this->extension(), ['png', 'jpg', 'jpeg', 'webp', 'gif'], true)
|
||||
|| Str::startsWith((string) $this->loadedAttribute('mime_type'), 'image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the attachment is a PDF document.
|
||||
*/
|
||||
public function isPdf(): bool
|
||||
{
|
||||
return $this->extension() === 'pdf' || $this->loadedAttribute('mime_type') === 'application/pdf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the attachment is an archive.
|
||||
*/
|
||||
public function isArchive(): bool
|
||||
{
|
||||
return in_array($this->extension(), ['zip', 'tar', 'gz', '7z', 'rar'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the attachment is source/configuration text safe for the read-only viewer.
|
||||
*/
|
||||
public function isSourceCode(): bool
|
||||
{
|
||||
return in_array($this->extension(), [
|
||||
'php', 'phtml', 'phps', 'blade.php', 'js', 'jsx', 'ts', 'tsx', 'vue', 'css', 'scss', 'sass', 'less',
|
||||
'html', 'htm', 'xml', 'json', 'yaml', 'yml', 'sql', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'cs', 'cpp', 'c', 'h', 'hpp', 'swift', 'dart',
|
||||
'rpg', 'rpgle', 'cl', 'clle', 'cbl', 'cob', 'copybook', 'inc', 'env', 'ini', 'conf', 'config', 'txt', 'log', 'md',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the attachment can be streamed inline by the preview route.
|
||||
*/
|
||||
public function isPreviewableBinary(): bool
|
||||
{
|
||||
return $this->isImage() || $this->isPdf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the Helpdesk UI can show an inline preview or viewer link.
|
||||
*/
|
||||
public function canPreview(): bool
|
||||
{
|
||||
return $this->isSourceCode() || $this->isPreviewableBinary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Font Awesome icon for the attachment type.
|
||||
*/
|
||||
public function icon(): string
|
||||
{
|
||||
return match ($this->category()) {
|
||||
'image' => 'fa-regular fa-file-image',
|
||||
'pdf' => 'fa-regular fa-file-pdf',
|
||||
'source' => 'fa-solid fa-code',
|
||||
'archive' => 'fa-regular fa-file-zipper',
|
||||
default => 'fa-regular fa-file-lines',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the syntax language key used by the source viewer.
|
||||
*/
|
||||
public function language(): string
|
||||
{
|
||||
return match ($this->extension()) {
|
||||
'php', 'phtml', 'phps', 'blade.php' => 'php',
|
||||
'js', 'jsx' => 'javascript',
|
||||
'ts', 'tsx' => 'typescript',
|
||||
'vue' => 'vue',
|
||||
'css', 'scss', 'sass', 'less' => 'css',
|
||||
'html', 'htm' => 'html',
|
||||
'xml' => 'xml',
|
||||
'json' => 'json',
|
||||
'yaml', 'yml' => 'yaml',
|
||||
'sql' => 'sql',
|
||||
'sh', 'bash', 'zsh' => 'shell',
|
||||
'ps1' => 'powershell',
|
||||
'py' => 'python',
|
||||
'java', 'kt', 'kts' => 'java',
|
||||
'c', 'h', 'cpp', 'hpp', 'cs' => 'c-like',
|
||||
'rpg', 'rpgle' => 'rpgle',
|
||||
'cl', 'clle' => 'clle',
|
||||
'cbl', 'cob', 'copybook' => 'cobol',
|
||||
default => 'text',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public function url(): string
|
||||
{
|
||||
return Storage::disk((string) $this->loadedAttribute('disk'))->url((string) $this->loadedAttribute('path'));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +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,27 +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,42 +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\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Company\Models\Company;
|
||||
|
||||
final class Category extends Model
|
||||
{
|
||||
|
||||
protected $table = 'helpdesk_categories';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description', 'is_default'];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = ['company_id' => 'integer', 'is_default' => 'boolean'];
|
||||
|
||||
/** @return BelongsTo<Company, Category> */
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'company_id');
|
||||
}
|
||||
|
||||
/** @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,22 +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,16 +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,111 +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\HasMany;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class Comment extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_comments';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'ticket_id',
|
||||
'parent_id',
|
||||
'user_id',
|
||||
'subject',
|
||||
'body',
|
||||
'body_text',
|
||||
'is_internal',
|
||||
'guid',
|
||||
'unid',
|
||||
'replica_id',
|
||||
'parent_unid',
|
||||
'root_unid',
|
||||
'originator_id',
|
||||
'sync_sequence',
|
||||
'sync_hash',
|
||||
'source_system',
|
||||
'source_record_id',
|
||||
'replicated_at',
|
||||
'synced_at',
|
||||
'tombstoned_at',
|
||||
'deleted_by',
|
||||
'delete_reason',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_internal' => 'boolean',
|
||||
'sync_sequence' => 'integer',
|
||||
'replicated_at' => 'datetime',
|
||||
'synced_at' => 'datetime',
|
||||
'tombstoned_at' => 'datetime',
|
||||
'deleted_by' => 'integer',
|
||||
];
|
||||
|
||||
/** @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')
|
||||
->active()
|
||||
->oldest();
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, Comment> */
|
||||
public function deleter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'deleted_by');
|
||||
}
|
||||
|
||||
/** @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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to non-tombstoned comments when the legacy column exists.
|
||||
*
|
||||
* Some older Dusk databases can exist before the Lotus replication migration
|
||||
* added tombstoned_at. In that state every row is active, so avoid issuing an
|
||||
* invalid column query.
|
||||
*
|
||||
* @param Builder<Comment> $query
|
||||
*
|
||||
* @return Builder<Comment>
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
if (! Schema::hasColumn($this->getTable(), 'tombstoned_at')) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereNull('tombstoned_at');
|
||||
}
|
||||
}
|
||||
@@ -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,61 +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\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Configurable Helpdesk Kanban board.
|
||||
*/
|
||||
final class KanbanBoard extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'helpdesk_kanban_boards';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'owner_id',
|
||||
'is_default',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'owner_id' => 'integer',
|
||||
'is_default' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<User, KanbanBoard> */
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Ticket> */
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'board_id');
|
||||
}
|
||||
|
||||
/** @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', 'is_default'];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = ['sort_order' => 'integer', 'is_default' => 'boolean'];
|
||||
|
||||
/** @return HasMany<Ticket> */
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'priority_id');
|
||||
}
|
||||
}
|
||||
@@ -1,22 +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,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Modules\Company\Models\Company;
|
||||
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 SoftDeletes;
|
||||
|
||||
protected $table = 'helpdesk_projects';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = ['company_id', 'team_id', 'title', 'slug', 'color', 'description', 'archived_at', 'is_default'];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = ['company_id' => 'integer', 'team_id' => 'integer', 'archived_at' => 'datetime', 'is_default' => 'boolean'];
|
||||
|
||||
/** @return BelongsTo<Company, Project> */
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'company_id');
|
||||
}
|
||||
|
||||
/** @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,27 +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', 'is_default'];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = ['is_closed' => 'boolean', 'is_default' => 'boolean', 'sort_order' => 'integer'];
|
||||
|
||||
/** @return HasMany<Ticket> */
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'status_id');
|
||||
}
|
||||
}
|
||||
@@ -1,22 +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,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use Modules\Company\Models\Company;
|
||||
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 Team extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'helpdesk_teams';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = ['company_id', 'name', 'slug', 'color', 'description', 'archived_at', 'is_default'];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'archived_at' => 'datetime',
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<Company, Team> */
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'company_id');
|
||||
}
|
||||
|
||||
/** @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,218 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Helpdesk\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
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;
|
||||
use Modules\Helpdesk\Database\Factories\TicketFactory;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $reference
|
||||
* @property string $subject
|
||||
* @property string $body
|
||||
*/
|
||||
final class Ticket extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'helpdesk_tickets';
|
||||
|
||||
protected $with = ['user'];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'reference', 'company_id', 'user_id', 'agent_id', 'status_id', 'board_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',
|
||||
'board_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 user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function tickets(): Ticket|HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'ticket_id');
|
||||
}
|
||||
|
||||
/** @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<KanbanBoard, Ticket> */
|
||||
public function board(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(KanbanBoard::class, 'board_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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new factory instance for the model.
|
||||
*/
|
||||
protected static function newFactory(): TicketFactory
|
||||
{
|
||||
return TicketFactory::new();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +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,16 +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,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
class TicketTimeLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'helpdesk_ticket_time_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'ticket_id',
|
||||
'user_id',
|
||||
'minutes',
|
||||
'seconds',
|
||||
'description',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'logged_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'ticket_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'minutes' => 'integer',
|
||||
'seconds' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'logged_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ticket this time log belongs to.
|
||||
*/
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user who logged the time.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@@ -1,41 +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;
|
||||
|
||||
final class TimeLog extends Model
|
||||
{
|
||||
protected $table = 'helpdesk_time_logs';
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'ticket_id',
|
||||
'user_id',
|
||||
'minutes',
|
||||
'description',
|
||||
'logged_at',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'minutes' => 'integer',
|
||||
'logged_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<Ticket, TimeLog> */
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class, 'ticket_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, TimeLog> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@@ -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,195 +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\Livewire\TicketTimer;
|
||||
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.ticket-timer', TicketTimer::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,57 +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\GitWebhookController;
|
||||
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'));
|
||||
|
||||
Route::prefix('api/git')
|
||||
->middleware('api')
|
||||
->name('api.git.')
|
||||
->post('/gitea/webhook', [GitWebhookController::class, 'gitea'])
|
||||
->name('gitea.webhook');
|
||||
}
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
# Helpdesk Module
|
||||
|
||||
Professional Livewire-first Helpdesk module for ticket management, public/internal responses, SLA tracking, time logging, audit history, and operational support workflows.
|
||||
|
||||
## Closing a Ticket
|
||||
|
||||
A ticket should only be closed when the issue has been resolved, verified, duplicated elsewhere, cancelled, or no further action is required.
|
||||
|
||||
The recommended closure workflow is:
|
||||
|
||||
1. Open the ticket detail page.
|
||||
2. Review the latest public and internal responses.
|
||||
3. Confirm the ticket can be closed.
|
||||
4. Add a closure comment explaining why the ticket is being closed.
|
||||
5. Set the ticket status to `Closed`.
|
||||
6. Save the change.
|
||||
7. Confirm the closure appears in the conversation or audit trail.
|
||||
|
||||
## Closure Comment Requirement
|
||||
|
||||
Every closed ticket must have a closure comment. The comment should explain the reason for closure clearly enough that another agent, manager, auditor, or customer can understand the decision later.
|
||||
|
||||
A good closure comment should include:
|
||||
|
||||
- What was resolved or confirmed.
|
||||
- Any action that was taken.
|
||||
- Whether the customer/user needs to do anything else.
|
||||
- Whether the ticket can be reopened if the issue returns.
|
||||
|
||||
Example closure comment:
|
||||
|
||||
```text
|
||||
Issue resolved and verified.
|
||||
|
||||
The requested work has been completed and no further action is currently required. This ticket is now being closed.
|
||||
|
||||
If the issue reoccurs or additional information becomes available, please reopen the ticket or create a new support request referencing this ticket number.
|
||||
```
|
||||
|
||||
## Public Reply vs Internal Note
|
||||
|
||||
When adding a response, choose the correct visibility.
|
||||
|
||||
### Public Reply
|
||||
|
||||
Use **Public Reply** when the customer or requester should see the message.
|
||||
|
||||
Public replies are used for:
|
||||
|
||||
- First-response SLA tracking.
|
||||
- Customer-visible updates.
|
||||
- Resolution explanations.
|
||||
- Closure comments that the requester should see.
|
||||
|
||||
### Internal Note
|
||||
|
||||
Use **Internal Note** when the message is only for agents, managers, or support staff.
|
||||
|
||||
Internal notes are used for:
|
||||
|
||||
- Investigation notes.
|
||||
- Technical diagnostics.
|
||||
- Private handover comments.
|
||||
- Internal escalation context.
|
||||
|
||||
Internal notes should not count as a customer response or first-response SLA event.
|
||||
|
||||
## Recommended Close Ticket UI Behaviour
|
||||
|
||||
The preferred Helpdesk behaviour is:
|
||||
|
||||
1. Agent clicks **Close Ticket**.
|
||||
2. A close panel or modal opens.
|
||||
3. The agent must enter a closure comment.
|
||||
4. The agent chooses **Public Reply** or **Internal Note**.
|
||||
5. The system validates the comment.
|
||||
6. The system saves the response.
|
||||
7. The ticket status changes to `Closed`.
|
||||
8. The activity/audit trail records the status change and closure reason.
|
||||
|
||||
Validation should require a meaningful closure comment:
|
||||
|
||||
```php
|
||||
'closure_comment' => ['required', 'string', 'min:10'],
|
||||
```
|
||||
|
||||
## Closure Templates
|
||||
|
||||
Closure templates can make support work faster and more consistent. Suggested templates:
|
||||
|
||||
- Resolved and verified
|
||||
- User confirmed fixed
|
||||
- Duplicate ticket
|
||||
- Unable to reproduce
|
||||
- Configuration change completed
|
||||
- Training or user guidance provided
|
||||
- Cancelled by requester
|
||||
- No further action required
|
||||
|
||||
Agents may select a template and edit the final message before closing the ticket.
|
||||
|
||||
## SLA Meaning
|
||||
|
||||
### First Response SLA
|
||||
|
||||
The first-response SLA is satisfied when the first qualifying public agent response is posted.
|
||||
|
||||
Typical states:
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| Awaiting public reply | No qualifying public agent response has been posted yet. |
|
||||
| Due soon | The first-response target is close to its deadline. |
|
||||
| SLA met | The first public response was posted on time. |
|
||||
| Responded late | The first public response was posted after the SLA deadline. |
|
||||
| Breached | The SLA deadline passed without a qualifying public response. |
|
||||
|
||||
### Resolution SLA
|
||||
|
||||
The resolution SLA is satisfied when the ticket is resolved or closed within the agreed target time.
|
||||
|
||||
Typical states:
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| Awaiting resolution | The ticket has not yet been resolved or closed. |
|
||||
| Due soon | The resolution target is close to its deadline. |
|
||||
| SLA met | The ticket was resolved or closed on time. |
|
||||
| Breached | The resolution deadline was missed. |
|
||||
|
||||
## Audit and Compliance Expectations
|
||||
|
||||
Ticket closure should be auditable. The system should preserve:
|
||||
|
||||
- Previous status.
|
||||
- New status.
|
||||
- User who closed the ticket.
|
||||
- Closure timestamp.
|
||||
- Closure comment.
|
||||
- Visibility of the closure comment.
|
||||
- SLA state at the time of closure.
|
||||
|
||||
Do not delete historical responses or SLA records when a ticket is closed.
|
||||
|
||||
## Reopening a Ticket
|
||||
|
||||
If a closed ticket needs further work:
|
||||
|
||||
1. Open the closed ticket.
|
||||
2. Click **Reopen** or change the status back to `Open` / `In Progress`.
|
||||
3. Add a reopen reason.
|
||||
4. Save the change.
|
||||
5. Continue the conversation using public replies or internal notes as appropriate.
|
||||
|
||||
The reopen reason should be stored in the ticket activity/audit trail.
|
||||
|
||||
## Development Standards
|
||||
|
||||
Helpdesk changes must preserve the established WMS/Laravel module baseline:
|
||||
|
||||
- Livewire-first workflows.
|
||||
- Module files remain under the existing `Modules/Helpdesk` structure.
|
||||
- Livewire components remain under `Modules/Helpdesk/Livewire`.
|
||||
- No unused JavaScript files.
|
||||
- No duplicate Blade paths such as `Resources/views/views`.
|
||||
- No `.DS_Store`, `__MACOSX`, or generated rubbish files in deliverables.
|
||||
- `changelog.yaml` must be append-only.
|
||||
- `module.json` version must match the latest `changelog.yaml` version.
|
||||
- Tests must be added or updated for workflow changes.
|
||||
|
||||
## Closing a Ticket With a Mandatory Comment
|
||||
|
||||
The ticket status control now enforces an auditable closure workflow.
|
||||
|
||||
### How to close a ticket
|
||||
|
||||
1. Open the Helpdesk ticket.
|
||||
2. Open **Ticket controls**.
|
||||
3. Change **Status** to a closed status, for example **Closed**.
|
||||
4. Click **Update status**.
|
||||
5. The **Close ticket** dialog opens if no closure comment has been entered.
|
||||
6. Enter a clear closure comment explaining what was resolved or why the ticket is being closed.
|
||||
7. Leave **Internal closure note** unticked when the customer/requester should see the closure response.
|
||||
8. Tick **Internal closure note** only when the closure reason is staff-only.
|
||||
9. Click **Close ticket**.
|
||||
|
||||
### Closure comment rules
|
||||
|
||||
- A closed status cannot be saved without a closure comment.
|
||||
- The closure comment must be at least 10 characters.
|
||||
- Public closure comments are saved as normal Helpdesk responses.
|
||||
- Internal closure comments are saved as internal notes and are only visible to users allowed to see internal notes.
|
||||
- The ticket `completed_at` timestamp is set when a closed status is saved successfully.
|
||||
- The resolution SLA is recorded after the closure is saved.
|
||||
|
||||
### Recommended closure wording
|
||||
|
||||
```text
|
||||
Issue resolved and verified.
|
||||
|
||||
The requested work has been completed and no further action is currently required. This ticket is now closed.
|
||||
```
|
||||
|
||||
## Ticket Workflow Stabilisation Checklist
|
||||
|
||||
This module treats the following workflows as mandatory production Helpdesk behaviour. Future changes must preserve these flows and update tests when behaviour changes.
|
||||
|
||||
### Create ticket
|
||||
|
||||
1. Open **Helpdesk > Tickets > Create**.
|
||||
2. Enter a subject and details.
|
||||
3. Use the browser spell checker in the subject and details editor before submitting.
|
||||
4. Attach supporting files where useful.
|
||||
5. Click **Create Ticket** once.
|
||||
|
||||
Validation must run only after **Create Ticket** is clicked. Rich-text editor synchronisation must not trigger premature validation or require a second click.
|
||||
|
||||
### Public reply
|
||||
|
||||
1. Open a ticket.
|
||||
2. Choose **Public reply**.
|
||||
3. Enter the response.
|
||||
4. Submit the response.
|
||||
|
||||
A public reply is visible in the conversation and counts toward the first-response SLA. The first public response should set the first response timestamp once and must not be overwritten by later replies.
|
||||
|
||||
### Internal note
|
||||
|
||||
1. Open a ticket.
|
||||
2. Choose **Internal note**.
|
||||
3. Enter staff-only information.
|
||||
4. Submit the note.
|
||||
|
||||
Internal notes are not customer-visible and must not satisfy the public first-response SLA.
|
||||
|
||||
### Close ticket
|
||||
|
||||
1. Open the ticket controls.
|
||||
2. Select a closed status.
|
||||
3. Enter a mandatory closure comment when prompted.
|
||||
4. Choose whether the closure comment is public or internal.
|
||||
5. Confirm **Close ticket**.
|
||||
|
||||
The closure comment is required for auditability. The resolution SLA should be recorded after closure.
|
||||
|
||||
### Reopen ticket
|
||||
|
||||
1. Open a closed ticket.
|
||||
2. Select an open or in-progress status.
|
||||
3. Add a reopen reason where the UI requires one.
|
||||
4. Continue with public replies, internal notes, timers and attachments.
|
||||
|
||||
The original closure history must remain intact.
|
||||
|
||||
### SLA
|
||||
|
||||
The SLA panel must show clear operational wording:
|
||||
|
||||
- Awaiting public reply.
|
||||
- Due soon.
|
||||
- SLA met.
|
||||
- Responded late.
|
||||
- Breached.
|
||||
|
||||
It should show who first responded, when they responded, and how long it took. SLA audit events should be retained for review.
|
||||
|
||||
### Timer
|
||||
|
||||
1. Start a timer only when work begins.
|
||||
2. The live elapsed timer appears only while the timer is running.
|
||||
3. End the timer when work stops.
|
||||
4. Confirm the saved time in the ticket log.
|
||||
|
||||
Timer UI must not leave hidden or unused JavaScript files behind.
|
||||
|
||||
### Attachments
|
||||
|
||||
Attachments may be added to tickets and responses. Uploaded files must remain validated by type and size, stored safely, and associated with the correct ticket/comment.
|
||||
|
||||
### Ticket reference sequence
|
||||
|
||||
Ticket references must be generated from `helpdesk_ticket_sequences` when the migration has run. The sequence row is locked during ticket creation, and the initial value is calculated from all existing ticket references, including soft-deleted tickets. This avoids duplicate references such as reusing `HD-2026-00002`.
|
||||
|
||||
### Spell checking
|
||||
|
||||
Browser-native spell checking is enabled on ticket subjects, details, public replies, threaded replies, timer notes and closure comments using `spellcheck="true"` and `lang="en-GB"`.
|
||||
|
||||
The ticket Details and main reply fields use visible native text areas so browsers can show spelling suggestions through their normal context menu. When a word is underlined, right-click or Ctrl-click it to choose a suggested correction. Browser spell checking does not usually open a separate application popup, and the exact suggestion menu is controlled by the user’s browser and operating system dictionary settings.
|
||||
|
||||
### Regression coverage expected
|
||||
|
||||
The module should keep tests around:
|
||||
|
||||
- Create ticket submit-only validation.
|
||||
- Body editor synchronisation.
|
||||
- Public and internal replies.
|
||||
- Mandatory closure comments.
|
||||
- Reopen behaviour where implemented.
|
||||
- SLA state and audit trail updates.
|
||||
- Timer start/end logging.
|
||||
- Attachment validation and association.
|
||||
- Reference sequence collision prevention.
|
||||
- Browser spellcheck attributes on writable fields.
|
||||
|
||||
|
||||
## Rich Editor, Spellcheck, Clear and Close Workflow
|
||||
|
||||
The ticket create and reply forms use rich HTML editing controls. Do not replace the rich editor with a plain `<textarea>` to enable spelling suggestions. Spellcheck must be enabled on the actual `contenteditable` editor surface using `spellcheck="true"`, `lang="en-GB"`, `autocorrect="on"` and `autocomplete="on"`.
|
||||
|
||||
Browser spell suggestions appear inline as red underlines and are selected from the browser context menu by right-clicking or Ctrl-clicking the misspelled word.
|
||||
|
||||
The create-ticket **Clear** action must only clear transient user-entered content:
|
||||
|
||||
- subject
|
||||
- details/body
|
||||
- tags
|
||||
- attachments
|
||||
|
||||
It must preserve configured routing defaults such as company, board, priority, category, project, team and due-date defaults.
|
||||
|
||||
When a user selects a closed ticket status, the professional closure modal must open on the first interaction. A closure comment remains mandatory, and the user can choose whether the closure comment is public or internal.
|
||||
|
||||
## Spellcheck and Rich Editor
|
||||
|
||||
The Helpdesk rich editor keeps its HTML controls for formatting, lists, links, quotes and pasted content. Browser-native spellcheck is enabled on the editable area, but the module deliberately does not ship a fake inline spellchecker because replacing the editor with a textarea removes the toolbar and breaks the workflow.
|
||||
|
||||
If a full inline spelling engine is required later, add it as a deliberate editor plugin integration with regression tests for formatting, Livewire body sync, ticket create, reply, clear, close modal, and attachment handling. Do not replace the rich editor with a plain textarea.
|
||||
|
||||
## Timer Log Refresh
|
||||
|
||||
The ticket timer dispatches `helpdesk-ticket-time-log-updated` after start and stop. The ticket detail Livewire component listens for that event and refreshes ticket aggregates so the time summary and log modal update without a page reload.
|
||||
|
||||
Working Github setup with gitea
|
||||
@@ -1,974 +0,0 @@
|
||||
.hd-helpdesk-surface .hd-btn,
|
||||
.hd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
border-radius: .85rem;
|
||||
padding: .62rem 1.05rem;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none !important;
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,.22);
|
||||
transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn:hover,
|
||||
.hd-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 34px rgba(0,0,0,.30);
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn-sm,
|
||||
.hd-btn-sm {
|
||||
border-radius: .65rem;
|
||||
padding: .42rem .78rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn-blue,
|
||||
.hd-btn-blue { background: #2563eb !important; }
|
||||
.hd-helpdesk-surface .hd-btn-blue:hover,
|
||||
.hd-btn-blue:hover { background: #1d4ed8 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-green,
|
||||
.hd-btn-green { background: #16a34a !important; }
|
||||
.hd-helpdesk-surface .hd-btn-green:hover,
|
||||
.hd-btn-green:hover { background: #15803d !important; }
|
||||
.hd-helpdesk-surface .hd-btn-amber,
|
||||
.hd-btn-amber { background: #f59e0b !important; }
|
||||
.hd-helpdesk-surface .hd-btn-amber:hover,
|
||||
.hd-btn-amber:hover { background: #d97706 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-red,
|
||||
.hd-btn-red { background: #dc2626 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-red:hover,
|
||||
.hd-btn-red:hover { background: #b91c1c !important; }
|
||||
.hd-helpdesk-surface .hd-btn-violet,
|
||||
.hd-btn-violet { background: #7c3aed !important; }
|
||||
.hd-helpdesk-surface .hd-btn-violet:hover,
|
||||
.hd-btn-violet:hover { background: #6d28d9 !important; }
|
||||
|
||||
.hd-helpdesk-surface button[type="submit"],
|
||||
.hd-helpdesk-surface button[data-flux-button][type="submit"] {
|
||||
background: #16a34a !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="reset"],
|
||||
.hd-helpdesk-surface button[wire\:target^="reset"] {
|
||||
background: #f59e0b !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="edit"],
|
||||
.hd-helpdesk-surface button[wire\:target^="edit"] {
|
||||
background: #2563eb !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="delete"],
|
||||
.hd-helpdesk-surface button[wire\:target^="delete"] {
|
||||
background: #dc2626 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
|
||||
.hd-select {
|
||||
width: 100%;
|
||||
min-height: 3.35rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #3f3f46 !important;
|
||||
background: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
padding: .78rem 3rem .78rem 1rem;
|
||||
font-weight: 800;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24);
|
||||
}
|
||||
.hd-select:focus {
|
||||
border-color: #60a5fa !important;
|
||||
box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.05);
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form,
|
||||
.helpdesk-ticket-form * {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.helpdesk-ticket-form input,
|
||||
.helpdesk-ticket-form textarea,
|
||||
.helpdesk-ticket-form select,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__rendered,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__choice {
|
||||
background: #09090b !important;
|
||||
background-color: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
border-color: #3f3f46 !important;
|
||||
caret-color: #f4f4f5 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.helpdesk-ticket-form input:disabled,
|
||||
.helpdesk-ticket-form textarea:disabled,
|
||||
.helpdesk-ticket-form select:disabled,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple,
|
||||
.helpdesk-ticket-form [disabled],
|
||||
.helpdesk-ticket-form [aria-disabled="true"] {
|
||||
background: #09090b !important;
|
||||
background-color: #09090b !important;
|
||||
color: #a1a1aa !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.helpdesk-ticket-form input:-webkit-autofill,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form select:-webkit-autofill,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f4f4f5 !important;
|
||||
box-shadow: 0 0 0 1000px #09090b inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface .hd-btn,
|
||||
.hd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
border-radius: .85rem;
|
||||
padding: .62rem 1.05rem;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #fff !important;
|
||||
text-decoration: none !important;
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,.22);
|
||||
transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface .hd-btn:hover,
|
||||
.hd-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 34px rgba(0,0,0,.30);
|
||||
}
|
||||
|
||||
.hd-btn-sm {
|
||||
border-radius: .65rem;
|
||||
padding: .42rem .78rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.hd-btn-blue { background: #2563eb !important; }
|
||||
.hd-btn-blue:hover { background: #1d4ed8 !important; }
|
||||
|
||||
.hd-btn-green { background: #16a34a !important; }
|
||||
.hd-btn-green:hover { background: #15803d !important; }
|
||||
|
||||
.hd-btn-amber { background: #f59e0b !important; }
|
||||
.hd-btn-amber:hover { background: #d97706 !important; }
|
||||
|
||||
.hd-btn-red { background: #dc2626 !important; }
|
||||
.hd-btn-red:hover { background: #b91c1c !important; }
|
||||
|
||||
.hd-btn-violet { background: #7c3aed !important; }
|
||||
.hd-btn-violet:hover { background: #6d28d9 !important; }
|
||||
|
||||
.hd-helpdesk-surface button[type="submit"],
|
||||
.hd-helpdesk-surface button[data-flux-button][type="submit"] {
|
||||
background: #16a34a !important;
|
||||
color: #fff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface button[wire\:click^="reset"],
|
||||
.hd-helpdesk-surface button[wire\:target^="reset"] {
|
||||
background: #f59e0b !important;
|
||||
color: #fff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface button[wire\:click^="edit"],
|
||||
.hd-helpdesk-surface button[wire\:target^="edit"] {
|
||||
background: #2563eb !important;
|
||||
color: #fff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface button[wire\:click^="delete"],
|
||||
.hd-helpdesk-surface button[wire\:target^="delete"] {
|
||||
background: #dc2626 !important;
|
||||
color: #fff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
|
||||
.hd-select {
|
||||
width: 100%;
|
||||
min-height: 3.35rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #3f3f46 !important;
|
||||
background: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
padding: .78rem 3rem .78rem 1rem;
|
||||
font-weight: 800;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24);
|
||||
}
|
||||
|
||||
.hd-select:focus {
|
||||
border-color: #60a5fa !important;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(96,165,250,.38),
|
||||
inset 0 1px 0 rgba(255,255,255,.05);
|
||||
}
|
||||
|
||||
.helpdesk-required-star {
|
||||
color: #ef4444 !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form,
|
||||
.helpdesk-ticket-form * {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form input,
|
||||
.helpdesk-ticket-form textarea,
|
||||
.helpdesk-ticket-form select,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__rendered,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__choice {
|
||||
background: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
border-color: #3f3f46 !important;
|
||||
caret-color: #f4f4f5 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form input:disabled,
|
||||
.helpdesk-ticket-form textarea:disabled,
|
||||
.helpdesk-ticket-form select:disabled,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple {
|
||||
background: #09090b !important;
|
||||
color: #a1a1aa !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form input:-webkit-autofill,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form select:-webkit-autofill,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f4f4f5 !important;
|
||||
box-shadow: 0 0 0 1000px #09090b inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.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;
|
||||
transform: translateY(-50%);
|
||||
color: #f87171 !important;
|
||||
font-weight: 800 !important;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Select2 base */
|
||||
.helpdesk-ticket-form .select2-container {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple {
|
||||
min-height: 3.35rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #d4d4d8 !important;
|
||||
background: #ffffff !important;
|
||||
color: #18181b !important;
|
||||
box-shadow: 0 10px 22px rgba(24, 24, 27, .08);
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--single .select2-selection__rendered {
|
||||
color: #18181b !important;
|
||||
line-height: 3.25rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 2.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple {
|
||||
align-items: center;
|
||||
padding: .4rem .65rem;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple .select2-selection__choice {
|
||||
border: 1px solid #2563eb !important;
|
||||
border-radius: 9999px !important;
|
||||
background: #dbeafe !important;
|
||||
color: #1e3a8a !important;
|
||||
font-weight: 700;
|
||||
padding: .2rem .55rem;
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
|
||||
color: #dc2626 !important;
|
||||
margin-right: .35rem;
|
||||
}
|
||||
|
||||
.select2-dropdown {
|
||||
border: 1px solid #d4d4d8 !important;
|
||||
border-radius: .9rem !important;
|
||||
background: #ffffff !important;
|
||||
color: #18181b !important;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 42px rgba(24, 24, 27, .18);
|
||||
}
|
||||
|
||||
.select2-search__field {
|
||||
border: 1px solid #d4d4d8 !important;
|
||||
border-radius: .7rem !important;
|
||||
background: #ffffff !important;
|
||||
color: #18181b !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-results__option {
|
||||
color: #18181b !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-results__option--highlighted[aria-selected] {
|
||||
background: #2563eb !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-results__option[aria-selected="true"] {
|
||||
background: #e0f2fe !important;
|
||||
color: #075985 !important;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .helpdesk-ticket-form .select2-container--default .select2-selection--single,
|
||||
.dark .helpdesk-ticket-form .select2-container--default .select2-selection--multiple {
|
||||
border-color: #3f3f46 !important;
|
||||
background: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24);
|
||||
}
|
||||
|
||||
.dark .helpdesk-ticket-form .select2-container--default .select2-selection--single .select2-selection__rendered {
|
||||
color: #f4f4f5 !important;
|
||||
}
|
||||
|
||||
.dark .helpdesk-ticket-form .select2-container--default .select2-selection--multiple .select2-selection__choice {
|
||||
border-color: #60a5fa !important;
|
||||
background: #1d4ed8 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .select2-dropdown {
|
||||
border-color: #3f3f46 !important;
|
||||
background: #18181b !important;
|
||||
color: #f4f4f5 !important;
|
||||
box-shadow: 0 18px 42px rgba(0,0,0,.55);
|
||||
}
|
||||
|
||||
.dark .select2-search__field {
|
||||
border-color: #3f3f46 !important;
|
||||
background: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
}
|
||||
|
||||
.dark .select2-container--default .select2-results__option {
|
||||
color: #f4f4f5 !important;
|
||||
}
|
||||
|
||||
.dark .select2-container--default .select2-results__option--highlighted[aria-selected] {
|
||||
background: #2563eb !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .select2-container--default .select2-results__option[aria-selected="true"] {
|
||||
background: #27272a !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-results__option {
|
||||
background: rgb(9 9 11);
|
||||
color: rgb(244 244 245);
|
||||
}
|
||||
|
||||
.select2-container--default .select2-results__option--highlighted[aria-selected],
|
||||
.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable {
|
||||
background: rgb(37 99 235) !important;
|
||||
color: rgb(255 255 255) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-results__option[aria-selected="true"],
|
||||
.select2-container--default .select2-results__option--selected {
|
||||
background: rgb(30 64 175) !important;
|
||||
color: rgb(255 255 255) !important;
|
||||
}
|
||||
|
||||
.select2-dropdown {
|
||||
border-color: rgb(63 63 70) !important;
|
||||
background: rgb(9 9 11) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single {
|
||||
border: 1px solid rgb(63 63 70) !important;
|
||||
background: rgb(9 9 11) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__rendered {
|
||||
color: rgb(244 244 245) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__placeholder {
|
||||
color: rgb(113 113 122) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__arrow b {
|
||||
border-color: rgb(161 161 170) transparent transparent transparent !important;
|
||||
}
|
||||
|
||||
.select2-container--default.select2-container--open .select2-selection--single {
|
||||
border-color: rgb(59 130 246) !important;
|
||||
box-shadow: 0 0 0 2px rgb(59 130 246 / 0.35);
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--multiple {
|
||||
border: 1px solid rgb(63 63 70) !important;
|
||||
background: rgb(9 9 11) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--multiple .select2-selection__choice {
|
||||
border: 1px solid rgb(59 130 246 / 0.45) !important;
|
||||
background: rgb(30 64 175 / 0.35) !important;
|
||||
color: rgb(219 234 254) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
|
||||
color: rgb(191 219 254) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single,
|
||||
.select2-container--default .select2-selection--multiple {
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
.select2-dropdown {
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
.select2-container--open .select2-dropdown--below {
|
||||
margin-top: 0.35rem;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
.select2-container--open .select2-dropdown--above {
|
||||
margin-bottom: 0.35rem;
|
||||
border-radius: 0.75rem !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single {
|
||||
position: relative;
|
||||
padding-right: 3.5rem;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__rendered {
|
||||
padding-right: 3.5rem;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__clear {
|
||||
position: absolute;
|
||||
right: 2rem;
|
||||
top: 50%;
|
||||
z-index: 3;
|
||||
margin: 0;
|
||||
transform: translateY(-50%);
|
||||
color: rgb(161 161 170);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__clear:hover {
|
||||
color: rgb(248 113 113);
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__arrow {
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__rendered {
|
||||
padding-left: 0.75rem !important;
|
||||
padding-right: 4.25rem !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__clear {
|
||||
position: absolute !important;
|
||||
right: 2.35rem !important;
|
||||
top: 50% !important;
|
||||
float: none !important;
|
||||
margin: 0 !important;
|
||||
transform: translateY(-50%) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__clear span {
|
||||
color: rgb(161 161 170) !important;
|
||||
font-size: 1.1rem !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__arrow {
|
||||
right: 0.75rem !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__rendered {
|
||||
flex: 1 1 auto !important;
|
||||
width: 100% !important;
|
||||
padding-right: 4.5rem !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__clear {
|
||||
position: absolute !important;
|
||||
right: 2.25rem !important;
|
||||
top: 50% !important;
|
||||
z-index: 20 !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
float: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
transform: translateY(-50%) !important;
|
||||
color: rgb(161 161 170) !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__clear:hover {
|
||||
color: rgb(248 113 113) !important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-selection--single .select2-selection__arrow {
|
||||
position: absolute !important;
|
||||
right: 0.75rem !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
}
|
||||
|
||||
/* Helpdesk Flatpickr date picker */
|
||||
.flatpickr-calendar {
|
||||
border: 1px solid rgb(63 63 70);
|
||||
background: rgb(24 24 27);
|
||||
color: rgb(244 244 245);
|
||||
box-shadow: 0 20px 45px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.flatpickr-calendar.arrowTop::before,
|
||||
.flatpickr-calendar.arrowTop::after,
|
||||
.flatpickr-calendar.arrowBottom::before,
|
||||
.flatpickr-calendar.arrowBottom::after {
|
||||
border-bottom-color: rgb(24 24 27);
|
||||
border-top-color: rgb(24 24 27);
|
||||
}
|
||||
|
||||
.flatpickr-months,
|
||||
.flatpickr-months .flatpickr-month,
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months,
|
||||
.flatpickr-current-month input.cur-year {
|
||||
background: rgb(24 24 27);
|
||||
color: rgb(244 244 245);
|
||||
}
|
||||
|
||||
.flatpickr-weekday,
|
||||
span.flatpickr-weekday {
|
||||
color: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.flatpickr-day {
|
||||
color: rgb(228 228 231);
|
||||
}
|
||||
|
||||
.flatpickr-day:hover,
|
||||
.flatpickr-day:focus {
|
||||
border-color: rgb(59 130 246);
|
||||
background: rgb(39 39 42);
|
||||
color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.startRange,
|
||||
.flatpickr-day.endRange {
|
||||
border-color: rgb(37 99 235);
|
||||
background: rgb(37 99 235);
|
||||
color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.flatpickr-day.today {
|
||||
border-color: rgb(34 197 94);
|
||||
}
|
||||
|
||||
.flatpickr-day.flatpickr-disabled,
|
||||
.flatpickr-day.prevMonthDay,
|
||||
.flatpickr-day.nextMonthDay {
|
||||
color: rgb(113 113 122);
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn,
|
||||
.hd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
border-radius: .85rem;
|
||||
padding: .62rem 1.05rem;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 800;
|
||||
font-size: .95rem;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none !important;
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,.22);
|
||||
transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn:hover,
|
||||
.hd-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 34px rgba(0,0,0,.30);
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn-sm,
|
||||
.hd-btn-sm {
|
||||
border-radius: .65rem;
|
||||
padding: .42rem .78rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn-blue,
|
||||
.hd-btn-blue { background: #2563eb !important; }
|
||||
.hd-helpdesk-surface .hd-btn-blue:hover,
|
||||
.hd-btn-blue:hover { background: #1d4ed8 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-green,
|
||||
.hd-btn-green { background: #16a34a !important; }
|
||||
.hd-helpdesk-surface .hd-btn-green:hover,
|
||||
.hd-btn-green:hover { background: #15803d !important; }
|
||||
.hd-helpdesk-surface .hd-btn-amber,
|
||||
.hd-btn-amber { background: #f59e0b !important; }
|
||||
.hd-helpdesk-surface .hd-btn-amber:hover,
|
||||
.hd-btn-amber:hover { background: #d97706 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-red,
|
||||
.hd-btn-red { background: #dc2626 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-red:hover,
|
||||
.hd-btn-red:hover { background: #b91c1c !important; }
|
||||
.hd-helpdesk-surface .hd-btn-violet,
|
||||
.hd-btn-violet { background: #7c3aed !important; }
|
||||
.hd-helpdesk-surface .hd-btn-violet:hover,
|
||||
.hd-btn-violet:hover { background: #6d28d9 !important; }
|
||||
|
||||
.hd-helpdesk-surface button[type="submit"],
|
||||
.hd-helpdesk-surface button[data-flux-button][type="submit"] {
|
||||
background: #16a34a !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="reset"],
|
||||
.hd-helpdesk-surface button[wire\:target^="reset"] {
|
||||
background: #f59e0b !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="edit"],
|
||||
.hd-helpdesk-surface button[wire\:target^="edit"] {
|
||||
background: #2563eb !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="delete"],
|
||||
.hd-helpdesk-surface button[wire\:target^="delete"] {
|
||||
background: #dc2626 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
|
||||
.hd-select-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 18rem;
|
||||
}
|
||||
.hd-select-wrap::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
width: .55rem;
|
||||
height: .55rem;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
border-right: 2px solid #93c5fd;
|
||||
border-bottom: 2px solid #93c5fd;
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
}
|
||||
.hd-select {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border-radius: .9rem;
|
||||
border: 1px solid rgba(96, 165, 250, .38) !important;
|
||||
background: linear-gradient(135deg, #030712 0%, #111827 54%, #0f172a 100%) !important;
|
||||
color: #f8fafc !important;
|
||||
padding: .62rem 2.45rem .62rem .9rem;
|
||||
font-weight: 800;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 14px 30px rgba(2,6,23,.38);
|
||||
}
|
||||
.hd-select option {
|
||||
background: #020617 !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
.hd-select:focus {
|
||||
border-color: #60a5fa !important;
|
||||
box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.08), 0 14px 30px rgba(2,6,23,.38);
|
||||
}
|
||||
.hd-ticket-board-select-wrap {
|
||||
position: relative;
|
||||
max-width: 12.5rem;
|
||||
}
|
||||
.hd-ticket-board-select-wrap::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: .7rem;
|
||||
width: .45rem;
|
||||
height: .45rem;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
border-right: 2px solid #86efac;
|
||||
border-bottom: 2px solid #86efac;
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
}
|
||||
.hd-ticket-board-select {
|
||||
width: 100%;
|
||||
min-height: 2.35rem;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border-radius: .85rem;
|
||||
border: 1px solid rgba(34,197,94,.35) !important;
|
||||
background: linear-gradient(135deg, #052e16 0%, #064e3b 100%) !important;
|
||||
color: #ecfdf5 !important;
|
||||
padding: .48rem 2rem .48rem .75rem;
|
||||
font-size: .75rem;
|
||||
font-weight: 800;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 10px 22px rgba(0,0,0,.26);
|
||||
}
|
||||
.hd-ticket-board-select option {
|
||||
background: #052e16 !important;
|
||||
color: #ecfdf5 !important;
|
||||
}
|
||||
.hd-ticket-board-select:focus {
|
||||
border-color: #86efac !important;
|
||||
box-shadow: 0 0 0 3px rgba(34,197,94,.28), inset 0 1px 0 rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form,
|
||||
.helpdesk-ticket-form * {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.helpdesk-ticket-form input,
|
||||
.helpdesk-ticket-form textarea,
|
||||
.helpdesk-ticket-form select,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__rendered,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__choice {
|
||||
background: #09090b !important;
|
||||
background-color: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
border-color: #3f3f46 !important;
|
||||
caret-color: #f4f4f5 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.helpdesk-ticket-form input:disabled,
|
||||
.helpdesk-ticket-form textarea:disabled,
|
||||
.helpdesk-ticket-form select:disabled,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple,
|
||||
.helpdesk-ticket-form [disabled],
|
||||
.helpdesk-ticket-form [aria-disabled="true"] {
|
||||
background: #09090b !important;
|
||||
background-color: #09090b !important;
|
||||
color: #a1a1aa !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.helpdesk-ticket-form input:-webkit-autofill,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form select:-webkit-autofill,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f4f4f5 !important;
|
||||
box-shadow: 0 0 0 1000px #09090b inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.hd-helpdesk-surface .hd-btn,
|
||||
.hd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
border-radius: .85rem;
|
||||
padding: .62rem 1.05rem;
|
||||
font-size: .875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none !important;
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
box-shadow: 0 12px 28px rgba(0,0,0,.22);
|
||||
transition: background-color .15s ease, transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn:hover,
|
||||
.hd-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 34px rgba(0,0,0,.30);
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn-sm,
|
||||
.hd-btn-sm {
|
||||
border-radius: .65rem;
|
||||
padding: .42rem .78rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
.hd-helpdesk-surface .hd-btn-blue,
|
||||
.hd-btn-blue { background: #2563eb !important; }
|
||||
.hd-helpdesk-surface .hd-btn-blue:hover,
|
||||
.hd-btn-blue:hover { background: #1d4ed8 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-green,
|
||||
.hd-btn-green { background: #16a34a !important; }
|
||||
.hd-helpdesk-surface .hd-btn-green:hover,
|
||||
.hd-btn-green:hover { background: #15803d !important; }
|
||||
.hd-helpdesk-surface .hd-btn-amber,
|
||||
.hd-btn-amber { background: #f59e0b !important; }
|
||||
.hd-helpdesk-surface .hd-btn-amber:hover,
|
||||
.hd-btn-amber:hover { background: #d97706 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-red,
|
||||
.hd-btn-red { background: #dc2626 !important; }
|
||||
.hd-helpdesk-surface .hd-btn-red:hover,
|
||||
.hd-btn-red:hover { background: #b91c1c !important; }
|
||||
.hd-helpdesk-surface .hd-btn-violet,
|
||||
.hd-btn-violet { background: #7c3aed !important; }
|
||||
.hd-helpdesk-surface .hd-btn-violet:hover,
|
||||
.hd-btn-violet:hover { background: #6d28d9 !important; }
|
||||
|
||||
.hd-helpdesk-surface button[type="submit"],
|
||||
.hd-helpdesk-surface button[data-flux-button][type="submit"] {
|
||||
background: #16a34a !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="reset"],
|
||||
.hd-helpdesk-surface button[wire\:target^="reset"] {
|
||||
background: #f59e0b !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="edit"],
|
||||
.hd-helpdesk-surface button[wire\:target^="edit"] {
|
||||
background: #2563eb !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
.hd-helpdesk-surface button[wire\:click^="delete"],
|
||||
.hd-helpdesk-surface button[wire\:target^="delete"] {
|
||||
background: #dc2626 !important;
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(255,255,255,.14) !important;
|
||||
}
|
||||
|
||||
.hd-select {
|
||||
width: 100%;
|
||||
min-height: 3.35rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #3f3f46 !important;
|
||||
background: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
padding: .78rem 3rem .78rem 1rem;
|
||||
font-weight: 800;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 12px 26px rgba(0,0,0,.24);
|
||||
}
|
||||
.hd-select:focus {
|
||||
border-color: #60a5fa !important;
|
||||
box-shadow: 0 0 0 3px rgba(96,165,250,.38), inset 0 1px 0 rgba(255,255,255,.05);
|
||||
}
|
||||
|
||||
.helpdesk-ticket-form,
|
||||
.helpdesk-ticket-form * {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.helpdesk-ticket-form input,
|
||||
.helpdesk-ticket-form textarea,
|
||||
.helpdesk-ticket-form select,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection--multiple,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__rendered,
|
||||
.helpdesk-ticket-form .select2-container--default .select2-selection__choice {
|
||||
background: #09090b !important;
|
||||
background-color: #09090b !important;
|
||||
color: #f4f4f5 !important;
|
||||
border-color: #3f3f46 !important;
|
||||
caret-color: #f4f4f5 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.helpdesk-ticket-form input:disabled,
|
||||
.helpdesk-ticket-form textarea:disabled,
|
||||
.helpdesk-ticket-form select:disabled,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--single,
|
||||
.helpdesk-ticket-form .select2-container--disabled .select2-selection--multiple,
|
||||
.helpdesk-ticket-form [disabled],
|
||||
.helpdesk-ticket-form [aria-disabled="true"] {
|
||||
background: #09090b !important;
|
||||
background-color: #09090b !important;
|
||||
color: #a1a1aa !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.helpdesk-ticket-form input:-webkit-autofill,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form input:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form textarea:-webkit-autofill:focus,
|
||||
.helpdesk-ticket-form select:-webkit-autofill,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:hover,
|
||||
.helpdesk-ticket-form select:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: #f4f4f5 !important;
|
||||
box-shadow: 0 0 0 1000px #09090b inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import './bootstrap';
|
||||
import $ from 'jquery';
|
||||
import select2 from 'select2/dist/js/select2.full.js';
|
||||
import flatpickr from 'flatpickr';
|
||||
import 'flatpickr/dist/flatpickr.css';
|
||||
|
||||
window.$ = $;
|
||||
window.jQuery = $;
|
||||
|
||||
select2(window, $);
|
||||
|
||||
function livewireComponentFor(element) {
|
||||
const componentRoot = element.closest('[wire\\:id]');
|
||||
|
||||
if (!componentRoot || ! window.Livewire) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.Livewire.find(componentRoot.getAttribute('wire:id'));
|
||||
}
|
||||
|
||||
function setLivewireProperty(element, model, value) {
|
||||
const component = livewireComponentFor(element);
|
||||
|
||||
if (!component || !model) {
|
||||
return;
|
||||
}
|
||||
|
||||
component.set(model, value === '' ? null : value);
|
||||
}
|
||||
|
||||
function initialiseHelpdeskSelects() {
|
||||
$('[data-helpdesk-select2]').each(function () {
|
||||
const select = this;
|
||||
const $select = $(select);
|
||||
const placeholder = $select.data('placeholder') || $select.find('option[value=""]').first().text() || 'Select option';
|
||||
|
||||
if ($select.hasClass('select2-hidden-accessible')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$select.find('option[value=""]').length) {
|
||||
$select.prepend(new Option('', '', false, false));
|
||||
}
|
||||
|
||||
$select.select2({
|
||||
width: '100%',
|
||||
allowClear: true,
|
||||
placeholder,
|
||||
});
|
||||
|
||||
$select.on('change.helpdesk-select2', function () {
|
||||
setLivewireProperty(select, $select.data('wire-model'), $select.val());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clearHelpdeskSelects(root = document) {
|
||||
$(root).find('[data-helpdesk-select2]').each(function () {
|
||||
const $select = $(this);
|
||||
|
||||
$select.val(null);
|
||||
$select.trigger('change.select2');
|
||||
$select.trigger('change.helpdesk-select2');
|
||||
$select.trigger('change');
|
||||
});
|
||||
}
|
||||
|
||||
function initialiseHelpdeskDatePickers() {
|
||||
document.querySelectorAll('[data-helpdesk-flatpickr]').forEach((input) => {
|
||||
if (input._helpdeskFlatpickr) {
|
||||
return;
|
||||
}
|
||||
|
||||
input._helpdeskFlatpickr = flatpickr(input, {
|
||||
allowInput: true,
|
||||
altInput: true,
|
||||
altFormat: 'd M Y',
|
||||
dateFormat: 'Y-m-d',
|
||||
disableMobile: false,
|
||||
onChange: (_selectedDates, dateText) => {
|
||||
setLivewireProperty(input, input.dataset.wireModel, dateText || null);
|
||||
},
|
||||
onClose: (_selectedDates, dateText) => {
|
||||
setLivewireProperty(input, input.dataset.wireModel, dateText || null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clearHelpdeskDatePickers(root = document) {
|
||||
root.querySelectorAll('[data-helpdesk-flatpickr]').forEach((input) => {
|
||||
if (input._helpdeskFlatpickr) {
|
||||
input._helpdeskFlatpickr.clear();
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
setLivewireProperty(input, input.dataset.wireModel, null);
|
||||
});
|
||||
}
|
||||
|
||||
function initialiseHelpdeskFormControls() {
|
||||
initialiseHelpdeskSelects();
|
||||
initialiseHelpdeskDatePickers();
|
||||
}
|
||||
|
||||
function clearHelpdeskFormControls() {
|
||||
clearHelpdeskSelects();
|
||||
clearHelpdeskDatePickers();
|
||||
}
|
||||
|
||||
window.initialiseHelpdeskFormControls = initialiseHelpdeskFormControls;
|
||||
window.clearHelpdeskFormControls = clearHelpdeskFormControls;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initialiseHelpdeskFormControls);
|
||||
document.addEventListener('livewire:navigated', initialiseHelpdeskFormControls);
|
||||
document.addEventListener('helpdesk-form-cleared', clearHelpdeskFormControls);
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
initialiseHelpdeskFormControls();
|
||||
|
||||
Livewire.hook('morphed', () => {
|
||||
initialiseHelpdeskFormControls();
|
||||
});
|
||||
|
||||
Livewire.on('helpdesk-form-cleared', () => {
|
||||
clearHelpdeskFormControls();
|
||||
});
|
||||
});
|
||||
|
||||
console.info('Helpdesk module assets loaded');
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user