new helpdesk module

This commit is contained in:
Mark Gregory
2026-06-12 10:56:36 +01:00
parent 67faed6165
commit 98c6ee4597
49 changed files with 2554 additions and 255 deletions
@@ -13,12 +13,13 @@ return new class extends Migration {
Schema::create('helpdesk_comments', static function (Blueprint $table): void {
$table->id();
$table->foreignId('ticket_id')->constrained('helpdesk_tickets')->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('helpdesk_comments')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->longText('body');
$table->boolean('is_internal')->default(false);
$table->timestamps();
$table->index(['ticket_id', 'created_at']);
$table->index(['ticket_id', 'parent_id', 'created_at']);
});
Schema::create('helpdesk_attachments', static function (Blueprint $table): void {
@@ -33,7 +34,7 @@ return new class extends Migration {
$table->unsignedBigInteger('size')->default(0);
$table->timestamps();
$table->index(['ticket_id', 'created_at']);
$table->index(['ticket_id', 'comment_id', 'created_at']);
});
}
@@ -0,0 +1,87 @@
<?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_git_repositories', static function (Blueprint $table): void {
$table->id();
$table->foreignId('company_id')->nullable()->constrained('companies')->nullOnDelete();
$table->foreignId('project_id')->nullable()->constrained('helpdesk_projects')->nullOnDelete();
$table->string('name', 160);
$table->string('provider', 40)->default('git');
$table->string('remote_url', 500);
$table->string('default_branch', 120)->default('main');
$table->string('webhook_secret', 120)->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['company_id', 'project_id']);
$table->index(['provider', 'is_active']);
});
Schema::create('helpdesk_git_commits', static function (Blueprint $table): void {
$table->id();
$table->foreignId('repository_id')->constrained('helpdesk_git_repositories')->cascadeOnDelete();
$table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete();
$table->string('hash', 80);
$table->string('short_hash', 16);
$table->string('branch', 120)->nullable();
$table->string('author_name', 160)->nullable();
$table->string('author_email', 190)->nullable();
$table->text('message');
$table->string('url', 500)->nullable();
$table->timestamp('committed_at')->nullable();
$table->timestamps();
$table->unique(['repository_id', 'hash']);
$table->index(['ticket_id', 'committed_at']);
});
Schema::create('helpdesk_workflow_templates', static function (Blueprint $table): void {
$table->id();
$table->string('name', 160);
$table->string('event', 80);
$table->string('subject', 220);
$table->longText('html_body');
$table->longText('text_body')->nullable();
$table->string('to_role', 40)->default('requester');
$table->string('cc', 500)->nullable();
$table->string('bcc', 500)->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['event', 'is_active']);
});
Schema::create('helpdesk_workflow_logs', static function (Blueprint $table): void {
$table->id();
$table->foreignId('template_id')->nullable()->constrained('helpdesk_workflow_templates')->nullOnDelete();
$table->foreignId('ticket_id')->nullable()->constrained('helpdesk_tickets')->nullOnDelete();
$table->string('event', 80);
$table->string('recipient', 190)->nullable();
$table->string('subject', 220)->nullable();
$table->string('status', 40)->default('pending');
$table->text('error')->nullable();
$table->timestamps();
$table->index(['ticket_id', 'event']);
$table->index(['status', 'created_at']);
});
}
/** @return void */
public function down(): void
{
Schema::dropIfExists('helpdesk_workflow_logs');
Schema::dropIfExists('helpdesk_workflow_templates');
Schema::dropIfExists('helpdesk_git_commits');
Schema::dropIfExists('helpdesk_git_repositories');
}
};
@@ -11,6 +11,7 @@ use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\WorkflowTemplate;
final class HelpdeskDatabaseSeeder extends Seeder
{
@@ -51,5 +52,18 @@ final class HelpdeskDatabaseSeeder extends Seeder
'color' => 'blue',
'description' => 'Default project for internal helpdesk tickets.',
]);
foreach ([
['name' => 'Ticket raised requester notice', 'event' => 'ticket.created', 'subject' => 'Ticket {{ ticket.reference }} has been raised', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been raised successfully.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket updated requester notice', 'event' => 'ticket.updated', 'subject' => 'Ticket {{ ticket.reference }} has been updated', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been updated.</p><p><strong>Status:</strong> {{ ticket.status }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket response requester notice', 'event' => 'ticket.commented', 'subject' => 'New response on ticket {{ ticket.reference }}', 'html_body' => '<p>Hello {{ requester.name }},</p><p>A new response has been added to <strong>{{ ticket.reference }}</strong>.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket closed requester notice', 'event' => 'ticket.closed', 'subject' => 'Ticket {{ ticket.reference }} has been closed', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been closed.</p><p>Thank you for using the support desk.</p>', 'to_role' => 'requester'],
] as $template) {
WorkflowTemplate::query()->updateOrCreate(
['name' => $template['name'], 'event' => $template['event']],
$template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true]
);
}
}
}
@@ -7,6 +7,7 @@ namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Models\Ticket;
final class HelpdeskDashboardWidget extends Component
@@ -15,13 +16,22 @@ final class HelpdeskDashboardWidget extends Component
public function render(): View
{
$user = Auth::user();
$base = Ticket::query()->visibleTo($user);
$totalCount = (clone $base)->count();
$openCount = (clone $base)->whereNull('completed_at')->count();
$closedCount = (clone $base)->whereNotNull('completed_at')->count();
$criticalCount = (clone $base)->whereNull('completed_at')->whereHas('priority', static fn ($q) => $q->whereIn('slug', ['critical', 'urgent', 'high']))->count();
$closedPercent = $totalCount > 0 ? (int) round(($closedCount / $totalCount) * 100) : 0;
$openPercent = $totalCount > 0 ? (int) round(($openCount / $totalCount) * 100) : 0;
return view('helpdesk::livewire.dashboard-widget', [
'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(),
'totalCount' => $totalCount,
'openCount' => $openCount,
'closedCount' => $closedCount,
'criticalCount' => $criticalCount,
'closedPercent' => $closedPercent,
'openPercent' => $openPercent,
'recentCommits' => GitCommit::query()->with(['repository', 'ticket'])->latest('committed_at')->limit(6)->get(),
'recentTickets' => (clone $base)->with(['status', 'priority'])->latest('last_activity_at')->limit(5)->get(),
]);
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class KanbanBoard extends Component
{
/**
* Move a ticket to another workflow status.
*
* @param int $ticketId
* @param int $statusId
* @param WorkflowNotificationService $workflow
* @return void
*/
public function moveTicket(int $ticketId, int $statusId, WorkflowNotificationService $workflow): void
{
$ticket = Ticket::query()->visibleTo(Auth::user())->findOrFail($ticketId);
$status = Status::query()->findOrFail($statusId);
$oldStatus = $ticket->status?->name;
$ticket->forceFill([
'status_id' => $status->id,
'completed_at' => $status->is_closed ? now() : null,
'last_activity_at' => now(),
])->save();
$workflow->dispatch(
$ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']),
$status->is_closed ? 'ticket_closed' : 'ticket_updated',
Auth::user()
);
session()->flash('success', 'Ticket moved to ' . $status->name . '.');
}
/**
* Render the Kanban board.
*
* @return View
*/
public function render(): View
{
return view('helpdesk::livewire.kanban-board', [
'statuses' => Status::query()->orderBy('sort_order')->get(),
'ticketsByStatus' => $this->ticketsByStatus(),
])->layout('layouts.app');
}
/**
* Get visible tickets grouped by status.
*
* @return array<int, Collection<int, Ticket>>
*/
private function ticketsByStatus(): array
{
return Ticket::query()
->with(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team'])
->visibleTo(Auth::user())
->latest('last_activity_at')
->limit(200)
->get()
->groupBy('status_id')
->all();
}
}
@@ -10,10 +10,12 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Livewire\Component;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\WorkflowTemplate;
final class SettingsManager extends Component
{
@@ -34,6 +36,12 @@ final class SettingsManager extends Component
/** @var array<string, mixed> */
public array $projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
/** @var array<string, mixed> */
public array $repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
/** @var array<string, mixed> */
public array $workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
/** @return void */
public function saveStatus(): void
{
@@ -219,6 +227,86 @@ final class SettingsManager extends Component
$this->projectForm = ['id' => null, 'company_id' => null, 'team_id' => null, 'title' => '', 'slug' => '', 'color' => 'blue', 'description' => '', 'archived_at' => null];
}
/** @return void */
public function saveRepository(): void
{
$data = $this->validate([
'repositoryForm.company_id' => ['nullable', 'integer'],
'repositoryForm.project_id' => ['nullable', 'integer', 'exists:helpdesk_projects,id'],
'repositoryForm.name' => ['required', 'string', 'max:160'],
'repositoryForm.provider' => ['required', 'string', 'max:40'],
'repositoryForm.remote_url' => ['required', 'string', 'max:500'],
'repositoryForm.default_branch' => ['required', 'string', 'max:120'],
'repositoryForm.webhook_secret' => ['nullable', 'string', 'max:120'],
])['repositoryForm'];
GitRepository::query()->updateOrCreate(['id' => $this->repositoryForm['id']], $data);
$this->resetRepositoryForm();
session()->flash('success', 'Git repository saved.');
}
/** @return void */
public function editRepository(int $id): void
{
$this->repositoryForm = GitRepository::query()->findOrFail($id)->only(['id', 'company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret']);
$this->section = 'git';
}
/** @return void */
public function deleteRepository(int $id): void
{
GitRepository::query()->findOrFail($id)->delete();
session()->flash('success', 'Git repository deleted.');
}
/** @return void */
public function resetRepositoryForm(): void
{
$this->repositoryForm = ['id' => null, 'company_id' => null, 'project_id' => null, 'name' => '', 'provider' => 'git', 'remote_url' => '', 'default_branch' => 'main', 'webhook_secret' => ''];
}
/** @return void */
public function saveWorkflowTemplate(): void
{
$data = $this->validate([
'workflowForm.name' => ['required', 'string', 'max:160'],
'workflowForm.event' => ['required', 'string', 'max:80'],
'workflowForm.subject' => ['required', 'string', 'max:220'],
'workflowForm.html_body' => ['required', 'string'],
'workflowForm.text_body' => ['nullable', 'string'],
'workflowForm.to_role' => ['required', 'string', 'max:40'],
'workflowForm.cc' => ['nullable', 'string', 'max:500'],
'workflowForm.bcc' => ['nullable', 'string', 'max:500'],
'workflowForm.is_active' => ['boolean'],
])['workflowForm'];
WorkflowTemplate::query()->updateOrCreate(['id' => $this->workflowForm['id']], $data);
$this->resetWorkflowTemplateForm();
session()->flash('success', 'Workflow message saved.');
}
/** @return void */
public function editWorkflowTemplate(int $id): void
{
$this->workflowForm = WorkflowTemplate::query()->findOrFail($id)->only(['id', 'name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active']);
$this->section = 'workflow';
}
/** @return void */
public function deleteWorkflowTemplate(int $id): void
{
WorkflowTemplate::query()->findOrFail($id)->delete();
session()->flash('success', 'Workflow message deleted.');
}
/** @return void */
public function resetWorkflowTemplateForm(): void
{
$this->workflowForm = ['id' => null, 'name' => '', 'event' => 'ticket.created', 'subject' => '', 'html_body' => '', 'text_body' => '', 'to_role' => 'requester', 'cc' => '', 'bcc' => '', 'is_active' => true];
}
/** @return View */
public function render(): View
{
@@ -229,6 +317,8 @@ final class SettingsManager extends Component
'categories' => Category::query()->withCount('tickets')->orderBy('name')->get(),
'teams' => Team::query()->withCount(['projects', 'tickets'])->orderBy('name')->get(),
'projects' => Project::query()->with(['team'])->withCount('tickets')->orderBy('title')->get(),
'repositories' => GitRepository::query()->with(['project'])->withCount('commits')->orderBy('name')->get(),
'workflowTemplates' => WorkflowTemplate::query()->orderBy('event')->orderBy('name')->get(),
])->layout('layouts.app');
}
+1 -1
View File
@@ -75,7 +75,7 @@ final class TicketCreate extends Component
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.');
return $this->redirectRoute('helpdesk.tickets.show', $ticket, navigate: true);
return $this->redirectRoute('helpdesk.tickets.show', ['ticket' => $ticket->getKey()]);
}
/** @return View */
+75 -21
View File
@@ -10,9 +10,11 @@ use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Services\TicketService;
use Modules\Helpdesk\Services\WorkflowNotificationService;
final class TicketShow extends Component
{
@@ -30,25 +32,36 @@ final class TicketShow extends Component
public ?int $status_id = null;
/**
* Mount the component.
*
* @param Ticket $ticket
* @return void
*/
public ?int $priority_id = null;
public ?int $reply_parent_id = null;
public string $replyBody = '';
/** @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->ticket = $this->freshTicket($ticket);
$this->status_id = $this->ticket->status_id;
$this->priority_id = $this->ticket->priority_id;
}
/**
* Add a comment to the ticket.
*
* @param TicketService $tickets
* @return void
*/
/** @return void */
public function startReply(int $commentId): void
{
$this->reply_parent_id = $commentId;
$this->replyBody = '';
}
/** @return void */
public function cancelReply(): void
{
$this->reply_parent_id = null;
$this->replyBody = '';
}
/** @return void */
public function addComment(TicketService $tickets): void
{
$this->authorize('comment', $this->ticket);
@@ -61,16 +74,29 @@ final class TicketShow extends Component
$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->ticket = $this->freshTicket($this->ticket);
$this->dispatch('helpdesk-comment-added');
}
/**
* Update status using Livewire AJAX.
*
* @return void
*/
public function updateStatus(): void
/** @return void */
public function addReply(TicketService $tickets): void
{
$this->authorize('comment', $this->ticket);
$this->validate([
'reply_parent_id' => ['required', 'integer', 'exists:helpdesk_comments,id'],
'replyBody' => ['required', 'string', 'min:2'],
]);
$tickets->comment($this->ticket, Auth::user(), $this->replyBody, $this->internal, [], $this->reply_parent_id);
$this->cancelReply();
$this->ticket = $this->freshTicket($this->ticket);
$this->dispatch('helpdesk-reply-added');
}
/** @return void */
public function updateStatus(WorkflowNotificationService $workflow): void
{
$this->authorize('update', $this->ticket);
$this->validate(['status_id' => ['required', 'integer', 'exists:helpdesk_statuses,id']]);
@@ -82,15 +108,43 @@ final class TicketShow extends Component
'last_activity_at' => now(),
])->save();
$this->ticket = $this->ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'comments.user', 'comments.attachments', 'attachments.user']);
$this->ticket = $this->freshTicket($this->ticket);
$workflow->dispatch($this->ticket, $status->is_closed ? 'ticket.closed' : 'ticket.updated', Auth::user());
$this->dispatch('helpdesk-status-updated');
}
/** @return void */
public function updatePriority(WorkflowNotificationService $workflow): void
{
$this->authorize('update', $this->ticket);
$this->validate(['priority_id' => ['required', 'integer', 'exists:helpdesk_priorities,id']]);
$this->ticket->forceFill([
'priority_id' => $this->priority_id,
'last_activity_at' => now(),
])->save();
$this->ticket = $this->freshTicket($this->ticket);
$workflow->dispatch($this->ticket, 'ticket.updated', Auth::user());
$this->dispatch('helpdesk-priority-updated');
}
/** @return View */
public function render(): View
{
return view('helpdesk::livewire.ticket-show', [
'statuses' => Status::query()->orderBy('sort_order')->get(),
'priorities' => Priority::query()->orderBy('sort_order')->get(),
])->layout('layouts.app');
}
/** @return Ticket */
private function freshTicket(Ticket $ticket): Ticket
{
return $ticket->fresh([
'owner', 'agent', 'status', 'priority', 'category', 'project', 'team',
'comments.user', 'comments.attachments', 'comments.replies.user', 'comments.replies.attachments', 'comments.replies.replies.user',
'attachments.user', 'gitCommits.repository',
]) ?? $ticket;
}
}
+13 -1
View File
@@ -17,7 +17,7 @@ final class Comment extends Model
protected $table = 'helpdesk_comments';
/** @var list<string> */
protected $fillable = ['ticket_id', 'user_id', 'body', 'is_internal'];
protected $fillable = ['ticket_id', 'parent_id', 'user_id', 'body', 'is_internal'];
/** @var array<string, string> */
protected $casts = ['is_internal' => 'boolean'];
@@ -28,6 +28,18 @@ final class Comment extends Model
return $this->belongsTo(Ticket::class, 'ticket_id');
}
/** @return BelongsTo<Comment, Comment> */
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
/** @return HasMany<Comment> */
public function replies(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->oldest();
}
/** @return BelongsTo<User, Comment> */
public function user(): BelongsTo
{
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class GitCommit extends Model
{
use HasFactory;
protected $table = 'helpdesk_git_commits';
/** @var list<string> */
protected $fillable = [
'repository_id', 'ticket_id', 'hash', 'short_hash', 'branch', 'author_name', 'author_email', 'message', 'url', 'committed_at',
];
/** @var array<string, string> */
protected $casts = ['repository_id' => 'integer', 'ticket_id' => 'integer', 'committed_at' => 'datetime'];
/** @return BelongsTo<GitRepository, GitCommit> */
public function repository(): BelongsTo
{
return $this->belongsTo(GitRepository::class, 'repository_id');
}
/** @return BelongsTo<Ticket, GitCommit> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}
+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\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class GitRepository extends Model
{
use HasFactory;
protected $table = 'helpdesk_git_repositories';
/** @var list<string> */
protected $fillable = ['company_id', 'project_id', 'name', 'provider', 'remote_url', 'default_branch', 'webhook_secret', 'is_active'];
/** @var array<string, string> */
protected $casts = ['company_id' => 'integer', 'project_id' => 'integer', 'is_active' => 'boolean'];
/** @return BelongsTo<Project, GitRepository> */
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'project_id');
}
/** @return HasMany<GitCommit> */
public function commits(): HasMany
{
return $this->hasMany(GitCommit::class, 'repository_id')->latest('committed_at');
}
}
+7 -1
View File
@@ -86,7 +86,13 @@ final class Ticket extends Model
/** @return HasMany<Comment> */
public function comments(): HasMany
{
return $this->hasMany(Comment::class, 'ticket_id')->latest();
return $this->hasMany(Comment::class, 'ticket_id')->whereNull('parent_id')->with(['user', 'attachments', 'replies.user', 'replies.attachments', 'replies.replies.user'])->oldest();
}
/** @return HasMany<GitCommit> */
public function gitCommits(): HasMany
{
return $this->hasMany(GitCommit::class, 'ticket_id')->latest('committed_at');
}
/** @return HasMany<Attachment> */
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
final class WorkflowLog extends Model
{
use HasFactory;
protected $table = 'helpdesk_workflow_logs';
/** @var list<string> */
protected $fillable = ['template_id', 'ticket_id', 'event', 'recipient', 'subject', 'status', 'error'];
/** @return BelongsTo<WorkflowTemplate, WorkflowLog> */
public function template(): BelongsTo
{
return $this->belongsTo(WorkflowTemplate::class, 'template_id');
}
/** @return BelongsTo<Ticket, WorkflowLog> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class WorkflowTemplate extends Model
{
use HasFactory;
protected $table = 'helpdesk_workflow_templates';
/** @var list<string> */
protected $fillable = ['name', 'event', 'subject', 'html_body', 'text_body', 'to_role', 'cc', 'bcc', 'is_active'];
/** @var array<string, string> */
protected $casts = ['is_active' => 'boolean'];
/** @return HasMany<WorkflowLog> */
public function logs(): HasMany
{
return $this->hasMany(WorkflowLog::class, 'template_id');
}
}
@@ -0,0 +1,63 @@
@props([
'label' => 'Details',
'rows' => 10,
'placeholder' => 'Write your message...',
])
@php($model = $attributes->wire('model')->value())
<div
{{ $attributes->except(['wire:model'])->merge(['class' => 'space-y-2']) }}
x-data="{
value: @entangle($model),
command(action, value = null) {
document.execCommand(action, false, value);
this.sync();
},
sync() {
this.value = this.$refs.editor.innerHTML;
},
init() {
this.$refs.editor.innerHTML = this.value || '';
this.$watch('value', (nextValue) => {
if (document.activeElement !== this.$refs.editor && this.$refs.editor.innerHTML !== (nextValue || '')) {
this.$refs.editor.innerHTML = nextValue || '';
}
});
}
}"
>
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $label }}</label>
<input type="hidden" x-model="value" />
<div class="overflow-hidden rounded-xl border border-zinc-300 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-950">
<div class="flex flex-wrap gap-1 border-b border-zinc-200 bg-zinc-50 p-2 dark:border-zinc-800 dark:bg-zinc-900">
<button type="button" x-on:click="command('bold')" class="rounded-lg px-2 py-1 text-sm font-bold hover:bg-zinc-200 dark:hover:bg-zinc-800">B</button>
<button type="button" x-on:click="command('italic')" class="rounded-lg px-2 py-1 text-sm italic hover:bg-zinc-200 dark:hover:bg-zinc-800">I</button>
<button type="button" x-on:click="command('underline')" class="rounded-lg px-2 py-1 text-sm underline hover:bg-zinc-200 dark:hover:bg-zinc-800">U</button>
<button type="button" x-on:click="command('insertUnorderedList')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">• List</button>
<button type="button" x-on:click="command('insertOrderedList')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">1. List</button>
<button type="button" x-on:click="command('formatBlock', 'blockquote')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Quote</button>
<button type="button" x-on:click="const url = prompt('URL'); if (url) command('createLink', url)" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Link</button>
<button type="button" x-on:click="command('removeFormat')" class="rounded-lg px-2 py-1 text-sm hover:bg-zinc-200 dark:hover:bg-zinc-800">Clear</button>
</div>
<div wire:ignore>
<div
x-ref="editor"
contenteditable="true"
x-on:input.debounce.500ms="sync"
x-on:blur="sync"
x-on:keydown.enter.stop
class="prose prose-sm min-h-[{{ (int) $rows * 28 }}px] max-w-none bg-white p-4 text-sm text-zinc-900 outline-none dark:prose-invert dark:bg-zinc-950 dark:text-zinc-100"
data-placeholder="{{ $placeholder }}"
></div>
</div>
</div>
@error($model)
<div class="text-sm text-red-600">{{ $message }}</div>
@enderror
</div>
@@ -0,0 +1,33 @@
@props(['items'])
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<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">Repository</th>
<th class="px-4 py-3">Project</th>
<th class="px-4 py-3">Provider</th>
<th class="px-4 py-3">Branch</th>
<th class="px-4 py-3">Commits</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($items as $item)
<tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $item->name }}</td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->project?->title ?? 'Global' }}</td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->provider }}</td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $item->default_branch }}</td>
<td class="px-4 py-3"><flux:badge color="zinc">{{ $item->commits_count }}</flux:badge></td>
<td class="px-4 py-3 text-right">
<flux:button size="sm" variant="ghost" wire:click="editRepository({{ $item->id }})">Edit</flux:button>
<flux:button size="sm" variant="danger" wire:click="deleteRepository({{ $item->id }})" wire:confirm="Delete this repository?">Delete</flux:button>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-zinc-500">No repositories yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
@@ -1,22 +1,46 @@
<x-layouts.app :title="__('Helpdesk')">
<x-app-layout :title="__('Helpdesk')">
<div class="space-y-6">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-8 text-white">
<div class="grid gap-6 lg:grid-cols-[1fr_320px] lg:items-center">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Support operations</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Helpdesk</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Manage tickets, priorities, statuses, categories, company teams and company projects from one professional workspace.</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight md:text-4xl">Helpdesk dashboard</h1>
<p class="mt-3 max-w-3xl text-sm leading-6 text-blue-100/90">Track open workload, closed tickets, response performance, project activity, priorities, Git commits and workflow communication from one professional support workspace.</p>
<div class="mt-5 flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus">New ticket</flux:button>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost" icon="ticket">Active tickets</flux:button>
<flux:button :href="route('helpdesk.kanban')" wire:navigate variant="ghost" icon="squares-2x2">Kanban</flux:button>
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost" icon="cog-6-tooth">Settings</flux:button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus">New ticket</flux:button>
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost" icon="cog-6-tooth">Settings</flux:button>
<div class="rounded-2xl border border-white/10 bg-white/10 p-5 shadow-2xl backdrop-blur">
<div class="grid grid-cols-3 gap-2">
@foreach (range(1, 9) as $cell)
<div class="h-14 rounded-xl bg-white/15"></div>
@endforeach
</div>
<div class="mt-4 rounded-xl bg-white/10 p-4">
<div class="h-2 rounded-full bg-blue-300/80"></div>
<div class="mt-3 h-2 w-3/4 rounded-full bg-emerald-300/80"></div>
<div class="mt-3 h-2 w-1/2 rounded-full bg-rose-300/80"></div>
</div>
</div>
</div>
</div>
</div>
<livewire:helpdesk.ticket-index />
<div class="rounded-2xl border border-zinc-200 bg-white p-3 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.index')" wire:navigate variant="primary">Dashboard</flux:button>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost">Tickets</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="ghost">Create</flux:button>
<flux:button :href="route('helpdesk.kanban')" wire:navigate variant="ghost">Kanban</flux:button>
<flux:button :href="route('helpdesk.settings')" wire:navigate variant="ghost">Settings</flux:button>
</div>
</div>
<livewire:helpdesk.dashboard-widget />
</div>
</x-layouts.app>
</x-app-layout>
@@ -1,38 +1,86 @@
<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 class="space-y-6" dusk="helpdesk-dashboard-widget">
<div class="grid gap-4 md:grid-cols-4">
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-sm font-medium text-zinc-500">Total tickets</div>
<div class="mt-3 text-4xl font-semibold text-zinc-950 dark:text-white">{{ $totalCount }}</div>
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-2 rounded-full bg-blue-500" style="width: 100%"></div></div>
</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 class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-sm font-medium text-zinc-500">Open tickets</div>
<div class="mt-3 text-4xl font-semibold text-blue-700 dark:text-blue-300">{{ $openCount }}</div>
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-2 rounded-full bg-blue-500" style="width: {{ $openPercent }}%"></div></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 class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-sm font-medium text-zinc-500">Closed tickets</div>
<div class="mt-3 text-4xl font-semibold text-emerald-700 dark:text-emerald-300">{{ $closedCount }}</div>
<div class="mt-3 h-2 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-2 rounded-full bg-emerald-500" style="width: {{ $closedPercent }}%"></div></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 class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="text-sm font-medium text-zinc-500">High priority</div>
<div class="mt-3 text-4xl font-semibold text-red-700 dark:text-red-300">{{ $criticalCount }}</div>
<div class="mt-3 text-xs text-zinc-500">Open urgent/high/critical workload.</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 class="grid gap-6 lg:grid-cols-3">
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 lg:col-span-2">
<div class="mb-5 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Performance</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Closure rate and remaining open workload.</p>
</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
<flux:badge color="emerald">{{ $closedPercent }}% closed</flux:badge>
</div>
<div class="space-y-5">
<div>
<div class="mb-2 flex justify-between text-sm"><span>Closed</span><span>{{ $closedPercent }}%</span></div>
<div class="h-4 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-4 rounded-full bg-emerald-500" style="width: {{ $closedPercent }}%"></div></div>
</div>
<div>
<div class="mb-2 flex justify-between text-sm"><span>Open</span><span>{{ $openPercent }}%</span></div>
<div class="h-4 rounded-full bg-zinc-100 dark:bg-zinc-800"><div class="h-4 rounded-full bg-blue-500" style="width: {{ $openPercent }}%"></div></div>
</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="mb-5 flex items-center justify-between">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent commits</h2>
<flux:button :href="route('helpdesk.settings')" wire:navigate size="sm" variant="ghost">Git setup</flux:button>
</div>
<div class="space-y-2">
@forelse ($recentCommits as $commit)
<div class="rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
<div class="mt-1 truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $commit->message }}</div>
</div>
@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 commits tracked yet.</div>
@endforelse
</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="mb-5 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-zinc-900 dark:text-white">Recent tickets</h2>
<p class="text-sm text-zinc-500 dark:text-zinc-400">Latest support activity.</p>
</div>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate size="sm" variant="ghost">View all</flux:button>
</div>
<div class="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>
</div>
@@ -0,0 +1,86 @@
<div class="mx-auto max-w-7xl space-y-6" dusk="helpdesk-kanban-board">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk workflow</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Kanban board</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Move tickets through your configured statuses, keep priorities visible and trigger workflow notifications when work changes state.</p>
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate variant="primary" icon="plus">New ticket</flux:button>
<flux:button :href="route('helpdesk.tickets.index')" wire:navigate variant="ghost" icon="list-bullet">List view</flux:button>
</div>
</div>
</div>
</div>
@if (session('success'))
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">
{{ session('success') }}
</div>
@endif
<div class="grid gap-4 overflow-x-auto lg:grid-cols-{{ max(1, min(5, $statuses->count())) }}">
@foreach ($statuses as $status)
@php($tickets = $ticketsByStatus[$status->id] ?? collect())
<section class="min-w-[280px] rounded-2xl border border-zinc-200 bg-zinc-50 shadow-sm dark:border-zinc-700 dark:bg-zinc-950" dusk="kanban-column-{{ $status->slug }}">
<header class="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $status->name }}</h2>
<p class="text-xs text-zinc-500">{{ $tickets->count() }} tickets</p>
</div>
<span class="inline-flex h-3 w-3 rounded-full" style="background-color: {{ $status->color }}"></span>
</header>
<div class="space-y-3 p-3">
@forelse ($tickets as $ticket)
<article class="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900" dusk="kanban-ticket-{{ $ticket->id }}">
<div class="flex items-start justify-between gap-3">
<div>
<a href="{{ route('helpdesk.tickets.show', $ticket) }}" wire:navigate class="text-sm font-semibold text-blue-700 hover:underline dark:text-blue-300">
{{ $ticket->reference }}
</a>
<h3 class="mt-1 line-clamp-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</h3>
</div>
@if ($ticket->priority)
<span class="rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ $ticket->priority->color }}">
{{ $ticket->priority->name }}
</span>
@endif
</div>
<div class="mt-3 space-y-1 text-xs text-zinc-500 dark:text-zinc-400">
<div>Owner: {{ $ticket->owner?->name ?? 'Unknown' }}</div>
@if ($ticket->project)
<div>Project: {{ $ticket->project->title }}</div>
@endif
@if ($ticket->team)
<div>Team: {{ $ticket->team->name }}</div>
@endif
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
@foreach ($statuses as $targetStatus)
@continue($targetStatus->id === $status->id)
<button
type="button"
wire:click="moveTicket({{ $ticket->id }}, {{ $targetStatus->id }})"
class="rounded-lg border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
{{ $targetStatus->name }}
</button>
@endforeach
</div>
</article>
@empty
<div class="rounded-xl border border-dashed border-zinc-300 bg-white p-6 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900">
No tickets in {{ $status->name }}.
</div>
@endforelse
</div>
</section>
@endforeach
</div>
</div>
@@ -5,7 +5,7 @@
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Helpdesk administration</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Support configuration</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Manage the lookup data used by tickets, including statuses, priorities, categories, company teams and company projects.</p>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Manage the lookup data used by tickets, including statuses, priorities, categories, company teams, company projects, Git repositories and workflow messages.</p>
</div>
<div class="flex flex-wrap gap-2">
@@ -20,13 +20,15 @@
<flux:callout variant="success" icon="check-circle">{{ session('success') }}</flux:callout>
@endif
<div class="grid gap-3 md:grid-cols-5">
<div class="grid gap-3 md:grid-cols-7">
@foreach ([
'statuses' => ['label' => 'Statuses', 'count' => $statuses->count()],
'priorities' => ['label' => 'Priorities', 'count' => $priorities->count()],
'categories' => ['label' => 'Categories', 'count' => $categories->count()],
'teams' => ['label' => 'Teams', 'count' => $teams->count()],
'projects' => ['label' => 'Projects', 'count' => $projects->count()],
'git' => ['label' => 'Git', 'count' => $repositories->count()],
'workflow' => ['label' => 'Workflow', 'count' => $workflowTemplates->count()],
] as $key => $item)
<button type="button" wire:click="$set('section', '{{ $key }}')" class="rounded-2xl border px-4 py-3 text-left shadow-sm transition {{ $section === $key ? 'border-blue-500 bg-blue-50 text-blue-900 dark:border-blue-400 dark:bg-blue-950 dark:text-blue-100' : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800' }}">
<div class="text-xs font-semibold uppercase tracking-wide opacity-70">{{ $item['label'] }}</div>
@@ -114,4 +116,107 @@
<x-helpdesk::project-table :items="$projects" />
</x-helpdesk::settings-section>
@endif
@if ($section === 'git')
<x-helpdesk::settings-section title="Git repositories" description="Configure repositories that can be linked to projects and tickets. Commits can then be stored against tickets for traceability.">
<x-slot:form>
<form wire:submit="saveRepository" class="grid gap-4 md:grid-cols-6">
<flux:select wire:model="repositoryForm.company_id" label="Company">
<option value="">Global</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model="repositoryForm.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="repositoryForm.name" label="Name" />
<flux:input wire:model="repositoryForm.provider" label="Provider" placeholder="git, github, gitlab" />
<flux:input wire:model="repositoryForm.default_branch" label="Default branch" />
<flux:input wire:model="repositoryForm.webhook_secret" label="Webhook secret" />
<div class="md:col-span-6">
<flux:input wire:model="repositoryForm.remote_url" label="Remote URL" placeholder="git@github.com:company/repo.git" />
</div>
<div class="md:col-span-6 flex gap-2">
<flux:button type="submit" variant="primary">Save repository</flux:button>
<flux:button type="button" wire:click="resetRepositoryForm" variant="ghost">Reset</flux:button>
</div>
</form>
</x-slot:form>
<x-helpdesk::repository-table :items="$repositories" />
</x-helpdesk::settings-section>
@endif
@if ($section === 'workflow')
<x-helpdesk::settings-section title="Workflow messages" description="Define the professional email messages sent when tickets are raised, updated, replied to and closed.">
<x-slot:form>
<form wire:submit="saveWorkflowTemplate" class="grid gap-4 md:grid-cols-6">
<flux:input wire:model="workflowForm.name" label="Name" />
<flux:select wire:model="workflowForm.event" label="Event">
<option value="ticket.created">Ticket raised</option>
<option value="ticket.updated">Ticket updated</option>
<option value="ticket.commented">Response added</option>
<option value="ticket.reply_created">Reply added</option>
<option value="ticket.closed">Ticket closed</option>
</flux:select>
<flux:select wire:model="workflowForm.to_role" label="Send to">
<option value="requester">Requester</option>
<option value="agent">Assigned agent</option>
<option value="actor">User making change</option>
</flux:select>
<flux:input wire:model="workflowForm.cc" label="CC" placeholder="Optional comma-separated emails" />
<flux:input wire:model="workflowForm.bcc" label="BCC" placeholder="Optional comma-separated emails" />
<label class="flex items-end gap-2 pb-2 text-sm text-zinc-700 dark:text-zinc-200"><input type="checkbox" wire:model="workflowForm.is_active" class="rounded border-zinc-300"> Active</label>
<div class="md:col-span-6">
<flux:input wire:model="workflowForm.subject" label="Subject" placeholder="Ticket @{{ ticket.reference }} has been raised" />
</div>
<div class="md:col-span-6">
<flux:textarea wire:model="workflowForm.html_body" label="HTML email body" rows="8" placeholder="Use tokens like @{{ ticket.reference }}, @{{ ticket.subject }}, @{{ ticket.status }}, @{{ requester.name }}, @{{ ticket.url }}" />
</div>
<div class="md:col-span-6">
<flux:textarea wire:model="workflowForm.text_body" label="Plain text body" rows="4" placeholder="Optional fallback text" />
</div>
<div class="md:col-span-6 flex gap-2">
<flux:button type="submit" variant="primary">Save workflow message</flux:button>
<flux:button type="button" wire:click="resetWorkflowTemplateForm" variant="ghost">Reset</flux:button>
</div>
</form>
</x-slot:form>
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<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">Name</th>
<th class="px-4 py-3">Event</th>
<th class="px-4 py-3">Recipient</th>
<th class="px-4 py-3">Active</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($workflowTemplates as $template)
<tr>
<td class="px-4 py-3 font-medium text-zinc-900 dark:text-zinc-100">{{ $template->name }}</td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $template->event }}</td>
<td class="px-4 py-3 text-zinc-600 dark:text-zinc-300">{{ $template->to_role }}</td>
<td class="px-4 py-3"><flux:badge color="{{ $template->is_active ? 'emerald' : 'zinc' }}">{{ $template->is_active ? 'Active' : 'Inactive' }}</flux:badge></td>
<td class="px-4 py-3 text-right">
<flux:button size="sm" variant="ghost" wire:click="editWorkflowTemplate({{ $template->id }})">Edit</flux:button>
<flux:button size="sm" variant="danger" wire:click="deleteWorkflowTemplate({{ $template->id }})" wire:confirm="Delete this workflow message?">Delete</flux:button>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-8 text-center text-zinc-500">No workflow messages yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
</x-helpdesk::settings-section>
@endif
</div>
@@ -1,70 +1,176 @@
<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>
<div>
@include('helpdesk::partials.page-header', [
'title' => 'Create Ticket',
'subtitle' => 'Raise a new support request with priority, project, category and full details.',
])
<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 />
<form wire:submit.prevent="save" class="space-y-6">
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div class="border-b border-slate-200 px-6 py-4">
<h2 class="text-lg font-semibold text-slate-900">Ticket details</h2>
</div>
<div class="grid gap-6 p-6 lg:grid-cols-2">
<div class="lg:col-span-2">
<label class="block text-sm font-semibold text-slate-700" for="title">
Title
</label>
<input
id="title"
type="text"
wire:model.defer="title"
class="mt-2 block w-full rounded-xl border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
@error('title')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="space-y-6">
<div>
<label class="block text-sm font-semibold text-slate-700" for="priority_id">
Priority
</label>
<flux:select wire:model="company_id" label="Company">
<option value="">Default company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model="team_id" label="Team">
<option value="">No team</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</flux:select>
<flux:select wire:model="priority_id" label="Priority" required>
<select
id="priority_id"
wire:model.defer="priority_id"
class="mt-2 block w-full rounded-xl border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="">Select priority</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
@endforeach
</flux:select>
</select>
<flux:select wire:model="category_id" label="Category">
@error('priority_id')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-semibold text-slate-700" for="category_id">
Category
</label>
<select
id="category_id"
wire:model.defer="category_id"
class="mt-2 block w-full rounded-xl border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="">Select category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</flux:select>
</select>
@error('category_id')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-semibold text-slate-700" for="project_id">
Project
</label>
<select
id="project_id"
wire:model.defer="project_id"
class="mt-2 block w-full rounded-xl border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="">Select project</option>
<flux:select wire:model="project_id" label="Project">
<option value="">No project</option>
@foreach ($projects as $project)
<option value="{{ $project->id }}">{{ $project->title }}</option>
<option value="{{ $project->id }}">{{ $project->name }}</option>
@endforeach
</flux:select>
</select>
<flux:input wire:model="due_at" type="date" label="Due date" />
@error('project_id')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<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>
<label class="block text-sm font-semibold text-slate-700" for="team_id">
Team
</label>
<select
id="team_id"
wire:model.defer="team_id"
class="mt-2 block w-full rounded-xl border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="">Select team</option>
@foreach ($teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</select>
@error('team_id')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="lg:col-span-2">
<label class="block text-sm font-semibold text-slate-700" for="details_editor">
Details
</label>
<div wire:ignore class="mt-2">
<textarea id="details_editor">{{ $details }}</textarea>
</div>
@error('details')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</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 class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
<a
href="{{ route('helpdesk.tickets.index') }}"
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Cancel
</a>
<button
type="submit"
class="rounded-xl bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700"
>
Create Ticket
</button>
</div>
</form>
</div>
</form>
</div>
@push('scripts')
<script>
document.addEventListener('livewire:init', function () {
const textarea = document.getElementById('details_editor');
if (!textarea || textarea.dataset.initialised === '1') {
return;
}
textarea.dataset.initialised = '1';
const editor = new EasyMDE({
element: textarea,
spellChecker: false,
status: false,
forceSync: true
});
editor.codemirror.on('change', function () {
@this.set('details', editor.value(), false);
});
});
</script>
@endpush
@@ -1,13 +1,18 @@
<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 class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Ticket management</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Active tickets</h1>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Search, triage and manage support requests across companies, projects, teams, priorities and statuses.</p>
</div>
<div class="flex flex-wrap gap-2">
<flux:button :href="route('helpdesk.index')" wire:navigate variant="ghost" icon="chart-bar">Dashboard</flux:button>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate icon="plus" variant="primary">New ticket</flux:button>
</div>
</div>
</div>
<flux:button :href="route('helpdesk.tickets.create')" wire:navigate icon="plus" variant="primary">
New ticket
</flux:button>
</div>
@if (session('success'))
@@ -1,24 +1,26 @@
<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 class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="mb-2 flex flex-wrap items-center gap-2">
<span class="font-mono text-xs text-blue-100">{{ $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-3xl font-semibold tracking-tight">{{ $ticket->subject }}</h1>
<p class="mt-2 text-sm text-blue-100/90">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>
<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)) !!}
{!! $ticket->body !!}
</div>
</article>
@@ -39,37 +41,16 @@
<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
@if ($ticket->comments->isNotEmpty())
@include('helpdesk::partials.comment-thread', ['comments' => $ticket->comments, 'level' => 0])
@else
<div class="rounded-2xl border border-dashed border-zinc-300 p-8 text-center text-zinc-500 dark:border-zinc-700">No responses yet.</div>
@endif
</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 />
<x-helpdesk::html-editor dusk="ticket-comment-editor" wire:model="commentBody" label="Comment" rows="5" placeholder="Write a professional response..." />
@can('update', $ticket)
<div class="mt-4">
@@ -84,7 +65,7 @@
</div>
<div class="mt-5 flex justify-end">
<flux:button type="submit" variant="primary" icon="chat-bubble-left-right" wire:loading.attr="disabled">
<flux:button dusk="post-comment" 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>
@@ -98,11 +79,26 @@
<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">Team</dt><dd>{{ $ticket->team?->name ?? '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>
<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">Git commits</h2>
<div class="space-y-2">
@forelse ($ticket->gitCommits as $commit)
<a href="{{ $commit->url ?: '#' }}" target="_blank" class="block rounded-xl border border-zinc-100 p-3 dark:border-zinc-800">
<div class="font-mono text-xs text-zinc-500">{{ $commit->short_hash }} · {{ $commit->repository?->name }}</div>
<div class="mt-1 text-sm font-medium text-zinc-900 dark:text-zinc-100">{{ $commit->message }}</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 commits linked.</div>
@endforelse
</div>
</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>
@@ -111,7 +107,15 @@
<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>
<flux:button dusk="update-status" wire:click="updateStatus" class="mt-4 w-full" variant="primary" icon="check">Update status</flux:button>
<div class="mt-5">
<flux:select wire:model="priority_id" label="Priority">
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
@endforeach
</flux:select>
<flux:button dusk="update-priority" wire:click="updatePriority" class="mt-4 w-full" variant="ghost" icon="flag">Update priority</flux:button>
</div>
</div>
@endcan
</aside>
@@ -0,0 +1,51 @@
@props(['comments', 'level' => 0])
<div class="space-y-3">
@foreach ($comments as $comment)
<div class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900" style="margin-left: {{ min((int) $level, 4) * 1.25 }}rem">
<div class="flex items-start 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?->format('d M Y H:i') }}</div>
</div>
@if ($comment->is_internal)
<flux:badge color="amber">Internal</flux:badge>
@endif
</div>
<div class="prose prose-sm mt-3 max-w-none dark:prose-invert">
{!! $comment->body !!}
</div>
@if ($comment->attachments->isNotEmpty())
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($comment->attachments as $attachment)
<a href="{{ $attachment->url() }}" target="_blank" class="rounded-lg border border-zinc-200 px-3 py-1 text-xs text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800">
{{ $attachment->original_name }}
</a>
@endforeach
</div>
@endif
<div class="mt-3 flex justify-end">
<flux:button size="sm" variant="ghost" wire:click="startReply({{ $comment->id }})">Reply</flux:button>
</div>
@if (($reply_parent_id ?? null) === $comment->id)
<form wire:submit="addReply" class="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40">
<x-helpdesk::html-editor wire:model="replyBody" label="Reply" rows="4" placeholder="Write a reply..." />
<div class="mt-4 flex justify-end gap-2">
<flux:button type="button" wire:click="cancelReply" variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary">Post reply</flux:button>
</div>
</form>
@endif
@if ($comment->replies->isNotEmpty())
<div class="mt-4">
@include('helpdesk::partials.comment-thread', ['comments' => $comment->replies, 'level' => ((int) $level) + 1])
</div>
@endif
</div>
@endforeach
</div>
@@ -0,0 +1,27 @@
<div class="mb-8 overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm">
<div class="bg-gradient-to-r from-slate-950 via-slate-900 to-blue-950 px-6 py-8 text-white">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-[0.3em] text-blue-200">
Helpdesk
</p>
<h1 class="mt-2 text-3xl font-bold tracking-tight">
{{ $title ?? 'Helpdesk' }}
</h1>
@isset($subtitle)
<p class="mt-2 max-w-3xl text-sm text-slate-300">
{{ $subtitle }}
</p>
@endisset
</div>
@isset($actions)
<div class="flex flex-wrap items-center gap-3">
{{ $actions }}
</div>
@endisset
</div>
</div>
</div>
+2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Modules\Helpdesk\Http\Controllers\HelpdeskController;
use Modules\Helpdesk\Livewire\KanbanBoard;
use Modules\Helpdesk\Livewire\SettingsManager;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Livewire\TicketIndex;
@@ -12,6 +13,7 @@ use Modules\Helpdesk\Livewire\TicketShow;
Route::get('/', HelpdeskController::class)->name('index');
Route::get('/settings', SettingsManager::class)->name('settings');
Route::get('/kanban', KanbanBoard::class)->name('kanban');
Route::get('/tickets', TicketIndex::class)->name('tickets.index');
Route::get('/tickets/create', TicketCreate::class)->name('tickets.create');
+13 -5
View File
@@ -16,6 +16,7 @@ final class TicketService
private readonly TicketReferenceService $references,
private readonly AgentAssignmentService $assignment,
private readonly AttachmentService $attachments,
private readonly WorkflowNotificationService $workflow,
) {
}
@@ -51,7 +52,10 @@ final class TicketService
$this->attachments->storeMany($ticket, $files, (int) $user->id);
return $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'attachments']) ?? $ticket;
$ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team', 'attachments']) ?? $ticket;
$this->workflow->dispatch($ticket, 'ticket.created', $user);
return $ticket;
});
}
@@ -65,12 +69,12 @@ final class TicketService
* @param array<int, \Illuminate\Http\UploadedFile> $files
* @return Comment
*/
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = []): Comment
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null): Comment
{
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files): Comment {
return DB::transaction(function () use ($ticket, $user, $body, $internal, $files, $parentId): Comment {
/** @var Comment $comment */
$comment = $ticket->comments()->create([
'company_id' => $data['company_id'] ?? ($user->company_id ?? null),
'parent_id' => $parentId,
'user_id' => $user->id,
'body' => $body,
'is_internal' => $internal,
@@ -78,6 +82,7 @@ final class TicketService
$ticket->forceFill(['last_activity_at' => now()])->save();
$this->attachments->storeMany($ticket, $files, (int) $user->id, $comment);
$this->workflow->dispatch($ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket, $parentId === null ? 'ticket.commented' : 'ticket.reply_created', $user);
return $comment->fresh(['user', 'attachments']) ?? $comment;
});
@@ -100,6 +105,9 @@ final class TicketService
'last_activity_at' => now(),
])->save();
return $ticket->fresh(['status']) ?? $ticket;
$ticket = $ticket->fresh(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']) ?? $ticket;
$this->workflow->dispatch($ticket, 'ticket.closed', $user);
return $ticket;
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Services;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Models\WorkflowLog;
use Modules\Helpdesk\Models\WorkflowTemplate;
use Throwable;
final class WorkflowNotificationService
{
/**
* Send all active workflow messages for the supplied ticket event.
*
* @param Ticket $ticket
* @param string $event
* @param User|null $actor
* @return void
*/
public function dispatch(Ticket $ticket, string $event, ?User $actor = null): void
{
$ticket->loadMissing(['owner', 'agent', 'status', 'priority', 'category', 'project', 'team']);
WorkflowTemplate::query()
->where('event', $event)
->where('is_active', true)
->get()
->each(function (WorkflowTemplate $template) use ($ticket, $event, $actor): void {
$recipient = $this->recipient($template->to_role, $ticket, $actor);
if ($recipient === null || blank($recipient->email ?? null)) {
$this->log($template, $ticket, $event, null, 'skipped', 'No recipient email was available.');
return;
}
$subject = $this->renderText($template->subject, $ticket, $actor);
$html = $this->wrapHtml($this->renderText($template->html_body, $ticket, $actor), $ticket);
$text = $this->renderText($template->text_body ?: strip_tags($template->html_body), $ticket, $actor);
try {
Mail::html($html, function ($message) use ($recipient, $template, $subject): void {
$message->to($recipient->email, $recipient->name ?? null)->subject($subject);
if (filled($template->cc)) {
$message->cc(array_map('trim', explode(',', (string) $template->cc)));
}
if (filled($template->bcc)) {
$message->bcc(array_map('trim', explode(',', (string) $template->bcc)));
}
});
$this->log($template, $ticket, $event, (string) $recipient->email, 'sent', null, $subject);
} catch (Throwable $exception) {
Log::warning('Helpdesk workflow email failed.', ['event' => $event, 'ticket_id' => $ticket->id, 'error' => $exception->getMessage()]);
$this->log($template, $ticket, $event, (string) $recipient->email, 'failed', $exception->getMessage(), $subject);
}
});
}
/** @return User|null */
private function recipient(string $role, Ticket $ticket, ?User $actor): ?User
{
return match ($role) {
'agent' => $ticket->agent,
'actor' => $actor,
default => $ticket->owner,
};
}
private function renderText(string $template, Ticket $ticket, ?User $actor): string
{
$tokens = [
'{{ ticket.reference }}' => $ticket->reference,
'{{ ticket.subject }}' => $ticket->subject,
'{{ ticket.status }}' => (string) ($ticket->status?->name ?? ''),
'{{ ticket.priority }}' => (string) ($ticket->priority?->name ?? ''),
'{{ ticket.category }}' => (string) ($ticket->category?->name ?? ''),
'{{ ticket.project }}' => (string) ($ticket->project?->title ?? ''),
'{{ ticket.team }}' => (string) ($ticket->team?->name ?? ''),
'{{ requester.name }}' => (string) ($ticket->owner?->name ?? ''),
'{{ agent.name }}' => (string) ($ticket->agent?->name ?? ''),
'{{ actor.name }}' => (string) ($actor?->name ?? ''),
'{{ ticket.url }}' => route('helpdesk.tickets.show', ['ticket' => $ticket->getKey()]),
];
return strtr($template, $tokens);
}
private function wrapHtml(string $body, Ticket $ticket): string
{
return '<div style="font-family:Inter,Arial,sans-serif;background:#f6f8fb;padding:24px">'
. '<div style="max-width:720px;margin:0 auto;background:#ffffff;border:1px solid #d8dee8;border-radius:18px;overflow:hidden">'
. '<div style="background:#0f172a;color:#ffffff;padding:24px 28px">'
. '<div style="font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:#bfdbfe">Helpdesk notification</div>'
. '<h1 style="margin:8px 0 0;font-size:22px">' . e($ticket->reference) . '</h1>'
. '<p style="margin:8px 0 0;color:#dbeafe">' . e($ticket->subject) . '</p>'
. '</div><div style="padding:28px;color:#1f2937;line-height:1.65">'
. $body
. '<p style="margin-top:28px"><a href="' . e(route('helpdesk.tickets.show', ['ticket' => $ticket->getKey()])) . '" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;padding:11px 16px;border-radius:10px">Open ticket</a></p>'
. '</div></div></div>';
}
private function log(WorkflowTemplate $template, Ticket $ticket, string $event, ?string $recipient, string $status, ?string $error = null, ?string $subject = null): void
{
WorkflowLog::query()->create([
'template_id' => $template->id,
'ticket_id' => $ticket->id,
'event' => $event,
'recipient' => $recipient,
'subject' => $subject,
'status' => $status,
'error' => $error,
]);
}
}
@@ -1,36 +0,0 @@
<?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,39 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskKanbanBrowserTest extends DuskTestCase
{
use BuildsHelpdeskFixtures;
use DatabaseMigrations;
/** @test */
public function kanban_board_is_available_from_helpdesk_module(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['high']->id,
'subject' => 'Kanban browser ticket',
]);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.kanban')
->assertPresent('@helpdesk-kanban-board')
->assertSee('Kanban board')
->assertSee('Kanban browser ticket')
->assertPresent('@kanban-column-open');
});
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskNavigationBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
use BuildsHelpdeskFixtures;
public function test_helpdesk_dashboard_and_main_pages_render_with_professional_headers(): void
{
$this->seedLookupData();
$user = $this->admin(['name' => 'Mark Gregory']);
$this->browse(function (Browser $browser) use ($user): void {
$browser->loginAs($user)
->visitRoute('helpdesk.index')
->waitFor('@helpdesk-dashboard-widget')
->assertSee('Helpdesk dashboard')
->assertSee('Total tickets')
->assertSee('Open tickets')
->assertSee('Closed tickets')
->clickLink('Tickets')
->waitFor('@helpdesk-ticket-index')
->assertSee('Active tickets')
->clickLink('New ticket')
->waitFor('@helpdesk-ticket-create')
->assertSee('Create ticket')
->visitRoute('helpdesk.settings')
->waitFor('@helpdesk-settings-manager')
->assertSee('Support configuration')
->assertSee('Statuses')
->assertSee('Priorities')
->assertSee('Projects')
->assertSee('Workflow');
});
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskSettingsBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
use BuildsHelpdeskFixtures;
public function test_settings_tabs_expose_lookup_git_and_workflow_setup_areas(): void
{
$this->seedLookupData();
$admin = $this->admin(['name' => 'Settings Admin']);
$this->browse(function (Browser $browser) use ($admin): void {
$browser->loginAs($admin)
->visitRoute('helpdesk.settings')
->waitFor('@helpdesk-settings-manager')
->assertSee('Support configuration')
->assertSee('Statuses')
->assertSee('Priorities')
->assertSee('Categories')
->assertSee('Teams')
->assertSee('Projects')
->assertSee('Git')
->assertSee('Workflow')
->press('Git')
->waitForText('Git repositories')
->assertSee('Remote URL')
->press('Workflow')
->waitForText('Workflow messages')
->assertSee('HTML email body');
});
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\DuskTestCase;
final class HelpdeskTicketBrowserTest extends DuskTestCase
{
use DatabaseMigrations;
use BuildsHelpdeskFixtures;
public function test_user_can_create_and_view_ticket_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Ticket Browser User']);
$this->browse(function (Browser $browser) use ($user, $fixtures): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.create')
->waitFor('@helpdesk-ticket-create')
->type('@ticket-subject', 'Browser created helpdesk ticket')
->script("document.querySelector('[dusk=\"ticket-body-editor\"] [contenteditable=\"true\"]').innerHTML = '<p>This browser ticket proves the form saves correctly.</p>'; document.querySelector('[dusk=\"ticket-body-editor\"] [contenteditable=\"true\"]').dispatchEvent(new Event('input', { bubbles: true }));");
$browser->select('@ticket-priority', (string) $fixtures['high']->id)
->select('@ticket-category', (string) $fixtures['category']->id)
->select('@ticket-team', (string) $fixtures['team']->id)
->select('@ticket-project', (string) $fixtures['project']->id)
->press('@save-ticket')
->waitFor('@helpdesk-ticket-show')
->assertSee('Browser created helpdesk ticket')
->assertSee('High')
->assertSee('Support')
->assertSee('Ticket details');
});
}
public function test_user_can_add_nested_response_in_browser(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Response Browser User']);
$ticket = $this->ticket(['owner' => $user, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Browser response ticket']);
$this->browse(function (Browser $browser) use ($user, $ticket): void {
$browser->loginAs($user)
->visitRoute('helpdesk.tickets.show', $ticket)
->waitFor('@helpdesk-ticket-show')
->script("document.querySelector('[dusk=\"ticket-comment-editor\"] [contenteditable=\"true\"]').innerHTML = '<p>First browser response</p>'; document.querySelector('[dusk=\"ticket-comment-editor\"] [contenteditable=\"true\"]').dispatchEvent(new Event('input', { bubbles: true }));");
$browser->press('@post-comment')
->waitForText('First browser response')
->assertSee('First browser response');
});
}
}
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Concerns;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\Ticket;
trait BuildsHelpdeskFixtures
{
protected function user(array $attributes = []): User
{
/** @var User $user */
$user = User::factory()->create(array_merge([
'name' => 'Helpdesk User',
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
], $attributes));
return $user;
}
protected function agent(array $attributes = []): User
{
$user = $this->user($attributes);
$user->forceFill(['helpdesk_agent' => true])->save();
return $user;
}
protected function admin(array $attributes = []): User
{
$user = $this->user($attributes);
$user->forceFill(['helpdesk_agent' => true, 'helpdesk_admin' => true])->save();
return $user;
}
protected function status(array $attributes = []): Status
{
return Status::query()->create(array_merge([
'name' => 'Open',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'sort_order' => 10,
'is_closed' => false,
], $attributes));
}
protected function closedStatus(array $attributes = []): Status
{
return $this->status(array_merge([
'name' => 'Closed',
'slug' => fake()->unique()->slug(2),
'color' => 'emerald',
'sort_order' => 99,
'is_closed' => true,
], $attributes));
}
protected function priority(array $attributes = []): Priority
{
return Priority::query()->create(array_merge([
'name' => 'Normal',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'sort_order' => 10,
], $attributes));
}
protected function category(array $attributes = []): Category
{
return Category::query()->create(array_merge([
'company_id' => null,
'name' => 'Support',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'description' => 'Support requests',
], $attributes));
}
protected function team(array $attributes = []): Team
{
return Team::query()->create(array_merge([
'company_id' => null,
'name' => 'Support Team',
'slug' => fake()->unique()->slug(2),
'color' => 'indigo',
'description' => 'Primary support team',
], $attributes));
}
protected function project(array $attributes = []): Project
{
return Project::query()->create(array_merge([
'company_id' => null,
'team_id' => null,
'title' => 'WMS Project',
'slug' => fake()->unique()->slug(2),
'color' => 'blue',
'description' => 'Warehouse platform project',
], $attributes));
}
protected function repository(array $attributes = []): GitRepository
{
return GitRepository::query()->create(array_merge([
'company_id' => null,
'project_id' => null,
'name' => 'Core',
'provider' => 'git',
'remote_url' => 'git@example.com:wms/core.git',
'default_branch' => 'develop',
'webhook_secret' => 'test-secret',
'is_active' => true,
], $attributes));
}
protected function ticket(array $attributes = []): Ticket
{
$owner = $attributes['owner'] ?? $this->user(['name' => 'Requester']);
$agent = $attributes['agent'] ?? null;
unset($attributes['owner'], $attributes['agent']);
return Ticket::query()->create(array_merge([
'reference' => 'HD-' . now()->format('Y') . '-' . str_pad((string) random_int(1, 99999), 5, '0', STR_PAD_LEFT),
'user_id' => $owner->id,
'agent_id' => $agent?->id,
'status_id' => $this->status()->id,
'priority_id' => $this->priority()->id,
'category_id' => null,
'project_id' => null,
'team_id' => null,
'subject' => 'Test ticket subject',
'body' => '<p>Detailed helpdesk ticket body.</p>',
'last_activity_at' => now(),
], $attributes));
}
protected function seedLookupData(): array
{
$open = $this->status(['name' => 'Open', 'slug' => 'open']);
$closed = $this->closedStatus(['name' => 'Closed', 'slug' => 'closed']);
$normal = $this->priority(['name' => 'Normal', 'slug' => 'normal']);
$high = $this->priority(['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 1]);
$category = $this->category(['name' => 'Support', 'slug' => 'support']);
$team = $this->team(['name' => 'Support Team', 'slug' => 'support-team']);
$project = $this->project(['title' => 'WMS Core', 'slug' => 'wms-core', 'team_id' => $team->id]);
return compact('open', 'closed', 'normal', 'high', 'category', 'team', 'project');
}
protected function ensureCompanyTableHasOne(): ?int
{
if (! Schema::hasTable('companies')) {
return null;
}
$columns = Schema::getColumnListing('companies');
$payload = [];
foreach ($columns as $column) {
if (in_array($column, ['id', 'created_at', 'updated_at', 'deleted_at'], true)) {
continue;
}
if ($column === 'name') {
$payload[$column] = 'MPBA Test Company';
}
}
if (! array_key_exists('name', $payload) && in_array('company_name', $columns, true)) {
$payload['company_name'] = 'MPBA Test Company';
}
return (int) DB::table('companies')->insertGetId($payload);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Services\AgentAssignmentService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class AgentAssignmentTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_assignment_selects_least_loaded_agent(): void
{
$fixtures = $this->seedLookupData();
$busy = $this->agent(['name' => 'Busy Agent']);
$quiet = $this->agent(['name' => 'Quiet Agent']);
$this->ticket(['agent' => $busy, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$this->ticket(['agent' => $busy, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$selected = app(AgentAssignmentService::class)->selectAgentId(null);
self::assertSame($quiet->id, $selected);
}
public function test_assignment_prefers_category_agents_when_category_has_agents(): void
{
$fixtures = $this->seedLookupData();
$generalAgent = $this->agent(['name' => 'General Agent']);
$categoryAgent = $this->agent(['name' => 'Category Agent']);
$fixtures['category']->agents()->attach($categoryAgent->id);
$this->ticket(['agent' => $generalAgent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$selected = app(AgentAssignmentService::class)->selectAgentId($fixtures['category']->id);
self::assertSame($categoryAgent->id, $selected);
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Modules\Helpdesk\Models\Attachment;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Services\AttachmentService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class GitAndAttachmentTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_git_commit_can_be_tracked_against_repository_and_ticket(): void
{
$fixtures = $this->seedLookupData();
$repository = $this->repository(['project_id' => $fixtures['project']->id]);
$ticket = $this->ticket(['project_id' => $fixtures['project']->id]);
GitCommit::query()->create([
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('b', 40),
'short_hash' => 'bbbbbbb',
'branch' => 'develop',
'author_name' => 'Mark Gregory',
'author_email' => 'mark@example.com',
'message' => 'Resolve ' . $ticket->reference . ' ticket save redirect',
'url' => 'https://example.com/commit/b',
'committed_at' => now(),
]);
$this->assertDatabaseHas('helpdesk_git_commits', [
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'short_hash' => 'bbbbbbb',
]);
self::assertSame($ticket->id, $repository->commits()->firstOrFail()->ticket_id);
}
public function test_attachment_service_stores_and_deletes_ticket_files(): void
{
Storage::fake('local');
config()->set('helpdesk.disk', 'local');
$user = $this->user();
$ticket = $this->ticket(['owner' => $user]);
$file = UploadedFile::fake()->create('evidence.pdf', 32, 'application/pdf');
app(AttachmentService::class)->storeMany($ticket, [$file], $user->id);
$attachment = Attachment::query()->where('ticket_id', $ticket->id)->firstOrFail();
Storage::disk('local')->assertExists($attachment->path);
self::assertSame('evidence.pdf', $attachment->original_name);
app(AttachmentService::class)->delete($attachment);
Storage::disk('local')->assertMissing($attachment->path);
$this->assertDatabaseMissing('helpdesk_attachments', ['id' => $attachment->id]);
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class HelpdeskRouteTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_authenticated_user_can_open_dashboard_tickets_create_and_settings_pages(): void
{
$this->seedLookupData();
$user = $this->admin();
$this->actingAs($user)->get(route('helpdesk.index'))->assertOk()->assertSee('Helpdesk', false);
$this->actingAs($user)->get(route('helpdesk.tickets.index'))->assertOk()->assertSee('Active tickets', false);
$this->actingAs($user)->get(route('helpdesk.tickets.create'))->assertOk()->assertSee('Create ticket', false);
$this->actingAs($user)->get(route('helpdesk.settings'))->assertOk()->assertSee('Support configuration', false);
}
public function test_guest_is_redirected_from_helpdesk_pages_when_application_uses_auth_middleware(): void
{
$response = $this->get(route('helpdesk.tickets.index'));
self::assertTrue(in_array($response->getStatusCode(), [200, 302], true));
}
}
@@ -1,40 +0,0 @@
<?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,
]);
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\KanbanBoard;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class KanbanBoardTest extends TestCase
{
use BuildsHelpdeskFixtures;
use RefreshDatabase;
/** @test */
public function kanban_board_renders_visible_tickets_by_status(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['high']->id,
'subject' => 'Kanban visible ticket',
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->assertSee('Kanban board')
->assertSee($ticket->reference)
->assertSee('Kanban visible ticket')
->assertSee('High');
}
/** @test */
public function kanban_board_can_move_ticket_to_closed_status(): void
{
$lookups = $this->seedLookupData();
$user = $this->admin();
$ticket = $this->ticket([
'owner' => $user,
'status_id' => $lookups['open']->id,
'priority_id' => $lookups['normal']->id,
]);
$this->actingAs($user);
Livewire::test(KanbanBoard::class)
->call('moveTicket', $ticket->id, $lookups['closed']->id)
->assertHasNoErrors();
$ticket->refresh();
$this->assertSame($lookups['closed']->id, $ticket->status_id);
$this->assertNotNull($ticket->completed_at);
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\SettingsManager;
use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\GitRepository;
use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Status;
use Modules\Helpdesk\Models\Team;
use Modules\Helpdesk\Models\WorkflowTemplate;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class SettingsCrudTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_settings_manager_creates_updates_and_deletes_statuses(): void
{
$admin = $this->admin();
$component = Livewire::actingAs($admin)
->test(SettingsManager::class)
->set('statusForm.name', 'Awaiting Customer')
->set('statusForm.color', 'amber')
->set('statusForm.sort_order', 20)
->set('statusForm.is_closed', false)
->call('saveStatus')
->assertHasNoErrors();
$status = Status::query()->where('slug', 'awaiting-customer')->firstOrFail();
$component->call('editStatus', $status->id)
->set('statusForm.name', 'Waiting Customer')
->call('saveStatus')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_statuses', ['id' => $status->id, 'name' => 'Waiting Customer']);
$component->call('deleteStatus', $status->id);
$this->assertDatabaseMissing('helpdesk_statuses', ['id' => $status->id]);
}
public function test_settings_manager_creates_lookup_project_team_repository_and_workflow_records(): void
{
$admin = $this->admin();
Livewire::actingAs($admin)
->test(SettingsManager::class)
->set('priorityForm.name', 'Critical')
->set('priorityForm.color', 'red')
->set('priorityForm.sort_order', 1)
->call('savePriority')
->set('categoryForm.name', 'Bug Fix')
->set('categoryForm.color', 'rose')
->set('categoryForm.description', 'Production defects')
->call('saveCategory')
->set('teamForm.name', 'Platform Support')
->set('teamForm.color', 'indigo')
->set('teamForm.description', 'Platform team')
->call('saveTeam')
->set('projectForm.title', 'Core WMS')
->set('projectForm.team_id', Team::query()->where('slug', 'platform-support')->firstOrFail()->id)
->set('projectForm.color', 'blue')
->set('projectForm.description', 'Core platform')
->call('saveProject')
->set('repositoryForm.name', 'WMS Core')
->set('repositoryForm.project_id', Project::query()->where('slug', 'core-wms')->firstOrFail()->id)
->set('repositoryForm.provider', 'github')
->set('repositoryForm.remote_url', 'git@github.com:mpba/wms-core.git')
->set('repositoryForm.default_branch', 'develop')
->set('repositoryForm.webhook_secret', 'secret')
->call('saveRepository')
->set('workflowForm.name', 'Ticket Raised')
->set('workflowForm.event', 'ticket.created')
->set('workflowForm.subject', 'Ticket {{ ticket.reference }} created')
->set('workflowForm.html_body', '<p>Hello {{ requester.name }}</p>')
->set('workflowForm.text_body', 'Hello {{ requester.name }}')
->set('workflowForm.to_role', 'requester')
->call('saveWorkflowTemplate')
->assertHasNoErrors();
$this->assertDatabaseHas('helpdesk_priorities', ['slug' => 'critical']);
$this->assertDatabaseHas('helpdesk_categories', ['slug' => 'bug-fix']);
$this->assertDatabaseHas('helpdesk_teams', ['slug' => 'platform-support']);
$this->assertDatabaseHas('helpdesk_projects', ['slug' => 'core-wms']);
$this->assertDatabaseHas('helpdesk_git_repositories', ['name' => 'WMS Core', 'provider' => 'github']);
$this->assertDatabaseHas('helpdesk_workflow_templates', ['name' => 'Ticket Raised', 'event' => 'ticket.created']);
}
public function test_settings_manager_can_edit_and_delete_all_new_setup_records(): void
{
$admin = $this->admin();
$priority = $this->priority(['name' => 'Low', 'slug' => 'low']);
$category = $this->category(['name' => 'Support', 'slug' => 'support']);
$team = $this->team(['name' => 'First Line', 'slug' => 'first-line']);
$project = $this->project(['title' => 'Portal', 'slug' => 'portal', 'team_id' => $team->id]);
$repository = $this->repository(['project_id' => $project->id, 'name' => 'Portal Repo']);
$template = WorkflowTemplate::query()->create([
'name' => 'Closed Message',
'event' => 'ticket.closed',
'subject' => 'Closed {{ ticket.reference }}',
'html_body' => '<p>Closed</p>',
'text_body' => 'Closed',
'to_role' => 'requester',
'is_active' => true,
]);
$component = Livewire::actingAs($admin)->test(SettingsManager::class);
$component->call('editPriority', $priority->id)->set('priorityForm.name', 'Low Priority')->call('savePriority');
$component->call('editCategory', $category->id)->set('categoryForm.name', 'Customer Support')->call('saveCategory');
$component->call('editTeam', $team->id)->set('teamForm.name', 'First Line Support')->call('saveTeam');
$component->call('editProject', $project->id)->set('projectForm.title', 'Customer Portal')->call('saveProject');
$component->call('editRepository', $repository->id)->set('repositoryForm.name', 'Customer Portal Repo')->call('saveRepository');
$component->call('editWorkflowTemplate', $template->id)->set('workflowForm.name', 'Ticket Closed Message')->call('saveWorkflowTemplate');
$this->assertDatabaseHas('helpdesk_priorities', ['id' => $priority->id, 'name' => 'Low Priority']);
$this->assertDatabaseHas('helpdesk_categories', ['id' => $category->id, 'name' => 'Customer Support']);
$this->assertDatabaseHas('helpdesk_teams', ['id' => $team->id, 'name' => 'First Line Support']);
$this->assertDatabaseHas('helpdesk_projects', ['id' => $project->id, 'title' => 'Customer Portal']);
$this->assertDatabaseHas('helpdesk_git_repositories', ['id' => $repository->id, 'name' => 'Customer Portal Repo']);
$this->assertDatabaseHas('helpdesk_workflow_templates', ['id' => $template->id, 'name' => 'Ticket Closed Message']);
$component->call('deletePriority', $priority->id)
->call('deleteCategory', $category->id)
->call('deleteRepository', $repository->id)
->call('deleteWorkflowTemplate', $template->id);
self::assertNull(Priority::query()->find($priority->id));
self::assertNull(Category::query()->find($category->id));
self::assertNull(GitRepository::query()->find($repository->id));
self::assertNull(WorkflowTemplate::query()->find($template->id));
}
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketShow;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketConversationTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_owner_can_add_public_response_to_ticket(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
Livewire::actingAs($owner)
->test(TicketShow::class, ['ticket' => $ticket])
->set('commentBody', '<p>Please can you check this update?</p>')
->call('addComment')
->assertHasNoErrors()
->assertDispatched('helpdesk-comment-added');
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => null,
'user_id' => $owner->id,
'is_internal' => false,
]);
}
public function test_comment_can_have_a_nested_reply(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
$comment = Comment::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $owner->id,
'body' => '<p>Parent response</p>',
'is_internal' => false,
]);
Livewire::actingAs($owner)
->test(TicketShow::class, ['ticket' => $ticket])
->call('startReply', $comment->id)
->set('replyBody', '<p>Nested reply body</p>')
->call('addReply')
->assertHasNoErrors()
->assertDispatched('helpdesk-reply-added');
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => $comment->id,
'body' => '<p>Nested reply body</p>',
]);
}
public function test_agent_can_update_status_to_closed_and_priority(): void
{
Mail::fake();
$fixtures = $this->seedLookupData();
$owner = $this->user();
$agent = $this->agent();
$ticket = $this->ticket(['owner' => $owner, 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
Livewire::actingAs($agent)
->test(TicketShow::class, ['ticket' => $ticket])
->set('status_id', $fixtures['closed']->id)
->call('updateStatus')
->assertHasNoErrors()
->assertDispatched('helpdesk-status-updated')
->set('priority_id', $fixtures['high']->id)
->call('updatePriority')
->assertHasNoErrors()
->assertDispatched('helpdesk-priority-updated');
$ticket->refresh();
self::assertNotNull($ticket->completed_at);
self::assertSame($fixtures['closed']->id, $ticket->status_id);
self::assertSame($fixtures['high']->id, $ticket->priority_id);
}
public function test_unassigned_agent_cannot_update_another_users_ticket(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user();
$agent = $this->agent();
$ticket = $this->ticket(['owner' => $owner, 'agent_id' => null, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id]);
Livewire::actingAs($agent)
->test(TicketShow::class, ['ticket' => $ticket])
->assertForbidden();
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Modules\Helpdesk\Livewire\TicketCreate;
use Modules\Helpdesk\Livewire\TicketIndex;
use Modules\Helpdesk\Models\Ticket;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketCrudTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_user_can_create_ticket_with_priority_category_team_and_project(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user(['name' => 'Mark Requester']);
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Warehouse printer does not print labels')
->set('body', '<p>The printer fails every time a dispatch label is generated.</p>')
->set('priority_id', $fixtures['high']->id)
->set('category_id', $fixtures['category']->id)
->set('team_id', $fixtures['team']->id)
->set('project_id', $fixtures['project']->id)
->call('save')
->assertHasNoErrors()
->assertRedirect();
$this->assertDatabaseHas('helpdesk_tickets', [
'user_id' => $user->id,
'subject' => 'Warehouse printer does not print labels',
'priority_id' => $fixtures['high']->id,
'category_id' => $fixtures['category']->id,
'team_id' => $fixtures['team']->id,
'project_id' => $fixtures['project']->id,
]);
}
public function test_ticket_create_validates_required_fields(): void
{
$this->seedLookupData();
$user = $this->user();
Livewire::actingAs($user)
->test(TicketCreate::class)
->call('save')
->assertHasErrors(['subject' => 'required', 'body' => 'required', 'priority_id' => 'required']);
}
public function test_ticket_index_shows_only_visible_tickets_for_standard_user(): void
{
$fixtures = $this->seedLookupData();
$owner = $this->user(['name' => 'Owner User']);
$other = $this->user(['name' => 'Other User']);
$visible = $this->ticket(['owner' => $owner, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Visible ticket']);
$this->ticket(['owner' => $other, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Hidden ticket']);
Livewire::actingAs($owner)
->test(TicketIndex::class)
->assertSee($visible->reference)
->assertSee('Visible ticket')
->assertDontSee('Hidden ticket');
}
public function test_agent_sees_assigned_ticket_and_admin_sees_all_tickets(): void
{
$fixtures = $this->seedLookupData();
$agent = $this->agent(['name' => 'Assigned Agent']);
$admin = $this->admin(['name' => 'Admin User']);
$owner = $this->user();
$assigned = $this->ticket(['owner' => $owner, 'agent' => $agent, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Assigned ticket']);
$unassigned = $this->ticket(['owner' => $this->user(), 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['normal']->id, 'subject' => 'Another ticket']);
Livewire::actingAs($agent)
->test(TicketIndex::class)
->assertSee($assigned->reference)
->assertDontSee($unassigned->reference);
Livewire::actingAs($admin)
->test(TicketIndex::class)
->assertSee($assigned->reference)
->assertSee($unassigned->reference);
}
public function test_index_filters_by_search_priority_and_closed_scope(): void
{
$fixtures = $this->seedLookupData();
$admin = $this->admin();
$openTicket = $this->ticket(['owner' => $admin, 'status_id' => $fixtures['open']->id, 'priority_id' => $fixtures['high']->id, 'subject' => 'API sync failure']);
$closedTicket = $this->ticket(['owner' => $admin, 'status_id' => $fixtures['closed']->id, 'priority_id' => $fixtures['normal']->id, 'completed_at' => now(), 'subject' => 'Old closed issue']);
Livewire::actingAs($admin)
->test(TicketIndex::class)
->set('search', 'API sync')
->set('priority', (string) $fixtures['high']->id)
->assertSee($openTicket->reference)
->assertDontSee($closedTicket->reference);
Livewire::actingAs($admin)
->test(TicketIndex::class)
->set('scope', 'closed')
->assertSee($closedTicket->reference)
->assertDontSee($openTicket->reference);
}
public function test_created_ticket_has_a_reference_and_last_activity_date(): void
{
$fixtures = $this->seedLookupData();
$user = $this->user();
Livewire::actingAs($user)
->test(TicketCreate::class)
->set('subject', 'Reference check')
->set('body', 'This ticket should have a generated reference.')
->set('priority_id', $fixtures['normal']->id)
->call('save');
$ticket = Ticket::query()->where('subject', 'Reference check')->firstOrFail();
self::assertStringStartsWith('HD-' . now()->format('Y') . '-', $ticket->reference);
self::assertNotNull($ticket->last_activity_at);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketPolicyTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_owner_can_view_and_comment_but_cannot_update_ticket(): void
{
$owner = $this->user();
$ticket = $this->ticket(['owner' => $owner]);
self::assertTrue(Gate::forUser($owner)->allows('view', $ticket));
self::assertTrue(Gate::forUser($owner)->allows('comment', $ticket));
self::assertFalse(Gate::forUser($owner)->allows('update', $ticket));
}
public function test_assigned_agent_can_view_comment_and_update_ticket(): void
{
$owner = $this->user();
$agent = $this->agent();
$ticket = $this->ticket(['owner' => $owner, 'agent' => $agent]);
self::assertTrue(Gate::forUser($agent)->allows('view', $ticket));
self::assertTrue(Gate::forUser($agent)->allows('comment', $ticket));
self::assertTrue(Gate::forUser($agent)->allows('update', $ticket));
}
public function test_helpdesk_admin_can_view_update_and_delete_any_ticket(): void
{
$admin = $this->admin();
$ticket = $this->ticket(['owner' => $this->user()]);
self::assertTrue(Gate::forUser($admin)->allows('view', $ticket));
self::assertTrue(Gate::forUser($admin)->allows('update', $ticket));
self::assertTrue(Gate::forUser($admin)->allows('delete', $ticket));
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Modules\Helpdesk\Models\WorkflowTemplate;
use Modules\Helpdesk\Services\WorkflowNotificationService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class WorkflowNotificationTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_workflow_dispatch_sends_active_template_and_logs_it(): void
{
Mail::fake();
$fixtures = $this->seedLookupData();
$owner = $this->user(['name' => 'Requester Name', 'email' => 'requester@example.com']);
$agent = $this->agent(['name' => 'Agent Name', 'email' => 'agent@example.com']);
$ticket = $this->ticket([
'owner' => $owner,
'agent' => $agent,
'status_id' => $fixtures['open']->id,
'priority_id' => $fixtures['high']->id,
'category_id' => $fixtures['category']->id,
'project_id' => $fixtures['project']->id,
'team_id' => $fixtures['team']->id,
'subject' => 'Workflow ticket subject',
]);
WorkflowTemplate::query()->create([
'name' => 'Ticket created requester email',
'event' => 'ticket.created',
'subject' => 'Ticket {{ ticket.reference }} raised for {{ requester.name }}',
'html_body' => '<p>{{ ticket.subject }} / {{ ticket.priority }} / {{ ticket.status }}</p>',
'text_body' => '{{ ticket.subject }}',
'to_role' => 'requester',
'is_active' => true,
]);
app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.created', $agent);
$this->assertDatabaseHas('helpdesk_workflow_logs', [
'ticket_id' => $ticket->id,
'event' => 'ticket.created',
'recipient' => 'requester@example.com',
'status' => 'sent',
'subject' => 'Ticket ' . $ticket->reference . ' raised for Requester Name',
]);
}
public function test_inactive_workflow_template_is_not_dispatched(): void
{
Mail::fake();
$ticket = $this->ticket();
WorkflowTemplate::query()->create([
'name' => 'Inactive template',
'event' => 'ticket.updated',
'subject' => 'Inactive',
'html_body' => '<p>Inactive</p>',
'text_body' => 'Inactive',
'to_role' => 'requester',
'is_active' => false,
]);
app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.updated', $ticket->owner);
$this->assertDatabaseCount('helpdesk_workflow_logs', 0);
}
public function test_workflow_logs_skipped_when_recipient_is_missing(): void
{
$ticket = $this->ticket(['agent_id' => null]);
WorkflowTemplate::query()->create([
'name' => 'Agent template',
'event' => 'ticket.updated',
'subject' => 'Agent update',
'html_body' => '<p>Agent update</p>',
'text_body' => 'Agent update',
'to_role' => 'agent',
'is_active' => true,
]);
app(WorkflowNotificationService::class)->dispatch($ticket, 'ticket.updated', $ticket->owner);
$this->assertDatabaseHas('helpdesk_workflow_logs', [
'ticket_id' => $ticket->id,
'event' => 'ticket.updated',
'recipient' => null,
'status' => 'skipped',
]);
}
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\GitCommit;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class ModelRelationshipTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_ticket_loads_company_project_team_category_priority_status_and_git_commits(): void
{
$fixtures = $this->seedLookupData();
$repository = $this->repository(['project_id' => $fixtures['project']->id]);
$ticket = $this->ticket([
'status_id' => $fixtures['open']->id,
'priority_id' => $fixtures['high']->id,
'category_id' => $fixtures['category']->id,
'team_id' => $fixtures['team']->id,
'project_id' => $fixtures['project']->id,
]);
GitCommit::query()->create([
'repository_id' => $repository->id,
'ticket_id' => $ticket->id,
'hash' => str_repeat('a', 40),
'short_hash' => 'aaaaaaa',
'branch' => 'develop',
'author_name' => 'Developer',
'author_email' => 'dev@example.com',
'message' => 'Fix HD reference handling',
'url' => 'https://example.com/commit/a',
'committed_at' => now(),
]);
$ticket->refresh()->load(['status', 'priority', 'category', 'team', 'project', 'gitCommits']);
self::assertSame('Open', $ticket->status?->name);
self::assertSame('High', $ticket->priority?->name);
self::assertSame('Support', $ticket->category?->name);
self::assertSame('Support Team', $ticket->team?->name);
self::assertSame('WMS Core', $ticket->project?->title);
self::assertCount(1, $ticket->gitCommits);
}
public function test_comments_support_nested_replies(): void
{
$ticket = $this->ticket();
$user = $this->user();
$comment = Comment::query()->create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'body' => '<p>Parent response</p>',
'is_internal' => false,
]);
Comment::query()->create([
'ticket_id' => $ticket->id,
'parent_id' => $comment->id,
'user_id' => $user->id,
'body' => '<p>Nested response</p>',
'is_internal' => false,
]);
self::assertSame('Nested response', strip_tags((string) $comment->replies()->firstOrFail()->body));
self::assertCount(1, $ticket->comments()->with('replies')->get());
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Tests\Unit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Helpdesk\Services\TicketReferenceService;
use Modules\Helpdesk\Tests\Concerns\BuildsHelpdeskFixtures;
use Tests\TestCase;
final class TicketReferenceServiceTest extends TestCase
{
use RefreshDatabase;
use BuildsHelpdeskFixtures;
public function test_it_generates_the_first_reference_for_the_current_year(): void
{
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . now()->format('Y') . '-00001', $reference);
}
public function test_it_generates_the_next_reference_after_existing_tickets(): void
{
$this->ticket(['reference' => 'HD-' . now()->format('Y') . '-00041']);
$reference = app(TicketReferenceService::class)->next();
self::assertSame('HD-' . now()->format('Y') . '-00042', $reference);
}
}
+5 -1
View File
@@ -8,4 +8,8 @@ versions:
- Added Vite Support and removed webpack
"1.0.2":
changed:
- Added Trasm support and crud entries
- Stabilised the Livewire rich text editor so ticket details no longer clear during typing or submit early.
- Added Helpdesk Kanban board for workflow status movement.
- Added Kanban PHPUnit and Dusk coverage.
- Added Kanban navigation links to the dashboard and ticket list.
+1 -1
View File
@@ -13,5 +13,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.0.1"
"version": "1.0.2"
}
+4 -6
View File
@@ -6,10 +6,8 @@
"build": "vite build"
},
"devDependencies": {
"@vitejs/plugin-vue": "latest",
"axios": "latest",
"laravel-vite-plugin": "latest",
"vite": "latest"
},
"dependencies": {}
"laravel-vite-plugin": "^2.0.1",
"sass-embedded": "^1.89.2",
"vite": "^7.2.7"
}
}