phase 6 added helpdesk

This commit is contained in:
Mark Gregory
2026-07-24 11:28:06 +01:00
parent 5ecf20fe97
commit 8c74abfbc6
212 changed files with 23064 additions and 0 deletions
@@ -0,0 +1,36 @@
<?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);
}
}
View File
+31
View File
@@ -0,0 +1,31 @@
<?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',
];
View File
@@ -0,0 +1,26 @@
<?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');
}
});
}
};
@@ -0,0 +1,73 @@
<?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']);
});
}
};
@@ -0,0 +1,41 @@
<?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');
});
}
};
@@ -0,0 +1,78 @@
<?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']);
});
}
};
@@ -0,0 +1,40 @@
<?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']);
});
}
};
@@ -0,0 +1,21 @@
<?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']);
});
}
};
@@ -0,0 +1,39 @@
<?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');
}
};
@@ -0,0 +1,31 @@
<?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');
}
};
@@ -0,0 +1,205 @@
<?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']);
});
}
};
@@ -0,0 +1,39 @@
<?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']);
});
}
};
@@ -0,0 +1,48 @@
<?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');
});
}
}
}
};
@@ -0,0 +1,34 @@
<?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');
});
}
}
};
@@ -0,0 +1,50 @@
<?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');
});
}
}
}
};
@@ -0,0 +1,76 @@
<?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');
});
}
}
};
@@ -0,0 +1,60 @@
<?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.
}
};
@@ -0,0 +1,65 @@
<?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.
}
};
@@ -0,0 +1,132 @@
<?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',
]);
});
}
};
@@ -0,0 +1,53 @@
<?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');
}
});
}
};
@@ -0,0 +1,40 @@
<?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');
}
});
}
};
@@ -0,0 +1,54 @@
<?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);
}
}
});
}
}
};
@@ -0,0 +1,41 @@
<?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');
}
};
@@ -0,0 +1,41 @@
<?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');
}
};
@@ -0,0 +1,45 @@
<?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');
});
}
};
@@ -0,0 +1,40 @@
<?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');
}
});
}
};
@@ -0,0 +1,68 @@
<?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',
];
}
};
@@ -0,0 +1,64 @@
<?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);
}
}
});
}
};
@@ -0,0 +1,277 @@
<?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,
],
);
}
}
@@ -0,0 +1,162 @@
<?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();
}
}
@@ -0,0 +1,225 @@
# 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
```
View File
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
if (! function_exists('helpdesk_module_name')) {
/**
* Return the Helpdesk module name.
*/
function helpdesk_module_name(): string
{
return 'Helpdesk';
}
}
@@ -0,0 +1,36 @@
<?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.');
}
}
@@ -0,0 +1,202 @@
<?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',
};
}
}
@@ -0,0 +1,50 @@
<?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',
]);
}
}
@@ -0,0 +1,355 @@
<?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'),
};
}
}
@@ -0,0 +1,23 @@
<?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()),
]);
}
}
@@ -0,0 +1,75 @@
<?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();
}
}
@@ -0,0 +1,105 @@
<?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()],
);
}
}
@@ -0,0 +1,75 @@
<?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.');
}
}
@@ -0,0 +1,200 @@
<?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, '&lt;') && ! str_contains($body, '&gt;') && ! str_contains($body, '&amp;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();
}
}
@@ -0,0 +1,256 @@
<?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;
}
}
@@ -0,0 +1,93 @@
<?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();
}
}
@@ -0,0 +1,96 @@
<?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.');
}
}
@@ -0,0 +1,29 @@
<?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',
]);
}
}
@@ -0,0 +1,52 @@
<?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(),
]);
}
}
+294
View File
@@ -0,0 +1,294 @@
<?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(),
],
);
}
}
}
@@ -0,0 +1,960 @@
<?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);
}
}
+350
View File
@@ -0,0 +1,350 @@
<?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();
}
}
+354
View File
@@ -0,0 +1,354 @@
<?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();
}
}
+152
View File
@@ -0,0 +1,152 @@
<?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);
}
}
+807
View File
@@ -0,0 +1,807 @@
<?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'
*/
}
+219
View File
@@ -0,0 +1,219 @@
<?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';
}
}
View File
+256
View File
@@ -0,0 +1,256 @@
<?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'));
}
}
@@ -0,0 +1,16 @@
<?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'];
}
@@ -0,0 +1,27 @@
<?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');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?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');
}
}
@@ -0,0 +1,16 @@
<?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'];
}
+111
View File
@@ -0,0 +1,111 @@
<?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');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?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');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?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');
}
}
+61
View File
@@ -0,0 +1,61 @@
<?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();
}
}
+28
View File
@@ -0,0 +1,28 @@
<?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');
}
}
+22
View File
@@ -0,0 +1,22 @@
<?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');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?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');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?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');
}
}
+22
View File
@@ -0,0 +1,22 @@
<?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');
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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');
}
}
+218
View File
@@ -0,0 +1,218 @@
<?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();
}
}
+16
View File
@@ -0,0 +1,16 @@
<?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'];
}
@@ -0,0 +1,16 @@
<?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'];
}
+55
View File
@@ -0,0 +1,55 @@
<?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');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?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');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?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');
}
}
@@ -0,0 +1,28 @@
<?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');
}
}
@@ -0,0 +1,57 @@
<?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;
}
}
View File
@@ -0,0 +1,195 @@
<?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 [];
}
}
@@ -0,0 +1,57 @@
<?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');
}
}
+333
View File
@@ -0,0 +1,333 @@
# 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
@@ -0,0 +1,974 @@
.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;
}
+131
View File
@@ -0,0 +1,131 @@
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');
+4
View File
@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = window.axios || axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
@@ -0,0 +1,3 @@
.helpdesk-module {
min-height: 100vh;
}

Some files were not shown because too many files have changed in this diff Show More