new helpdesk module

This commit is contained in:
Mark Gregory
2026-06-12 09:41:57 +01:00
parent 67c9bb22d9
commit da35c7eb95
50 changed files with 1874 additions and 270 deletions
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Concerns;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\Helpdesk\Models\Ticket;
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');
}
/** @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);
}
}
+9 -4
View File
@@ -1,8 +1,13 @@
<?php
declare(strict_types=1);
return [
'name' => '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',
];
@@ -0,0 +1,45 @@
<?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.
*
* @return void
*/
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');
}
});
}
/**
* 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');
}
});
}
};
@@ -0,0 +1,61 @@
<?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 {
/** @return void */
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->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');
}
};
@@ -0,0 +1,43 @@
<?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 {
/** @return void */
public function up(): void
{
Schema::create('helpdesk_tickets', static function (Blueprint $table): void {
$table->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');
}
};
@@ -0,0 +1,46 @@
<?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 {
/** @return void */
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('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');
}
};
@@ -0,0 +1,27 @@
<?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 {
/** @return void */
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']);
});
}
/** @return void */
public function down(): void
{
Schema::dropIfExists('helpdesk_category_user');
}
};
@@ -1,21 +1,47 @@
<?php
namespace Modules\Helpdesk\Database\Seeders;
declare(strict_types=1);
namespace Modules\Helpdesk\database\seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
class HelpdeskDatabaseSeeder extends Seeder
final class HelpdeskDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
/** @return void */
public function run(): void
{
Model::unguard();
foreach ([
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false],
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false],
['name' => '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.',
]);
}
}
+93
View File
@@ -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
<livewire:helpdesk.dashboard-widget />
```
## 7. Navigation link
```blade
<flux:navlist.item icon="lifebuoy" :href="route('helpdesk.tickets.index')" :current="request()->routeIs('helpdesk.*')" wire:navigate>
Helpdesk
</flux:navlist.item>
```
## 8. Tests
```bash
php artisan test Modules/Helpdesk/tests/Feature
php artisan dusk Modules/Helpdesk/tests/Browser
```
+6 -5
View File
@@ -1,12 +1,13 @@
<?php
use Modules\Helpdesk\Support\ModuleInfo;
if (! function_exists('helpdesk_feature_enabled')) {
declare(strict_types=1);
if (! function_exists('helpdesk_module_name')) {
/**
* Check if a feature is enabled in the config.
* Return the Helpdesk module name.
*/
function helpdesk_feature_enabled(string $key): bool
function helpdesk_module_name(): string
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
return 'Helpdesk';
}
}
@@ -1,79 +1,19 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
class HelpdeskController extends Controller
final class HelpdeskController extends Controller
{
/**
* Display a listing of the resource.
* @return Renderable
* Display the Helpdesk module landing page.
*/
public function index()
public function __invoke(): View
{
return view('helpdesk::index');
}
/**
* Show the form for creating a new resource.
* @return Renderable
*/
public function create()
{
return view('helpdesk::create');
}
/**
* Store a newly created resource in storage.
* @param Request $request
* @return Renderable
*/
public function store(Request $request)
{
//
}
/**
* Show the specified resource.
* @param int $id
* @return Renderable
*/
public function show($id)
{
return view('helpdesk::show');
}
/**
* Show the form for editing the specified resource.
* @param int $id
* @return Renderable
*/
public function edit($id)
{
return view('helpdesk::edit');
}
/**
* Update the specified resource in storage.
* @param Request $request
* @param int $id
* @return Renderable
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
* @param int $id
* @return Renderable
*/
public function destroy($id)
{
//
}
}
@@ -0,0 +1,28 @@
<?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\Ticket;
final class HelpdeskDashboardWidget extends Component
{
/** @return View */
public function render(): View
{
$user = Auth::user();
$base = Ticket::query()->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(),
]);
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Services\TicketService;
final class TicketCreate extends Component
{
use WithFileUploads;
#[Validate('required|string|max:200')]
public string $subject = '';
#[Validate('required|string|min:10')]
public string $body = '';
#[Validate('required|integer|exists:helpdesk_priorities,id')]
public ?int $priority_id = null;
#[Validate('nullable|integer|exists:helpdesk_categories,id')]
public ?int $category_id = null;
#[Validate('nullable|integer|exists:helpdesk_projects,id')]
public ?int $project_id = null;
#[Validate('nullable|date')]
public ?string $due_at = null;
/** @var array<int, TemporaryUploadedFile> */
#[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');
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
final class TicketIndex extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
#[Url]
public string $priority = '';
#[Url]
public string $scope = 'open';
/**
* Reset pagination when filters change.
*
* @return void
*/
public function updated(): void
{
$this->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<int, Ticket>
*/
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);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\TicketService;
final class TicketShow extends Component
{
use WithFileUploads;
public Ticket $ticket;
#[Validate('required|string|min:2')]
public string $commentBody = '';
public bool $internal = false;
/** @var array<int, TemporaryUploadedFile> */
public array $attachments = [];
public ?int $status_id = null;
/**
* 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');
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
final class Attachment extends Model
{
use HasFactory;
protected $table = 'helpdesk_attachments';
/** @var list<string> */
protected $fillable = ['ticket_id', 'comment_id', 'user_id', 'disk', 'path', 'original_name', 'mime_type', 'size'];
/** @var array<string, string> */
protected $casts = ['size' => 'integer'];
/** @return BelongsTo<Ticket, Attachment> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
/** @return BelongsTo<Comment, Attachment> */
public function comment(): BelongsTo
{
return $this->belongsTo(Comment::class, 'comment_id');
}
/** @return BelongsTo<User, Attachment> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/** @return string */
public function url(): string
{
return Storage::disk($this->disk)->url($this->path);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Category extends Model
{
use HasFactory;
protected $table = 'helpdesk_categories';
/** @var list<string> */
protected $fillable = ['name', 'slug', 'color', 'description'];
/** @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();
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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\HasMany;
final class Comment extends Model
{
use HasFactory;
protected $table = 'helpdesk_comments';
/** @var list<string> */
protected $fillable = ['ticket_id', 'user_id', 'body', 'is_internal'];
/** @var array<string, string> */
protected $casts = ['is_internal' => 'boolean'];
/** @return BelongsTo<Ticket, Comment> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
/** @return BelongsTo<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');
}
}
+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'];
/** @var array<string, string> */
protected $casts = ['sort_order' => 'integer'];
/** @return HasMany<Ticket> */
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'priority_id');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Project extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'helpdesk_projects';
/** @var list<string> */
protected $fillable = ['title', 'slug', 'color', 'description', 'archived_at'];
/** @var array<string, string> */
protected $casts = ['archived_at' => 'datetime'];
/** @return HasMany<Ticket> */
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'project_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'];
/** @var array<string, string> */
protected $casts = ['is_closed' => 'boolean', 'sort_order' => 'integer'];
/** @return HasMany<Ticket> */
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'status_id');
}
}
+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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
* @property string $reference
* @property string $subject
* @property string $body
*/
final class Ticket extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'helpdesk_tickets';
/** @var list<string> */
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<string, string> */
protected $casts = [
'due_at' => 'datetime',
'completed_at' => 'datetime',
'last_activity_at' => 'datetime',
'archived_at' => 'datetime',
];
/** @return BelongsTo<User, Ticket> */
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/** @return BelongsTo<User, Ticket> */
public function agent(): BelongsTo
{
return $this->belongsTo(User::class, 'agent_id');
}
/** @return BelongsTo<Status, Ticket> */
public function status(): BelongsTo
{
return $this->belongsTo(Status::class, 'status_id');
}
/** @return BelongsTo<Priority, Ticket> */
public function priority(): BelongsTo
{
return $this->belongsTo(Priority::class, 'priority_id');
}
/** @return BelongsTo<Category, Ticket> */
public function category(): BelongsTo
{
return $this->belongsTo(Category::class, 'category_id');
}
/** @return BelongsTo<Project, Ticket> */
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'project_id');
}
/** @return HasMany<Comment> */
public function comments(): HasMany
{
return $this->hasMany(Comment::class, 'ticket_id')->latest();
}
/** @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);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Policies;
use App\Models\User;
use Modules\Helpdesk\Models\Ticket;
final class TicketPolicy
{
/** @return bool */
public function viewAny(User $user): bool
{
return true;
}
/** @return bool */
public function view(User $user, Ticket $ticket): bool
{
return (bool) ($user->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);
}
}
@@ -1,125 +1,165 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Eloquent\Factory;
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\TicketCreate;
use Modules\Helpdesk\Livewire\TicketIndex;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Policies\TicketPolicy;
use Modules\Helpdesk\Support\ModuleInfo;
use Config;
class HelpdeskServiceProvider extends ServiceProvider
final class HelpdeskServiceProvider extends ServiceProvider
{
/**
* @var string $moduleName
* Module name.
*
* @var string
*/
protected string $moduleName = 'Helpdesk';
/**
* @var string $moduleNameLower
* Lowercase module name.
*
* @var string
*/
protected string $moduleNameLower = 'helpdesk';
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides():array
{
return [];
}
/**
* Boot the application events.
*
* @return void
*/
public function boot() : void
{
$this->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<int, string>
*/
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (Config::get('view.paths') as $path) {
if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
$paths[] = $path . '/modules/' . ModuleInfo::nameLower();
}
}
return $paths;
}
/**
* Get provided services.
*
* @return array<int, string>
*/
public function provides(): array
{
return [];
}
}
@@ -1,69 +1,49 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
use Modules\Helpdesk\Support\ModuleInfo;
class RouteServiceProvider extends ServiceProvider
final class RouteServiceProvider extends ServiceProvider
{
/**
* The module namespace to assume when generating URLs to actions.
*
* @var string
*/
protected $moduleNamespace = 'Modules\Helpdesk\Http\Controllers';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
* Define module routes.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
public function map(): void
{
$this->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'));
}
}
@@ -0,0 +1 @@
.helpdesk-module{}
+3 -1
View File
@@ -1 +1,3 @@
window.Helpdesk = window.Helpdesk || {};
import './bootstrap';
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 @@
<livewire:helpdesk.dashboard-widget />
@@ -1,9 +1,3 @@
@extends('helpdesk::layouts.master')
@section('content')
<h1>Hello World</h1>
<p>
This view is loaded from module: {!! config('helpdesk.name') !!}
</p>
@endsection
<x-layouts.app :title="__('Helpdesk')">
<livewire:helpdesk.ticket-index />
</x-layouts.app>
@@ -1,17 +1,4 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Module Helpdesk</title>
@vite([
'Resources/assets/sass/app.scss',
'Resources/assets/js/app.js',
], 'build/helpdesk')
</head>
<body>
@yield('content')
</body>
</html>
<x-layouts.app :title="$title ?? __('Helpdesk')">
{{ $slot ?? '' }}
@yield('content')
</x-layouts.app>
@@ -0,0 +1,38 @@
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" dusk="helpdesk-dashboard-widget">
<div class="mb-5 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Helpdesk</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Your current support workload.</p>
</div>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate size="sm" variant="ghost">View all</flux:button>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-xl bg-blue-50 p-4 dark:bg-blue-950/40">
<div class="text-2xl font-semibold text-blue-700 dark:text-blue-200">{{ $openCount }}</div>
<div class="text-xs font-medium uppercase tracking-wide text-blue-700/70 dark:text-blue-200/70">Open</div>
</div>
<div class="rounded-xl bg-red-50 p-4 dark:bg-red-950/40">
<div class="text-2xl font-semibold text-red-700 dark:text-red-200">{{ $criticalCount }}</div>
<div class="text-xs font-medium uppercase tracking-wide text-red-700/70 dark:text-red-200/70">Critical</div>
</div>
<div class="rounded-xl bg-green-50 p-4 dark:bg-green-950/40">
<div class="text-2xl font-semibold text-green-700 dark:text-green-200">{{ $closedCount }}</div>
<div class="text-xs font-medium uppercase tracking-wide text-green-700/70 dark:text-green-200/70">Closed</div>
</div>
</div>
<div class="mt-5 space-y-2">
@forelse ($recentTickets as $ticket)
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="block rounded-xl border border-zinc-100 p-3 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
<div class="flex items-center justify-between gap-3">
<span class="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</span>
<flux:badge color="{{ $ticket->priority->color }}" size="sm">{{ $ticket->priority->name }}</flux:badge>
</div>
<div class="mt-1 text-xs text-zinc-500">{{ $ticket->reference }} · {{ $ticket->status->name }}</div>
</a>
@empty
<div class="rounded-xl border border-dashed border-zinc-300 p-4 text-center text-sm text-zinc-500 dark:border-zinc-700">No helpdesk tickets yet.</div>
@endforelse
</div>
</div>
@@ -0,0 +1,55 @@
<div class="mx-auto max-w-5xl space-y-6" dusk="helpdesk-ticket-create">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">Create ticket</h1>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Raise a support request with optional attachments.</p>
</div>
<form wire:submit="save" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="grid gap-6 md:grid-cols-3">
<div class="space-y-6 md:col-span-2">
<flux:input wire:model="subject" label="Subject" placeholder="Short summary" required />
<flux:textarea wire:model="body" label="Details" rows="10" placeholder="What happened? What did you expect? Include steps to reproduce where useful." required />
</div>
<div class="space-y-6">
<flux:select wire:model="priority_id" label="Priority" required>
<option value="">Select priority</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model="category_id" label="Category">
<option value="">Select category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model="project_id" label="Project">
<option value="">No project</option>
@foreach ($projects as $project)
<option value="{{ $project->id }}">{{ $project->title }}</option>
@endforeach
</flux:select>
<flux:input wire:model="due_at" type="date" label="Due date" />
<div>
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Attachments</label>
<input wire:model="attachments" type="file" multiple class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" />
<div class="mt-2 text-xs text-zinc-500">Allowed: {{ implode(', ', config('helpdesk.allowed_mimes')) }}. Max {{ config('helpdesk.max_upload_kb') }}KB each.</div>
@error('attachments.*') <div class="mt-2 text-sm text-red-600">{{ $message }}</div> @enderror
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-3 border-t border-zinc-200 pt-6 dark:border-zinc-700">
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary" icon="paper-airplane" wire:loading.attr="disabled">
<span wire:loading.remove>Create ticket</span>
<span wire:loading>Creating...</span>
</flux:button>
</div>
</form>
</div>
@@ -0,0 +1,90 @@
<div class="space-y-6" dusk="helpdesk-ticket-index">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">Helpdesk</h1>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Search, triage and manage support tickets.</p>
</div>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate icon="plus" variant="primary">
New ticket
</flux:button>
</div>
@if (session('success'))
<flux:callout variant="success" icon="check-circle">{{ session('success') }}</flux:callout>
@endif
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="grid gap-4 md:grid-cols-4">
<flux:input wire:model.live.debounce.350ms="search" icon="magnifying-glass" placeholder="Search reference, subject or body" />
<flux:select wire:model.live="scope">
<option value="open">Open tickets</option>
<option value="closed">Closed tickets</option>
<option value="all">All tickets</option>
</flux:select>
<flux:select wire:model.live="status">
<option value="">All statuses</option>
@foreach ($statuses as $status)
<option value="{{ $status->id }}">{{ $status->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model.live="priority">
<option value="">All priorities</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
@endforeach
</flux:select>
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div wire:loading.flex class="items-center gap-2 border-b border-zinc-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-zinc-700 dark:bg-blue-950 dark:text-blue-200">
<flux:icon.arrow-path class="size-4 animate-spin" /> Refreshing tickets...
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
<thead class="bg-zinc-50 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
<tr>
<th class="px-4 py-3">Reference</th>
<th class="px-4 py-3">Subject</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Priority</th>
<th class="px-4 py-3">Agent</th>
<th class="px-4 py-3">Last activity</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($tickets as $ticket)
<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-800/70">
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs">
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="font-semibold text-blue-700 hover:underline dark:text-blue-300">
{{ $ticket->reference }}
</a>
</td>
<td class="px-4 py-3">
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</div>
<div class="text-xs text-zinc-500">{{ $ticket->category?->name ?? 'No category' }}</div>
</td>
<td class="px-4 py-3"><flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge></td>
<td class="px-4 py-3"><flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge></td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $ticket->agent?->name ?? 'Unassigned' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-zinc-500">{{ $ticket->last_activity_at?->diffForHumans() ?? $ticket->created_at->diffForHumans() }}</td>
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-12 text-center text-zinc-500">No tickets matched your filters.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="border-t border-zinc-200 p-4 dark:border-zinc-700">
{{ $tickets->links() }}
</div>
</div>
</div>
@@ -0,0 +1,119 @@
<div class="space-y-6" dusk="helpdesk-ticket-show">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span class="font-mono text-xs text-zinc-500">{{ $ticket->reference }}</span>
<flux:badge color="{{ $ticket->status->color }}">{{ $ticket->status->name }}</flux:badge>
<flux:badge color="{{ $ticket->priority->color }}">{{ $ticket->priority->name }}</flux:badge>
</div>
<h1 class="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-white">{{ $ticket->subject }}</h1>
<p class="text-sm text-zinc-500 dark:text-zinc-400">
Raised by {{ $ticket->owner?->name ?? 'Unknown user' }} · Assigned to {{ $ticket->agent?->name ?? 'Unassigned' }}
</p>
</div>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate icon="arrow-left" variant="ghost">Back</flux:button>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<article class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="prose max-w-none dark:prose-invert">
{!! nl2br(e($ticket->body)) !!}
</div>
</article>
@if ($ticket->attachments->isNotEmpty())
<div class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket attachments</h2>
<div class="grid gap-3 sm:grid-cols-2">
@foreach ($ticket->attachments as $attachment)
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-xl border border-zinc-200 p-3 text-sm hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-800">
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $attachment->original_name }}</div>
<div class="text-xs text-zinc-500">{{ Number::fileSize($attachment->size) }}</div>
</a>
@endforeach
</div>
</div>
@endif
<section class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Conversation</h2>
@forelse ($ticket->comments as $comment)
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 {{ $comment->is_internal ? 'ring-2 ring-amber-300' : '' }}">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<div class="font-medium text-zinc-900 dark:text-zinc-100">{{ $comment->user?->name ?? 'Unknown user' }}</div>
<div class="text-xs text-zinc-500">{{ $comment->created_at->diffForHumans() }}</div>
</div>
@if ($comment->is_internal)
<flux:badge color="amber">Internal note</flux:badge>
@endif
</div>
<div class="prose max-w-none text-sm dark:prose-invert">{!! nl2br(e($comment->body)) !!}</div>
@if ($comment->attachments->isNotEmpty())
<div class="mt-4 flex flex-wrap gap-2">
@foreach ($comment->attachments as $attachment)
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200">
{{ $attachment->original_name }}
</a>
@endforeach
</div>
@endif
</div>
@empty
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">No comments yet.</div>
@endforelse
</section>
<form wire:submit="addComment" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Add reply</h2>
<flux:textarea wire:model="commentBody" label="Comment" rows="5" required />
@can('update', $ticket)
<div class="mt-4">
<flux:checkbox wire:model="internal" label="Internal note only" />
</div>
@endcan
<div class="mt-4">
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Attach files</label>
<input wire:model="attachments" type="file" multiple class="block w-full rounded-lg border border-zinc-300 bg-white p-2 text-sm dark:border-zinc-700 dark:bg-zinc-950" />
@error('attachments.*') <div class="mt-2 text-sm text-red-600">{{ $message }}</div> @enderror
</div>
<div class="mt-5 flex justify-end">
<flux:button type="submit" variant="primary" icon="chat-bubble-left-right" wire:loading.attr="disabled">
<span wire:loading.remove>Post reply</span>
<span wire:loading>Posting...</span>
</flux:button>
</div>
</form>
</div>
<aside class="space-y-6">
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Ticket details</h2>
<dl class="space-y-3 text-sm">
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Category</dt><dd>{{ $ticket->category?->name ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Project</dt><dd>{{ $ticket->project?->title ?? 'None' }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Created</dt><dd>{{ $ticket->created_at->format('d M Y H:i') }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-zinc-500">Due</dt><dd>{{ $ticket->due_at?->format('d M Y') ?? 'No due date' }}</dd></div>
</dl>
</div>
@can('update', $ticket)
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<h2 class="mb-4 text-base font-semibold text-zinc-900 dark:text-white">Agent actions</h2>
<flux:select wire:model="status_id" label="Status">
@foreach ($statuses as $status)
<option value="{{ $status->id }}">{{ $status->name }}</option>
@endforeach
</flux:select>
<flux:button wire:click="updateStatus" class="mt-4 w-full" variant="primary" icon="check">Update status</flux:button>
</div>
@endcan
</aside>
</div>
</div>
+6 -14
View File
@@ -1,18 +1,10 @@
<?php
use Illuminate\Http\Request;
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
use Illuminate\Support\Facades\Route;
Route::middleware('auth:api')->get('/helpdesk', function (Request $request) {
return $request->user();
});
Route::get('/status', static fn (): array => [
'module' => 'helpdesk',
'status' => 'ok',
])->name('status');
+9 -13
View File
@@ -1,16 +1,12 @@
<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
declare(strict_types=1);
Route::prefix('helpdesk')->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');
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Modules\Helpdesk\Models\Category;
final class AgentAssignmentService
{
/**
* Select the least-loaded available agent for a category.
*
* @param int|null $categoryId
* @return int|null
*/
public function selectAgentId(?int $categoryId): ?int
{
$query = User::query()
->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;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Ticket;
final class AttachmentService
{
/**
* Store uploaded files against a ticket and optional comment.
*
* @param Ticket $ticket
* @param array<int, UploadedFile> $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();
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use Modules\Helpdesk\Models\Ticket;
final class TicketReferenceService
{
/**
* Build the next human-readable ticket reference.
*
* @return string
*/
public function next(): string
{
$year = now()->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);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
final class TicketService
{
public function __construct(
private readonly TicketReferenceService $references,
private readonly AgentAssignmentService $assignment,
private readonly AttachmentService $attachments,
) {
}
/**
* Create a ticket and store initial attachments.
*
* @param User $user
* @param array<string, mixed> $data
* @param array<int, \Illuminate\Http\UploadedFile> $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<int, \Illuminate\Http\UploadedFile> $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;
}
}
+7 -9
View File
@@ -1,26 +1,24 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Support;
class ModuleInfo
final class ModuleInfo
{
protected static string $moduleName = 'Helpdesk';
/**
* defines the Module Name
* @return string
* Return the module name.
*/
public static function name(): string
{
return static::$moduleName;
return 'Helpdesk';
}
/**
* define the lowercase version
* @return string
* Return the lowercase module name.
*/
public static function nameLower(): string
{
return strtolower(static::$moduleName);
return 'helpdesk';
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\tests\Browser;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Tests\DuskTestCase;
final class HelpdeskBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
/** @test */
public function a_user_can_open_the_helpdesk_index_and_create_page(): void
{
Status::query()->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');
});
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Tests\TestCase;
final class HelpdeskTicketTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function a_user_can_create_a_ticket(): void
{
Status::query()->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,
]);
}
}
+1 -2
View File
@@ -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/"
}
}
+8 -5
View File
@@ -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"
}
+3 -3
View File
@@ -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": {}
}
+2 -1
View File
@@ -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',
],
}),