new helpdesk module
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
+45
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
+61
@@ -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');
|
||||
}
|
||||
};
|
||||
+43
@@ -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');
|
||||
}
|
||||
};
|
||||
+46
@@ -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');
|
||||
}
|
||||
};
|
||||
+27
@@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -1 +1,3 @@
|
||||
window.Helpdesk = window.Helpdesk || {};
|
||||
import './bootstrap';
|
||||
|
||||
console.info('Helpdesk module assets loaded');
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user