diff --git a/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php b/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php new file mode 100644 index 00000000..cea0de34 --- /dev/null +++ b/Modules/Helpdesk/Concerns/HasHelpdeskTickets.php @@ -0,0 +1,35 @@ + */ + public function helpdeskTickets(): HasMany + { + return $this->hasMany(Ticket::class, 'user_id'); + } + + /** @return HasMany */ + public function helpdeskAssignedTickets(): HasMany + { + return $this->hasMany(Ticket::class, 'agent_id'); + } + + /** @return bool */ + public function isHelpdeskAgent(): bool + { + return (bool) (($this->helpdesk_agent ?? false) || ($this->helpdesk_admin ?? false)); + } + + /** @return bool */ + public function isHelpdeskAdmin(): bool + { + return (bool) ($this->helpdesk_admin ?? false); + } +} diff --git a/Modules/Helpdesk/Config/config.php b/Modules/Helpdesk/Config/config.php index 779c63c8..a629042b 100644 --- a/Modules/Helpdesk/Config/config.php +++ b/Modules/Helpdesk/Config/config.php @@ -1,8 +1,13 @@ 'Helpdesk', - 'features' => - [ - ], + 'route_prefix' => env('HELPDESK_ROUTE_PREFIX', 'helpdesk'), + 'middleware' => ['web', 'auth'], + 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'helpdesk'), + 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), + 'allowed_mimes' => ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'], + 'default_status_slug' => 'open', + 'closed_status_slug' => 'closed', ]; diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php new file mode 100644 index 00000000..f4673bd2 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000001_add_helpdesk_flags_to_users_table.php @@ -0,0 +1,45 @@ +boolean('helpdesk_agent')->default(false)->after('password'); + } + + if (! Schema::hasColumn('users', 'helpdesk_admin')) { + $table->boolean('helpdesk_admin')->default(false)->after('helpdesk_agent'); + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::table('users', static function (Blueprint $table): void { + if (Schema::hasColumn('users', 'helpdesk_admin')) { + $table->dropColumn('helpdesk_admin'); + } + + if (Schema::hasColumn('users', 'helpdesk_agent')) { + $table->dropColumn('helpdesk_agent'); + } + }); + } +}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000002_create_helpdesk_lookup_tables.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000002_create_helpdesk_lookup_tables.php new file mode 100644 index 00000000..e0e2fd37 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000002_create_helpdesk_lookup_tables.php @@ -0,0 +1,61 @@ +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->string('name', 120); + $table->string('slug', 140)->unique(); + $table->string('color', 30)->default('blue'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('helpdesk_projects', static function (Blueprint $table): void { + $table->id(); + $table->string('title', 160); + $table->string('slug', 180)->unique(); + $table->string('color', 30)->default('blue'); + $table->text('description')->nullable(); + $table->timestamp('archived_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** @return void */ + public function down(): void + { + Schema::dropIfExists('helpdesk_projects'); + Schema::dropIfExists('helpdesk_categories'); + Schema::dropIfExists('helpdesk_priorities'); + Schema::dropIfExists('helpdesk_statuses'); + } +}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000003_create_helpdesk_tickets_table.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000003_create_helpdesk_tickets_table.php new file mode 100644 index 00000000..2b5b9698 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000003_create_helpdesk_tickets_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('reference', 32)->unique(); + $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->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(['status_id', 'priority_id']); + $table->index(['agent_id', 'completed_at']); + $table->index(['user_id', 'created_at']); + $table->index('last_activity_at'); + }); + } + + /** @return void */ + public function down(): void + { + Schema::dropIfExists('helpdesk_tickets'); + } +}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000004_create_helpdesk_comments_and_attachments_tables.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000004_create_helpdesk_comments_and_attachments_tables.php new file mode 100644 index 00000000..83e7011a --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000004_create_helpdesk_comments_and_attachments_tables.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->longText('body'); + $table->boolean('is_internal')->default(false); + $table->timestamps(); + + $table->index(['ticket_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', 'created_at']); + }); + } + + /** @return void */ + public function down(): void + { + Schema::dropIfExists('helpdesk_attachments'); + Schema::dropIfExists('helpdesk_comments'); + } +}; diff --git a/Modules/Helpdesk/Database/Migrations/2026_04_24_000005_create_helpdesk_category_user_table.php b/Modules/Helpdesk/Database/Migrations/2026_04_24_000005_create_helpdesk_category_user_table.php new file mode 100644 index 00000000..457d8100 --- /dev/null +++ b/Modules/Helpdesk/Database/Migrations/2026_04_24_000005_create_helpdesk_category_user_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('category_id')->constrained('helpdesk_categories')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['category_id', 'user_id']); + }); + } + + /** @return void */ + public function down(): void + { + Schema::dropIfExists('helpdesk_category_user'); + } +}; diff --git a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php index baf6cadf..36ced7b7 100644 --- a/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php +++ b/Modules/Helpdesk/Database/Seeders/HelpdeskDatabaseSeeder.php @@ -1,21 +1,47 @@ 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false], + ['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false], + ['name' => 'Waiting Customer', 'slug' => 'waiting-customer', 'color' => 'purple', 'sort_order' => 30, 'is_closed' => false], + ['name' => 'Closed', 'slug' => 'closed', 'color' => 'green', 'sort_order' => 90, 'is_closed' => true], + ] as $row) { + Status::query()->updateOrCreate(['slug' => $row['slug']], $row); + } - // $this->call("OthersTableSeeder"); + foreach ([ + ['name' => 'Low', 'slug' => 'low', 'color' => 'zinc', 'sort_order' => 10], + ['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue', 'sort_order' => 20], + ['name' => 'High', 'slug' => 'high', 'color' => 'orange', 'sort_order' => 30], + ['name' => 'Critical', 'slug' => 'critical', 'color' => 'red', 'sort_order' => 40], + ] as $row) { + Priority::query()->updateOrCreate(['slug' => $row['slug']], $row); + } + + foreach (['General Support', 'Software', 'Hardware', 'Access Request', 'Incident'] as $name) { + Category::query()->updateOrCreate(['slug' => Str::slug($name)], ['name' => $name, 'color' => 'blue']); + } + + Project::query()->updateOrCreate(['slug' => 'internal-helpdesk'], [ + 'title' => 'Internal Helpdesk', + 'color' => 'blue', + 'description' => 'Default project for internal helpdesk tickets.', + ]); } } diff --git a/Modules/Helpdesk/Docs/INSTALL.md b/Modules/Helpdesk/Docs/INSTALL.md new file mode 100644 index 00000000..1ea4246d --- /dev/null +++ b/Modules/Helpdesk/Docs/INSTALL.md @@ -0,0 +1,93 @@ +# Helpdesk Module Install + +## 1. Copy the module + +Copy `Modules/Helpdesk` into your Laravel 13 project. + +## 2. Register the provider + +If your module loader reads `module.json`, no manual provider entry is needed. Otherwise add this provider to `bootstrap/providers.php`: + +```php +Modules\Helpdesk\Providers\HelpdeskServiceProvider::class, +``` + +## 3. Add the helpdesk disk + +Add this to `config/filesystems.php` disks: + +```php +'helpdesk' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/helpdesk'), + 'url' => env('APP_URL') . '/storage/helpdesk', + 'visibility' => 'public', +], +``` + +Then run: + +```bash +php artisan storage:link +``` + +## 4. Add relations to `App\Models\User` + +```php +use Modules\Helpdesk\Concerns\HasHelpdeskTickets; + +class User extends Authenticatable +{ + use HasHelpdeskTickets; +} +``` + +Add casts if you want strict booleans: + +```php +protected function casts(): array +{ + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'helpdesk_agent' => 'boolean', + 'helpdesk_admin' => 'boolean', + ]; +} +``` + +## 5. Run migrations and seeders + +```bash +php artisan migrate +php artisan db:seed --class="Modules\\Helpdesk\\database\\seeders\\HelpdeskDatabaseSeeder" +``` + +## 6. Add dashboard widget + +Place this where you want it on your dashboard: + +```blade +@include('helpdesk::dashboard.card') +``` + +or directly: + +```blade + +``` + +## 7. Navigation link + +```blade + + Helpdesk + +``` + +## 8. Tests + +```bash +php artisan test Modules/Helpdesk/tests/Feature +php artisan dusk Modules/Helpdesk/tests/Browser +``` diff --git a/Modules/Helpdesk/Helpers/Helper.php b/Modules/Helpdesk/Helpers/Helper.php index 80c99394..d5ac059c 100644 --- a/Modules/Helpdesk/Helpers/Helper.php +++ b/Modules/Helpdesk/Helpers/Helper.php @@ -1,12 +1,13 @@ visibleTo($user); + + return view('helpdesk::livewire.dashboard-widget', [ + '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->where('slug', 'critical'))->count(), + 'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(), + ]); + } +} diff --git a/Modules/Helpdesk/Livewire/TicketCreate.php b/Modules/Helpdesk/Livewire/TicketCreate.php new file mode 100644 index 00000000..50c684ef --- /dev/null +++ b/Modules/Helpdesk/Livewire/TicketCreate.php @@ -0,0 +1,79 @@ + */ + #[Validate('array')] + public array $attachments = []; + + /** + * Store a new helpdesk ticket. + * + * @param TicketService $tickets + * @return mixed + */ + public function save(TicketService $tickets): mixed + { + $this->validate([ + 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb') . '|mimes:' . implode(',', config('helpdesk.allowed_mimes')), + ]); + + $ticket = $tickets->create(Auth::user(), [ + 'subject' => $this->subject, + 'body' => $this->body, + 'priority_id' => $this->priority_id, + 'category_id' => $this->category_id, + 'project_id' => $this->project_id, + 'due_at' => $this->due_at, + ], $this->attachments); + + session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); + + return $this->redirectRoute('helpdesk.tickets.show', $ticket, navigate: true); + } + + /** @return View */ + public function render(): View + { + return view('helpdesk::livewire.ticket-create', [ + 'priorities' => Priority::query()->orderBy('sort_order')->get(), + 'categories' => Category::query()->orderBy('name')->get(), + 'projects' => Project::query()->whereNull('archived_at')->orderBy('title')->get(), + ])->layout('layouts.app'); + } +} diff --git a/Modules/Helpdesk/Livewire/TicketIndex.php b/Modules/Helpdesk/Livewire/TicketIndex.php new file mode 100644 index 00000000..64b1b8ca --- /dev/null +++ b/Modules/Helpdesk/Livewire/TicketIndex.php @@ -0,0 +1,84 @@ +resetPage(); + } + + /** + * Render the ticket list. + * + * @return View + */ + public function render(): View + { + return view('helpdesk::livewire.ticket-index', [ + 'tickets' => $this->tickets(), + 'statuses' => Status::query()->orderBy('sort_order')->get(), + 'priorities' => Priority::query()->orderBy('sort_order')->get(), + ])->layout('layouts.app'); + } + + /** + * Build paginated ticket results. + * + * @return LengthAwarePaginator + */ + private function tickets(): LengthAwarePaginator + { + $user = Auth::user(); + + return Ticket::query() + ->with(['owner', 'agent', 'status', 'priority', 'category', 'project']) + ->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($this->search !== '', function (Builder $query): Builder { + return $query->where(static function (Builder $subQuery): void { + $subQuery->where('reference', 'like', '%' . $this->search . '%') + ->orWhere('subject', 'like', '%' . $this->search . '%') + ->orWhere('body', 'like', '%' . $this->search . '%'); + }); + }) + ->latest('last_activity_at') + ->paginate(15); + } +} diff --git a/Modules/Helpdesk/Livewire/TicketShow.php b/Modules/Helpdesk/Livewire/TicketShow.php new file mode 100644 index 00000000..cc86ba67 --- /dev/null +++ b/Modules/Helpdesk/Livewire/TicketShow.php @@ -0,0 +1,96 @@ + */ + public array $attachments = []; + + public ?int $status_id = null; + + /** + * Mount the component. + * + * @param Ticket $ticket + * @return void + */ + public function mount(Ticket $ticket): void + { + $this->authorize('view', $ticket); + $this->ticket = $ticket->load(['owner', 'agent', 'status', 'priority', 'category', 'project', 'attachments.user']); + $this->status_id = $this->ticket->status_id; + } + + /** + * Add a comment to the ticket. + * + * @param TicketService $tickets + * @return void + */ + public function addComment(TicketService $tickets): void + { + $this->authorize('comment', $this->ticket); + + $this->validate([ + 'commentBody' => ['required', 'string', 'min:2'], + 'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb') . '|mimes:' . implode(',', config('helpdesk.allowed_mimes')), + ]); + + $tickets->comment($this->ticket, Auth::user(), $this->commentBody, $this->internal, $this->attachments); + + $this->reset(['commentBody', 'internal', 'attachments']); + $this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'comments.user', 'comments.attachments', 'attachments.user']); + $this->dispatch('helpdesk-comment-added'); + } + + /** + * Update status using Livewire AJAX. + * + * @return void + */ + public function updateStatus(): void + { + $this->authorize('update', $this->ticket); + $this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]); + + $status = Status::query()->findOrFail($this->status_id); + $this->ticket->forceFill([ + 'status_id' => $status->id, + 'completed_at' => $status->is_closed ? now() : null, + 'last_activity_at' => now(), + ])->save(); + + $this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'comments.user', 'comments.attachments', 'attachments.user']); + $this->dispatch('helpdesk-status-updated'); + } + + /** @return View */ + public function render(): View + { + return view('helpdesk::livewire.ticket-show', [ + 'statuses' => Status::query()->orderBy('sort_order')->get(), + ])->layout('layouts.app'); + } +} diff --git a/Modules/Helpdesk/Models/Attachment.php b/Modules/Helpdesk/Models/Attachment.php new file mode 100644 index 00000000..3f838510 --- /dev/null +++ b/Modules/Helpdesk/Models/Attachment.php @@ -0,0 +1,48 @@ + */ + protected $fillable = ['ticket_id', 'comment_id', 'user_id', 'disk', 'path', 'original_name', 'mime_type', 'size']; + + /** @var array */ + protected $casts = ['size' => 'integer']; + + /** @return BelongsTo */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + /** @return BelongsTo */ + public function comment(): BelongsTo + { + return $this->belongsTo(Comment::class, 'comment_id'); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** @return string */ + public function url(): string + { + return Storage::disk($this->disk)->url($this->path); + } +} diff --git a/Modules/Helpdesk/Models/Category.php b/Modules/Helpdesk/Models/Category.php new file mode 100644 index 00000000..468536f0 --- /dev/null +++ b/Modules/Helpdesk/Models/Category.php @@ -0,0 +1,33 @@ + */ + protected $fillable = ['name', 'slug', 'color', 'description']; + + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'category_id'); + } + + /** @return BelongsToMany */ + public function agents(): BelongsToMany + { + return $this->belongsToMany(User::class, 'helpdesk_category_user', 'category_id', 'user_id')->withTimestamps(); + } +} diff --git a/Modules/Helpdesk/Models/Comment.php b/Modules/Helpdesk/Models/Comment.php new file mode 100644 index 00000000..53f11a65 --- /dev/null +++ b/Modules/Helpdesk/Models/Comment.php @@ -0,0 +1,42 @@ + */ + protected $fillable = ['ticket_id', 'user_id', 'body', 'is_internal']; + + /** @var array */ + protected $casts = ['is_internal' => 'boolean']; + + /** @return BelongsTo */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** @return HasMany */ + public function attachments(): HasMany + { + return $this->hasMany(Attachment::class, 'comment_id'); + } +} diff --git a/Modules/Helpdesk/Models/Priority.php b/Modules/Helpdesk/Models/Priority.php new file mode 100644 index 00000000..caf3efc0 --- /dev/null +++ b/Modules/Helpdesk/Models/Priority.php @@ -0,0 +1,28 @@ + */ + protected $fillable = ['name', 'slug', 'color', 'sort_order']; + + /** @var array */ + protected $casts = ['sort_order' => 'integer']; + + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'priority_id'); + } +} diff --git a/Modules/Helpdesk/Models/Project.php b/Modules/Helpdesk/Models/Project.php new file mode 100644 index 00000000..7473b41c --- /dev/null +++ b/Modules/Helpdesk/Models/Project.php @@ -0,0 +1,30 @@ + */ + protected $fillable = ['title', 'slug', 'color', 'description', 'archived_at']; + + /** @var array */ + protected $casts = ['archived_at' => 'datetime']; + + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'project_id'); + } +} diff --git a/Modules/Helpdesk/Models/Status.php b/Modules/Helpdesk/Models/Status.php new file mode 100644 index 00000000..d91006df --- /dev/null +++ b/Modules/Helpdesk/Models/Status.php @@ -0,0 +1,35 @@ + */ + protected $fillable = ['name', 'slug', 'color', 'sort_order', 'is_closed']; + + /** @var array */ + protected $casts = ['is_closed' => 'boolean', 'sort_order' => 'integer']; + + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class, 'status_id'); + } +} diff --git a/Modules/Helpdesk/Models/Ticket.php b/Modules/Helpdesk/Models/Ticket.php new file mode 100644 index 00000000..78e93b37 --- /dev/null +++ b/Modules/Helpdesk/Models/Ticket.php @@ -0,0 +1,111 @@ + */ + protected $fillable = [ + 'reference', 'user_id', 'agent_id', 'status_id', 'priority_id', 'category_id', 'project_id', + 'subject', 'body', 'due_at', 'completed_at', 'last_activity_at', 'archived_at', + ]; + + /** @var array */ + protected $casts = [ + 'due_at' => 'datetime', + 'completed_at' => 'datetime', + 'last_activity_at' => 'datetime', + 'archived_at' => 'datetime', + ]; + + /** @return BelongsTo */ + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** @return BelongsTo */ + public function agent(): BelongsTo + { + return $this->belongsTo(User::class, 'agent_id'); + } + + /** @return BelongsTo */ + public function status(): BelongsTo + { + return $this->belongsTo(Status::class, 'status_id'); + } + + /** @return BelongsTo */ + public function priority(): BelongsTo + { + return $this->belongsTo(Priority::class, 'priority_id'); + } + + /** @return BelongsTo */ + public function category(): BelongsTo + { + return $this->belongsTo(Category::class, 'category_id'); + } + + /** @return BelongsTo */ + public function project(): BelongsTo + { + return $this->belongsTo(Project::class, 'project_id'); + } + + /** @return HasMany */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class, 'ticket_id')->latest(); + } + + /** @return HasMany */ + public function attachments(): HasMany + { + return $this->hasMany(Attachment::class, 'ticket_id')->latest(); + } + + /** @param Builder $query @return Builder */ + public function scopeOpen(Builder $query): Builder + { + return $query->whereNull('completed_at'); + } + + /** @param Builder $query @return Builder */ + 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); + } +} diff --git a/Modules/Helpdesk/Policies/TicketPolicy.php b/Modules/Helpdesk/Policies/TicketPolicy.php new file mode 100644 index 00000000..05313691 --- /dev/null +++ b/Modules/Helpdesk/Policies/TicketPolicy.php @@ -0,0 +1,49 @@ +helpdesk_admin ?? false) + || (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; + } + + /** @return bool */ + public function create(User $user): bool + { + return true; + } + + /** @return bool */ + public function update(User $user, Ticket $ticket): bool + { + return (bool) ($user->helpdesk_admin ?? false) || (int) $ticket->agent_id === (int) $user->id; + } + + /** @return bool */ + public function comment(User $user, Ticket $ticket): bool + { + return $this->view($user, $ticket); + } + + /** @return bool */ + public function delete(User $user, Ticket $ticket): bool + { + return (bool) ($user->helpdesk_admin ?? false); + } +} diff --git a/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php b/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php index c2879fba..5ba825f8 100644 --- a/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php +++ b/Modules/Helpdesk/Providers/HelpdeskServiceProvider.php @@ -1,125 +1,165 @@ registerTranslations(); - $this->registerConfig(); - $this->registerViews(); - $this->registerHelpers(); - $this->registerCommands(); - $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); - } - - /** - * Register the service provider. + * Register module services. * * @return void */ public function register(): void { $this->app->register(RouteServiceProvider::class); + + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), + ModuleInfo::nameLower() + ); } /** - * Register the helpers. + * Bootstrap module services. + * + * @return void + */ + 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. + * + * @return void + */ + protected function registerLivewireComponents(): void + { + Livewire::component('helpdesk.ticket-index', TicketIndex::class); + Livewire::component('helpdesk.ticket-create', TicketCreate::class); + Livewire::component('helpdesk.ticket-show', TicketShow::class); + Livewire::component('helpdesk.dashboard-widget', HelpdeskDashboardWidget::class); + } + + /** + * Register policies and gates. + * + * @return void + */ + 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. * * @return void */ protected function registerHelpers(): void { - foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) { require_once $filename; } } - /** - * Dynamically Register Commands. + /** + * Dynamically register console commands. * * @return void */ - public function registerCommands(): void - { - if (!$this->app->runningInConsole()) { - return; - } - $commandPath = __DIR__ . '/../Console'; - $commands = collect(File::allFiles($commandPath)) - ->map(function ($file) { - $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); - return $class; - }) - ->filter(function ($class) { - return class_exists($class); - }) - ->values() - ->all(); - $this->commands($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. + * Register config publishing. * * @return void */ protected function registerConfig(): void { $this->publishes([ - module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + module_path(ModuleInfo::name(), 'Config/config.php') => config_path(ModuleInfo::nameLower() . '.php'), ], 'config'); - $this->mergeConfigFrom( - module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() - ); } /** - * Register views. + * Register module views. * * @return void */ - public function registerViews(): void + 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()); + $sourcePath => $viewPath, + ], ['views', ModuleInfo::nameLower() . '-module-views']); + + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + + Blade::componentNamespace('Modules\Helpdesk\View\Components', 'helpdesk'); } /** @@ -127,29 +167,44 @@ class HelpdeskServiceProvider extends ServiceProvider * * @return void */ - public function registerTranslations(): void + protected function registerTranslations(): void { - $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); if (is_dir($langPath)) { - $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); - } else { - $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + + return; } + + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); } /** - * Regsiter View Paths - * @return array + * Get publishable view override paths. + * + * @return array */ 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 + */ + public function provides(): array + { + return []; + } } diff --git a/Modules/Helpdesk/Providers/RouteServiceProvider.php b/Modules/Helpdesk/Providers/RouteServiceProvider.php index 2abe49d0..bd57dc73 100644 --- a/Modules/Helpdesk/Providers/RouteServiceProvider.php +++ b/Modules/Helpdesk/Providers/RouteServiceProvider.php @@ -1,69 +1,49 @@ mapApiRoutes(); - $this->mapWebRoutes(); } /** - * Define the "web" routes for the application. - * - * These routes all receive session state, CSRF protection, etc. + * Define web routes. * * @return void */ - protected function mapWebRoutes() + protected function mapWebRoutes(): void { - Route::middleware('web') - ->namespace($this->moduleNamespace) - ->group(module_path('Helpdesk', '/Routes/web.php')); + Route::middleware(config('helpdesk.middleware', ['web', 'auth'])) + ->prefix(config('helpdesk.route_prefix', 'helpdesk')) + ->name('helpdesk.') + ->group(module_path(ModuleInfo::name(), 'Routes/web.php')); } /** - * Define the "api" routes for the application. - * - * These routes are typically stateless. + * Define API routes. * * @return void */ - protected function mapApiRoutes() + protected function mapApiRoutes(): void { - Route::prefix('api') + Route::prefix('api/helpdesk') ->middleware('api') - ->namespace($this->moduleNamespace) - ->group(module_path('Helpdesk', '/Routes/api.php')); + ->name('api.helpdesk.') + ->group(module_path(ModuleInfo::name(), 'Routes/api.php')); } } diff --git a/Modules/Helpdesk/Resources/assets/css/.gitkeep b/Modules/Helpdesk/Resources/assets/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/Helpdesk/Resources/assets/css/app.css b/Modules/Helpdesk/Resources/assets/css/app.css new file mode 100644 index 00000000..d9f96508 --- /dev/null +++ b/Modules/Helpdesk/Resources/assets/css/app.css @@ -0,0 +1 @@ +.helpdesk-module{} diff --git a/Modules/Helpdesk/Resources/assets/js/.gitkeep b/Modules/Helpdesk/Resources/assets/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Modules/Helpdesk/Resources/assets/js/app.js b/Modules/Helpdesk/Resources/assets/js/app.js index 656ed5e3..919f48df 100644 --- a/Modules/Helpdesk/Resources/assets/js/app.js +++ b/Modules/Helpdesk/Resources/assets/js/app.js @@ -1 +1,3 @@ -window.Helpdesk = window.Helpdesk || {}; +import './bootstrap'; + +console.info('Helpdesk module assets loaded'); diff --git a/Modules/Helpdesk/Resources/assets/js/bootstrap.js b/Modules/Helpdesk/Resources/assets/js/bootstrap.js new file mode 100644 index 00000000..f7ae55eb --- /dev/null +++ b/Modules/Helpdesk/Resources/assets/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; + +window.axios = window.axios || axios; +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/Modules/Helpdesk/Resources/views/dashboard/card.blade.php b/Modules/Helpdesk/Resources/views/dashboard/card.blade.php new file mode 100644 index 00000000..37648219 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/dashboard/card.blade.php @@ -0,0 +1 @@ + diff --git a/Modules/Helpdesk/Resources/views/index.blade.php b/Modules/Helpdesk/Resources/views/index.blade.php index 0adc337d..cc9adc5a 100644 --- a/Modules/Helpdesk/Resources/views/index.blade.php +++ b/Modules/Helpdesk/Resources/views/index.blade.php @@ -1,9 +1,3 @@ -@extends('helpdesk::layouts.master') - -@section('content') -

Hello World

- -

- This view is loaded from module: {!! config('helpdesk.name') !!} -

-@endsection + + + diff --git a/Modules/Helpdesk/Resources/views/layouts/master.blade.php b/Modules/Helpdesk/Resources/views/layouts/master.blade.php index cf299290..805cc84b 100644 --- a/Modules/Helpdesk/Resources/views/layouts/master.blade.php +++ b/Modules/Helpdesk/Resources/views/layouts/master.blade.php @@ -1,17 +1,4 @@ - - - - - - - Module Helpdesk - - @vite([ - 'Resources/assets/sass/app.scss', - 'Resources/assets/js/app.js', - ], 'build/helpdesk') - - - @yield('content') - - + + {{ $slot ?? '' }} + @yield('content') + diff --git a/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php new file mode 100644 index 00000000..d8bfb77d --- /dev/null +++ b/Modules/Helpdesk/Resources/views/livewire/dashboard-widget.blade.php @@ -0,0 +1,38 @@ +
+
+
+

Helpdesk

+

Your current support workload.

+
+ View all +
+ +
+
+
{{ $openCount }}
+
Open
+
+
+
{{ $criticalCount }}
+
Critical
+
+
+
{{ $closedCount }}
+
Closed
+
+
+ +
+ @forelse ($recentTickets as $ticket) + +
+ {{ $ticket->subject }} + {{ $ticket->priority->name }} +
+
{{ $ticket->reference }} · {{ $ticket->status->name }}
+
+ @empty +
No helpdesk tickets yet.
+ @endforelse +
+
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php new file mode 100644 index 00000000..a976b028 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-create.blade.php @@ -0,0 +1,55 @@ +
+
+

Create ticket

+

Raise a support request with optional attachments.

+
+ +
+
+
+ + +
+ +
+ + + @foreach ($priorities as $priority) + + @endforeach + + + + + @foreach ($categories as $category) + + @endforeach + + + + + @foreach ($projects as $project) + + @endforeach + + + + +
+ + +
Allowed: {{ implode(', ', config('helpdesk.allowed_mimes')) }}. Max {{ config('helpdesk.max_upload_kb') }}KB each.
+ @error('attachments.*')
{{ $message }}
@enderror +
+
+
+ +
+ Cancel + + Create ticket + Creating... + +
+
+
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php new file mode 100644 index 00000000..9a63a86f --- /dev/null +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-index.blade.php @@ -0,0 +1,90 @@ +
+
+
+

Helpdesk

+

Search, triage and manage support tickets.

+
+ + + New ticket + +
+ + @if (session('success')) + {{ session('success') }} + @endif + +
+
+ + + + + + + + + + + @foreach ($statuses as $status) + + @endforeach + + + + + @foreach ($priorities as $priority) + + @endforeach + +
+
+ +
+
+ Refreshing tickets... +
+ +
+ + + + + + + + + + + + + @forelse ($tickets as $ticket) + + + + + + + + + @empty + + + + @endforelse + +
ReferenceSubjectStatusPriorityAgentLast activity
+ + {{ $ticket->reference }} + + +
{{ $ticket->subject }}
+
{{ $ticket->category?->name ?? 'No category' }}
+
{{ $ticket->status->name }}{{ $ticket->priority->name }}{{ $ticket->agent?->name ?? 'Unassigned' }}{{ $ticket->last_activity_at?->diffForHumans() ?? $ticket->created_at->diffForHumans() }}
No tickets matched your filters.
+
+ +
+ {{ $tickets->links() }} +
+
+
diff --git a/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php new file mode 100644 index 00000000..39317eb7 --- /dev/null +++ b/Modules/Helpdesk/Resources/views/livewire/ticket-show.blade.php @@ -0,0 +1,119 @@ +
+
+
+
+ {{ $ticket->reference }} + {{ $ticket->status->name }} + {{ $ticket->priority->name }} +
+

{{ $ticket->subject }}

+

+ Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }} +

+
+ Back +
+ +
+
+
+
+ {!! nl2br(e($ticket->body)) !!} +
+
+ + @if ($ticket->attachments->isNotEmpty()) +
+

Ticket attachments

+
+ @foreach ($ticket->attachments as $attachment) + +
{{ $attachment->original_name }}
+
{{ Number::fileSize($attachment->size) }}
+
+ @endforeach +
+
+ @endif + +
+

Conversation

+ + @forelse ($ticket->comments as $comment) +
+
+
+
{{ $comment->user?->name ?? 'Unknown user' }}
+
{{ $comment->created_at->diffForHumans() }}
+
+ @if ($comment->is_internal) + Internal note + @endif +
+
{!! nl2br(e($comment->body)) !!}
+ + @if ($comment->attachments->isNotEmpty()) +
+ @foreach ($comment->attachments as $attachment) + + {{ $attachment->original_name }} + + @endforeach +
+ @endif +
+ @empty +
No comments yet.
+ @endforelse +
+ +
+

Add reply

+ + + @can('update', $ticket) +
+ +
+ @endcan + +
+ + + @error('attachments.*')
{{ $message }}
@enderror +
+ +
+ + Post reply + Posting... + +
+ +
+ + +
+
diff --git a/Modules/Helpdesk/Routes/api.php b/Modules/Helpdesk/Routes/api.php index 105c826e..1fcabb32 100644 --- a/Modules/Helpdesk/Routes/api.php +++ b/Modules/Helpdesk/Routes/api.php @@ -1,18 +1,10 @@ get('/helpdesk', function (Request $request) { - return $request->user(); -}); \ No newline at end of file +Route::get('/status', static fn (): array => [ + 'module' => 'helpdesk', + 'status' => 'ok', +])->name('status'); diff --git a/Modules/Helpdesk/Routes/web.php b/Modules/Helpdesk/Routes/web.php index d06d45d0..d6766827 100644 --- a/Modules/Helpdesk/Routes/web.php +++ b/Modules/Helpdesk/Routes/web.php @@ -1,16 +1,12 @@ group(function() { - Route::get('/', 'HelpdeskController@index'); -}); +use Illuminate\Support\Facades\Route; +use Modules\Helpdesk\Livewire\TicketCreate; +use Modules\Helpdesk\Livewire\TicketIndex; +use Modules\Helpdesk\Livewire\TicketShow; + +Route::get('/', TicketIndex::class)->name('tickets.index'); +Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); +Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); diff --git a/Modules/Helpdesk/Services/AgentAssignmentService.php b/Modules/Helpdesk/Services/AgentAssignmentService.php new file mode 100644 index 00000000..8b47e197 --- /dev/null +++ b/Modules/Helpdesk/Services/AgentAssignmentService.php @@ -0,0 +1,43 @@ +where(static function (Builder $query): void { + $query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true); + }) + ->withCount(['helpdeskAssignedTickets as open_helpdesk_tickets_count' => static function (Builder $query): void { + $query->whereNull('completed_at'); + }]); + + if ($categoryId !== null) { + $category = Category::query()->find($categoryId); + $agentIds = $category?->agents()->pluck('users.id')->all() ?? []; + + if ($agentIds !== []) { + $query->whereIn('id', $agentIds); + } + } + + /** @var User|null $agent */ + $agent = $query->orderBy('open_helpdesk_tickets_count')->orderBy('id')->first(); + + return $agent?->id; + } +} diff --git a/Modules/Helpdesk/Services/AttachmentService.php b/Modules/Helpdesk/Services/AttachmentService.php new file mode 100644 index 00000000..d1eddff6 --- /dev/null +++ b/Modules/Helpdesk/Services/AttachmentService.php @@ -0,0 +1,54 @@ + $files + * @param int $userId + * @param Comment|null $comment + * @return void + */ + public function storeMany(Ticket $ticket, array $files, int $userId, ?Comment $comment = null): void + { + foreach ($files as $file) { + $disk = (string) config('helpdesk.disk'); + $path = $file->store('tickets/' . $ticket->id, $disk); + + Attachment::query()->create([ + 'ticket_id' => $ticket->id, + 'comment_id' => $comment?->id, + 'user_id' => $userId, + 'disk' => $disk, + 'path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getClientMimeType(), + 'size' => $file->getSize() ?: 0, + ]); + } + } + + /** + * Delete a stored attachment and its backing file. + * + * @param Attachment $attachment + * @return void + */ + public function delete(Attachment $attachment): void + { + Storage::disk($attachment->disk)->delete($attachment->path); + $attachment->delete(); + } +} diff --git a/Modules/Helpdesk/Services/TicketReferenceService.php b/Modules/Helpdesk/Services/TicketReferenceService.php new file mode 100644 index 00000000..7f10045b --- /dev/null +++ b/Modules/Helpdesk/Services/TicketReferenceService.php @@ -0,0 +1,29 @@ +format('Y'); + $prefix = 'HD-' . $year . '-'; + $last = Ticket::query() + ->where('reference', 'like', $prefix . '%') + ->latest('id') + ->value('reference'); + + $next = $last ? ((int) substr((string) $last, -5)) + 1 : 1; + + return $prefix . str_pad((string) $next, 5, '0', STR_PAD_LEFT); + } +} diff --git a/Modules/Helpdesk/Services/TicketService.php b/Modules/Helpdesk/Services/TicketService.php new file mode 100644 index 00000000..6ac6413a --- /dev/null +++ b/Modules/Helpdesk/Services/TicketService.php @@ -0,0 +1,102 @@ + $data + * @param array $files + * @return Ticket + */ + public function create(User $user, array $data, array $files = []): Ticket + { + return DB::transaction(function () use ($user, $data, $files): Ticket { + $status = Status::query()->where('slug', config('helpdesk.default_status_slug'))->firstOrFail(); + + /** @var Ticket $ticket */ + $ticket = Ticket::query()->create([ + 'reference' => $this->references->next(), + 'user_id' => $user->id, + 'agent_id' => $this->assignment->selectAgentId($data['category_id'] ?? null), + 'status_id' => $status->id, + 'priority_id' => $data['priority_id'], + 'category_id' => $data['category_id'] ?? null, + 'project_id' => $data['project_id'] ?? null, + 'subject' => $data['subject'], + 'body' => $data['body'], + 'due_at' => $data['due_at'] ?? null, + 'last_activity_at' => now(), + ]); + + $this->attachments->storeMany($ticket, $files, (int) $user->id); + + return $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'attachments']) ?? $ticket; + }); + } + + /** + * Add a public or internal comment and optional attachments. + * + * @param Ticket $ticket + * @param User $user + * @param string $body + * @param bool $internal + * @param array $files + * @return Comment + */ + public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = []): Comment + { + return DB::transaction(function () use ($ticket, $user, $body, $internal, $files): Comment { + /** @var Comment $comment */ + $comment = $ticket->comments()->create([ + 'user_id' => $user->id, + 'body' => $body, + 'is_internal' => $internal, + ]); + + $ticket->forceFill(['last_activity_at' => now()])->save(); + $this->attachments->storeMany($ticket, $files, (int) $user->id, $comment); + + return $comment->fresh(['user', 'attachments']) ?? $comment; + }); + } + + /** + * Close a ticket. + * + * @param Ticket $ticket + * @param User $user + * @return Ticket + */ + public function close(Ticket $ticket, User $user): Ticket + { + $status = Status::query()->where('slug', config('helpdesk.closed_status_slug'))->firstOrFail(); + + $ticket->forceFill([ + 'status_id' => $status->id, + 'completed_at' => now(), + 'last_activity_at' => now(), + ])->save(); + + return $ticket->fresh(['status']) ?? $ticket; + } +} diff --git a/Modules/Helpdesk/Support/ModuleInfo.php b/Modules/Helpdesk/Support/ModuleInfo.php index e0c9285f..1fdaf00e 100644 --- a/Modules/Helpdesk/Support/ModuleInfo.php +++ b/Modules/Helpdesk/Support/ModuleInfo.php @@ -1,26 +1,24 @@ create(['name' => 'Open', 'slug' => 'open', 'color' => 'blue']); + Priority::query()->create(['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue']); + + $user = User::factory()->create(); + + $this->browse(function (Browser $browser) use ($user): void { + $browser->loginAs($user) + ->visitRoute('helpdesk.tickets.index') + ->waitFor('@helpdesk-ticket-index') + ->assertSee('Helpdesk') + ->clickLink('New ticket') + ->waitFor('@helpdesk-ticket-create') + ->assertSee('Create ticket'); + }); + } +} diff --git a/Modules/Helpdesk/Tests/Feature/HelpdeskTicketTest.php b/Modules/Helpdesk/Tests/Feature/HelpdeskTicketTest.php new file mode 100644 index 00000000..a00137eb --- /dev/null +++ b/Modules/Helpdesk/Tests/Feature/HelpdeskTicketTest.php @@ -0,0 +1,40 @@ +create(['name' => 'Open', 'slug' => 'open', 'color' => 'blue']); + Priority::query()->create(['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue']); + + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(TicketCreate::class) + ->set('subject', 'Printer is not working') + ->set('body', 'The office printer is showing an error code and will not print.') + ->set('priority_id', Priority::query()->firstOrFail()->id) + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('helpdesk_tickets', [ + 'subject' => 'Printer is not working', + 'user_id' => $user->id, + ]); + } +} diff --git a/Modules/Helpdesk/composer.json b/Modules/Helpdesk/composer.json index 1d558f65..3dc67688 100644 --- a/Modules/Helpdesk/composer.json +++ b/Modules/Helpdesk/composer.json @@ -1,6 +1,6 @@ { "name": "mpba/helpdesk", - "description": "Helpdesk module", + "description": "Livewire/Flux helpdesk module with ticketing, comments, attachments, assignment and dashboard widgets.", "authors": [ { "name": "Mark Gregory", @@ -20,7 +20,6 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/", "Modules\\Helpdesk\\Tests\\": "Tests/" } } diff --git a/Modules/Helpdesk/module.json b/Modules/Helpdesk/module.json index 0c7b3433..4328c8a5 100644 --- a/Modules/Helpdesk/module.json +++ b/Modules/Helpdesk/module.json @@ -1,14 +1,17 @@ { "name": "Helpdesk", "alias": "helpdesk", - "version": "1.0.0", - "description": "", - "keywords": [], + "description": "Livewire/Flux helpdesk module with ticketing, comments, attachments, assignment and dashboard widgets.", + "keywords": [ + "helpdesk", + "tickets", + "livewire", + "flux" + ], "priority": 0, "providers": [ "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" ], - "aliases": {}, "files": [], - "requires": [] + "version": "1.0.1" } diff --git a/Modules/Helpdesk/package.json b/Modules/Helpdesk/package.json index 7e63fa3a..b5b870d5 100644 --- a/Modules/Helpdesk/package.json +++ b/Modules/Helpdesk/package.json @@ -2,14 +2,14 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host 0.0.0.0", "build": "vite build" }, "devDependencies": { "@vitejs/plugin-vue": "latest", "axios": "latest", "laravel-vite-plugin": "latest", - "sass-embedded": "^1.100.0", "vite": "latest" - } + }, + "dependencies": {} } diff --git a/Modules/Helpdesk/vite.config.js b/Modules/Helpdesk/vite.config.js index 8d79262d..2b9e384d 100644 --- a/Modules/Helpdesk/vite.config.js +++ b/Modules/Helpdesk/vite.config.js @@ -12,13 +12,14 @@ export default defineConfig({ publicDirectory: '../../public', buildDirectory: 'build/helpdesk', input: [ - 'Resources/assets/sass/app.scss', + 'Resources/assets/css/app.css', 'Resources/assets/js/app.js', ], refresh: [ 'Resources/views/**/*.blade.php', 'Routes/**/*.php', 'Http/**/*.php', + 'Livewire/**/*.php', 'Providers/**/*.php', ], }),