FIXING TICKET SAVE

This commit is contained in:
Mark Gregory
2026-06-12 12:58:34 +01:00
parent 292cda7ffc
commit 8ec2c33c47
19 changed files with 435 additions and 173 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ return [
'middleware' => ['web', 'auth'], 'middleware' => ['web', 'auth'],
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'helpdesk'), 'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'helpdesk'),
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240), 'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
'allowed_mimes' => ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'], 'allowed_mimes' => ['md','pdf', 'png', 'jpg', 'jpeg', 'webp', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'csv', 'zip'],
'default_status_slug' => 'open', 'default_status_slug' => 'open',
'closed_status_slug' => 'closed', 'closed_status_slug' => 'closed',
]; ];
@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Modules\Helpdesk\database\seeders; namespace Modules\Helpdesk\Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -15,44 +15,113 @@ use Modules\Helpdesk\Models\WorkflowTemplate;
final class HelpdeskDatabaseSeeder extends Seeder final class HelpdeskDatabaseSeeder extends Seeder
{ {
/** @return void */ /**
* Seed the Helpdesk module lookup data and default workflow templates.
*
* @return void
*/
public function run(): void public function run(): void
{
$this->seedStatuses();
$this->seedPriorities();
$this->seedCategories();
$this->seedTeamsAndProjects();
$this->seedWorkflowTemplates();
}
/**
* Seed ticket workflow statuses.
*
* @return void
*/
private function seedStatuses(): void
{ {
foreach ([ foreach ([
['name' => 'New', 'slug' => 'new', 'color' => 'sky', 'sort_order' => 5, 'is_closed' => false],
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false], ['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false],
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false], ['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false],
['name' => 'Waiting Customer', 'slug' => 'waiting-customer', 'color' => 'purple', 'sort_order' => 30, 'is_closed' => false], ['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'rose', 'sort_order' => 30, 'is_closed' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'green', 'sort_order' => 90, 'is_closed' => true], ['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'violet', 'sort_order' => 40, 'is_closed' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'emerald', 'sort_order' => 90, 'is_closed' => true],
] as $row) { ] as $row) {
Status::query()->updateOrCreate(['slug' => $row['slug']], $row); Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
} }
}
/**
* Seed ticket priorities.
*
* @return void
*/
private function seedPriorities(): void
{
foreach ([ foreach ([
['name' => 'Low', 'slug' => 'low', 'color' => 'zinc', 'sort_order' => 10], ['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10],
['name' => 'Normal', 'slug' => 'normal', 'color' => 'blue', 'sort_order' => 20], ['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20],
['name' => 'High', 'slug' => 'high', 'color' => 'orange', 'sort_order' => 30], ['name' => 'Low', 'slug' => 'low', 'color' => 'emerald', 'sort_order' => 30],
['name' => 'Critical', 'slug' => 'critical', 'color' => 'red', 'sort_order' => 40],
] as $row) { ] as $row) {
Priority::query()->updateOrCreate(['slug' => $row['slug']], $row); Priority::query()->updateOrCreate(['slug' => $row['slug']], $row);
} }
}
foreach (['General Support', 'Software', 'Hardware', 'Access Request', 'Incident'] as $name) { /**
Category::query()->updateOrCreate(['slug' => Str::slug($name)], ['name' => $name, 'color' => 'blue']); * Seed ticket categories.
*
* @return void
*/
private function seedCategories(): void
{
foreach ([
['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.'],
['name' => 'Systems Deployment', 'color' => 'cyan', 'description' => 'Deployment, release, infrastructure and environment requests.'],
['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.'],
['name' => 'Bug', 'color' => 'rose', 'description' => 'Defects, regressions and incorrect behaviour.'],
] as $row) {
Category::query()->updateOrCreate(
['company_id' => null, 'slug' => Str::slug($row['name'])],
$row + ['company_id' => null, 'slug' => Str::slug($row['name'])]
);
} }
}
$team = Team::query()->updateOrCreate(['slug' => 'support'], [ /**
'name' => 'Support', * Seed the default support team and a default project.
'color' => 'indigo', *
'description' => 'Default support team for helpdesk tickets.', * @return void
]); */
private function seedTeamsAndProjects(): void
{
$team = Team::query()->updateOrCreate(
['company_id' => null, 'slug' => 'support'],
[
'company_id' => null,
'name' => 'Support',
'slug' => 'support',
'color' => 'blue',
'description' => 'Default support team for triage, ownership and ticket routing.',
]
);
Project::query()->updateOrCreate(['slug' => 'internal-helpdesk'], [ Project::query()->updateOrCreate(
'team_id' => $team->id, ['company_id' => null, 'slug' => 'support-desk'],
'title' => 'Internal Helpdesk', [
'color' => 'blue', 'company_id' => null,
'description' => 'Default project for internal helpdesk tickets.', 'team_id' => $team->id,
]); 'title' => 'Support Desk',
'slug' => 'support-desk',
'color' => 'indigo',
'description' => 'Default project for operational helpdesk tickets.',
]
);
}
/**
* Seed default professional workflow email templates.
*
* @return void
*/
private function seedWorkflowTemplates(): void
{
foreach ([ 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 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 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'],
@@ -64,6 +133,5 @@ final class HelpdeskDatabaseSeeder extends Seeder
$template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true] $template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true]
); );
} }
} }
} }
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\Category;
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
{
/**
* Seed the Helpdesk module lookup data and default workflow templates.
*
* @return void
*/
public function run(): void
{
$this->seedStatuses();
$this->seedPriorities();
$this->seedCategories();
$this->seedTeamsAndProjects();
$this->seedWorkflowTemplates();
}
/**
* Seed ticket workflow statuses.
*
* @return void
*/
private function seedStatuses(): void
{
foreach ([
['name' => 'New', 'slug' => 'new', 'color' => 'sky', 'sort_order' => 5, 'is_closed' => false],
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false],
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false],
['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'rose', 'sort_order' => 30, 'is_closed' => false],
['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'violet', 'sort_order' => 40, 'is_closed' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'emerald', 'sort_order' => 90, 'is_closed' => true],
] as $row) {
Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
}
}
/**
* Seed ticket priorities.
*
* @return void
*/
private function seedPriorities(): void
{
foreach ([
['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10],
['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20],
['name' => 'Low', 'slug' => 'low', 'color' => 'emerald', 'sort_order' => 30],
] as $row) {
Priority::query()->updateOrCreate(['slug' => $row['slug']], $row);
}
}
/**
* Seed ticket categories.
*
* @return void
*/
private function seedCategories(): void
{
foreach ([
['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.'],
['name' => 'Systems Deployment', 'color' => 'cyan', 'description' => 'Deployment, release, infrastructure and environment requests.'],
['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.'],
['name' => 'Bug', 'color' => 'rose', 'description' => 'Defects, regressions and incorrect behaviour.'],
] as $row) {
Category::query()->updateOrCreate(
['company_id' => null, 'slug' => Str::slug($row['name'])],
$row + ['company_id' => null, 'slug' => Str::slug($row['name'])]
);
}
}
/**
* Seed the default support team and a default project.
*
* @return void
*/
private function seedTeamsAndProjects(): void
{
$team = Team::query()->updateOrCreate(
['company_id' => null, 'slug' => 'support'],
[
'company_id' => null,
'name' => 'Support',
'slug' => 'support',
'color' => 'blue',
'description' => 'Default support team for triage, ownership and ticket routing.',
]
);
Project::query()->updateOrCreate(
['company_id' => null, 'slug' => 'support-desk'],
[
'company_id' => null,
'team_id' => $team->id,
'title' => 'Support Desk',
'slug' => 'support-desk',
'color' => 'indigo',
'description' => 'Default project for operational helpdesk tickets.',
]
);
}
/**
* Seed default professional workflow email templates.
*
* @return void
*/
private function seedWorkflowTemplates(): void
{
foreach ([
['name' => 'Ticket raised requester notice', 'event' => 'ticket.created', 'subject' => 'Ticket {{ ticket.reference }} has been raised', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been raised successfully.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket updated requester notice', 'event' => 'ticket.updated', 'subject' => 'Ticket {{ ticket.reference }} has been updated', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been updated.</p><p><strong>Status:</strong> {{ ticket.status }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket response requester notice', 'event' => 'ticket.commented', 'subject' => 'New response on ticket {{ ticket.reference }}', 'html_body' => '<p>Hello {{ requester.name }},</p><p>A new response has been added to <strong>{{ ticket.reference }}</strong>.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket closed requester notice', 'event' => 'ticket.closed', 'subject' => 'Ticket {{ ticket.reference }} has been closed', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been closed.</p><p>Thank you for using the support desk.</p>', 'to_role' => 'requester'],
] as $template) {
WorkflowTemplate::query()->updateOrCreate(
['name' => $template['name'], 'event' => $template['event']],
$template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true]
);
}
}
}
+4 -8
View File
@@ -6,6 +6,8 @@ namespace Modules\Helpdesk\Livewire;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
@@ -14,8 +16,6 @@ use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Priority; use Modules\Helpdesk\Models\Priority;
use Modules\Helpdesk\Models\Project; use Modules\Helpdesk\Models\Project;
use Modules\Helpdesk\Models\Team; use Modules\Helpdesk\Models\Team;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\Helpdesk\Services\TicketService; use Modules\Helpdesk\Services\TicketService;
final class TicketCreate extends Component final class TicketCreate extends Component
@@ -52,16 +52,13 @@ final class TicketCreate extends Component
/** /**
* Store a new helpdesk ticket. * Store a new helpdesk ticket.
*
* @param TicketService $tickets
* @return mixed
*/ */
public function save(TicketService $tickets): mixed public function save(TicketService $tickets): mixed
{ {
$this->validate(); $this->validate();
$this->validate([ $this->validate([
'attachments.*' => 'file|max:' . config('helpdesk.max_upload_kb') . '|mimes:' . implode(',', config('helpdesk.allowed_mimes')), 'attachments.*' => 'file|max:'.config('helpdesk.max_upload_kb').'|mimes:'.implode(',', config('helpdesk.allowed_mimes')),
]); ]);
$ticket = $tickets->create(Auth::user(), [ $ticket = $tickets->create(Auth::user(), [
@@ -75,12 +72,11 @@ final class TicketCreate extends Component
'due_at' => $this->due_at, 'due_at' => $this->due_at,
], $this->attachments); ], $this->attachments);
session()->flash('success', 'Ticket ' . $ticket->reference . ' has been created.'); session()->flash('success', 'Ticket '.$ticket->reference.' has been created.');
return redirect()->route('helpdesk.tickets.index'); return redirect()->route('helpdesk.tickets.index');
} }
/** @return View */
public function render(): View public function render(): View
{ {
return view('helpdesk::livewire.ticket-create', [ return view('helpdesk::livewire.ticket-create', [
@@ -33,6 +33,10 @@
> >
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $label }}</label> <label class="block text-sm font-medium text-zinc-700 dark:text-zinc-200">{{ $label }}</label>
@if($model)
<input type="hidden" name="{{ $model }}" x-bind:value="value">
@endif
<div class="overflow-hidden rounded-xl border border-zinc-300 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-950" wire:ignore> <div class="overflow-hidden rounded-xl border border-zinc-300 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-950" wire:ignore>
<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"> <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('bold')" class="rounded-lg px-2 py-1 text-sm font-bold hover:bg-zinc-200 dark:hover:bg-zinc-800">B</button>
@@ -5,28 +5,7 @@
'subtitle' => 'Raise a new support request with priority, project, category, team and full details.', 'subtitle' => 'Raise a new support request with priority, project, category, team and full details.',
]) ])
<form <form method="POST" action="{{ route('helpdesk.tickets.store') }}" enctype="multipart/form-data" wire:submit.prevent="save" class="space-y-6">
method="POST"
action="{{ route('helpdesk.tickets.store') }}"
enctype="multipart/form-data"
class="space-y-6"
x-data="{
body: @js(old('body', $body ?? '')),
syncBody() {
this.body = this.$refs.bodyEditor.innerHTML;
this.$refs.bodyInput.value = this.body;
},
command(action, value = null) {
this.$refs.bodyEditor.focus();
document.execCommand(action, false, value);
this.syncBody();
},
init() {
this.$refs.bodyEditor.innerHTML = this.body || '';
this.$refs.bodyInput.value = this.body || '';
}
}"
>
@csrf @csrf
<div class="rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"> <div class="rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
@@ -38,30 +17,17 @@
<div class="grid gap-6 p-6 lg:grid-cols-2"> <div class="grid gap-6 p-6 lg:grid-cols-2">
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="subject">Subject</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="subject">Subject</label>
<input <input id="subject" name="subject" type="text" wire:model.defer="subject" value="{{ old('subject', $subject) }}" class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-subject">
id="subject"
name="subject"
type="text"
value="{{ old('subject', $subject ?? '') }}"
class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-subject"
>
@error('subject') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('subject') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
@if($companies->isNotEmpty()) @if($companies->isNotEmpty())
<div> <div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="company_id">Company</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="company_id">Company</label>
<select <select id="company_id" name="company_id" wire:model.defer="company_id" class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
id="company_id"
name="company_id"
class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
<option value="">Select company</option> <option value="">Select company</option>
@foreach ($companies as $company) @foreach ($companies as $company)
<option value="{{ $company->id }}" @selected((string) old('company_id', $company_id ?? '') === (string) $company->id)> <option value="{{ $company->id }}">{{ $company->name }}</option>
{{ $company->name }}
</option>
@endforeach @endforeach
</select> </select>
@error('company_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('company_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@@ -70,17 +36,10 @@
<div> <div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="priority_id">Priority</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="priority_id">Priority</label>
<select <select id="priority_id" name="priority_id" wire:model.defer="priority_id" class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" data-dusk="ticket-priority">
id="priority_id"
name="priority_id"
class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
data-dusk="ticket-priority"
>
<option value="">Select priority</option> <option value="">Select priority</option>
@foreach ($priorities as $priority) @foreach ($priorities as $priority)
<option value="{{ $priority->id }}" @selected((string) old('priority_id', $priority_id ?? '') === (string) $priority->id)> <option value="{{ $priority->id }}">{{ $priority->name }}</option>
{{ $priority->name }}
</option>
@endforeach @endforeach
</select> </select>
@error('priority_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('priority_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@@ -88,16 +47,10 @@
<div> <div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="category_id">Category</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="category_id">Category</label>
<select <select id="category_id" name="category_id" wire:model.defer="category_id" class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
id="category_id"
name="category_id"
class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
<option value="">Select category</option> <option value="">Select category</option>
@foreach ($categories as $category) @foreach ($categories as $category)
<option value="{{ $category->id }}" @selected((string) old('category_id', $category_id ?? '') === (string) $category->id)> <option value="{{ $category->id }}">{{ $category->name }}</option>
{{ $category->name }}
</option>
@endforeach @endforeach
</select> </select>
@error('category_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('category_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@@ -105,16 +58,10 @@
<div> <div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="project_id">Project</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="project_id">Project</label>
<select <select id="project_id" name="project_id" wire:model.defer="project_id" class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
id="project_id"
name="project_id"
class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
<option value="">Select project</option> <option value="">Select project</option>
@foreach ($projects as $project) @foreach ($projects as $project)
<option value="{{ $project->id }}" @selected((string) old('project_id', $project_id ?? '') === (string) $project->id)> <option value="{{ $project->id }}">{{ $project->title ?? $project->name }}</option>
{{ $project->title ?? $project->name }}
</option>
@endforeach @endforeach
</select> </select>
@error('project_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('project_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@@ -122,16 +69,10 @@
<div> <div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="team_id">Team</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="team_id">Team</label>
<select <select id="team_id" name="team_id" wire:model.defer="team_id" class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
id="team_id"
name="team_id"
class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
<option value="">Select team</option> <option value="">Select team</option>
@foreach ($teams as $team) @foreach ($teams as $team)
<option value="{{ $team->id }}" @selected((string) old('team_id', $team_id ?? '') === (string) $team->id)> <option value="{{ $team->id }}">{{ $team->name }}</option>
{{ $team->name }}
</option>
@endforeach @endforeach
</select> </select>
@error('team_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('team_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@@ -139,63 +80,25 @@
<div> <div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="due_at">Due date</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="due_at">Due date</label>
<input <input id="due_at" name="due_at" type="date" wire:model.defer="due_at" value="{{ old('due_at', $due_at) }}" class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
id="due_at"
name="due_at"
type="date"
value="{{ old('due_at', $due_at ?? '') }}"
class="mt-2 block w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
@error('due_at') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('due_at') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200">Details</label> <x-helpdesk::html-editor dusk="ticket-body-editor" wire:model="body" label="Details" rows="10" placeholder="Describe the issue, expected outcome, steps to reproduce and any useful context..." />
<div class="mt-2 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
x-ref="bodyEditor"
contenteditable="true"
x-on:input.debounce.200ms="syncBody"
x-on:blur="syncBody"
class="prose prose-sm min-h-[280px] 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-dusk="ticket-body-editor"
></div>
</div>
<input x-ref="bodyInput" type="hidden" name="body" value="{{ old('body', $body ?? '') }}">
@error('body') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
<div class="lg:col-span-2"> <div class="lg:col-span-2">
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="attachments">Attachments</label> <label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="attachments">Attachments</label>
<input <input id="attachments" name="attachments[]" type="file" wire:model="attachments" multiple class="mt-2 block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
id="attachments"
name="attachments[]"
type="file"
multiple
class="mt-2 block w-full rounded-xl border border-zinc-300 bg-white px-3 py-2 text-sm shadow-sm dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
>
@error('attachments') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('attachments') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
@error('attachments.*') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror @error('attachments.*') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
</div> </div>
<div class="flex items-center justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700"> <div class="flex items-center justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
<a href="{{ route('helpdesk.tickets.index') }}" wire:navigate class="rounded-xl border border-zinc-300 px-4 py-2 text-sm font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800">Cancel</a> <a href="{{ route('helpdesk.tickets.index') }}" class="rounded-xl border border-zinc-300 px-4 py-2 text-sm font-semibold text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800">Cancel</a>
<button type="submit" x-on:click="syncBody" class="rounded-xl bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700" data-dusk="ticket-create-submit">Create Ticket</button> <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" data-dusk="ticket-create-submit">Create Ticket</button>
</div> </div>
</div> </div>
</form> </form>
+1 -1
View File
@@ -17,6 +17,6 @@ Route::get('/settings', SettingsManager::class)->name('settings');
Route::get('/kanban', KanbanBoard::class)->name('kanban'); Route::get('/kanban', KanbanBoard::class)->name('kanban');
Route::get('/tickets', TicketIndex::class)->name('tickets.index'); Route::get('/tickets', TicketIndex::class)->name('tickets.index');
Route::post('/tickets', [TicketStoreController::class, 'store'])->name('tickets.store');
Route::get('/tickets/create', TicketCreate::class)->name('tickets.create'); Route::get('/tickets/create', TicketCreate::class)->name('tickets.create');
Route::post('/tickets', TicketStoreController::class)->name('tickets.store');
Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show'); Route::get('/tickets/{ticket}', TicketShow::class)->name('tickets.show');
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Modules\Helpdesk\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use Modules\Helpdesk\Models\Category;
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
{
/**
* Seed the Helpdesk module lookup data and default workflow templates.
*
* @return void
*/
public function run(): void
{
$this->seedStatuses();
$this->seedPriorities();
$this->seedCategories();
$this->seedTeamsAndProjects();
$this->seedWorkflowTemplates();
}
/**
* Seed ticket workflow statuses.
*
* @return void
*/
private function seedStatuses(): void
{
foreach ([
['name' => 'New', 'slug' => 'new', 'color' => 'sky', 'sort_order' => 5, 'is_closed' => false],
['name' => 'Open', 'slug' => 'open', 'color' => 'blue', 'sort_order' => 10, 'is_closed' => false],
['name' => 'In Progress', 'slug' => 'in-progress', 'color' => 'amber', 'sort_order' => 20, 'is_closed' => false],
['name' => 'Stalled', 'slug' => 'stalled', 'color' => 'rose', 'sort_order' => 30, 'is_closed' => false],
['name' => 'On-Hold', 'slug' => 'on-hold', 'color' => 'violet', 'sort_order' => 40, 'is_closed' => false],
['name' => 'Closed', 'slug' => 'closed', 'color' => 'emerald', 'sort_order' => 90, 'is_closed' => true],
] as $row) {
Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
}
}
/**
* Seed ticket priorities.
*
* @return void
*/
private function seedPriorities(): void
{
foreach ([
['name' => 'High', 'slug' => 'high', 'color' => 'red', 'sort_order' => 10],
['name' => 'Medium', 'slug' => 'medium', 'color' => 'amber', 'sort_order' => 20],
['name' => 'Low', 'slug' => 'low', 'color' => 'emerald', 'sort_order' => 30],
] as $row) {
Priority::query()->updateOrCreate(['slug' => $row['slug']], $row);
}
}
/**
* Seed ticket categories.
*
* @return void
*/
private function seedCategories(): void
{
foreach ([
['name' => 'Development', 'color' => 'indigo', 'description' => 'Software changes, enhancements and development work.'],
['name' => 'Systems Deployment', 'color' => 'cyan', 'description' => 'Deployment, release, infrastructure and environment requests.'],
['name' => 'Concepts', 'color' => 'purple', 'description' => 'Ideas, concepts, design discussion and future improvements.'],
['name' => 'Bug', 'color' => 'rose', 'description' => 'Defects, regressions and incorrect behaviour.'],
] as $row) {
Category::query()->updateOrCreate(
['company_id' => null, 'slug' => Str::slug($row['name'])],
$row + ['company_id' => null, 'slug' => Str::slug($row['name'])]
);
}
}
/**
* Seed the default support team and a default project.
*
* @return void
*/
private function seedTeamsAndProjects(): void
{
$team = Team::query()->updateOrCreate(
['company_id' => null, 'slug' => 'support'],
[
'company_id' => null,
'name' => 'Support',
'slug' => 'support',
'color' => 'blue',
'description' => 'Default support team for triage, ownership and ticket routing.',
]
);
Project::query()->updateOrCreate(
['company_id' => null, 'slug' => 'support-desk'],
[
'company_id' => null,
'team_id' => $team->id,
'title' => 'Support Desk',
'slug' => 'support-desk',
'color' => 'indigo',
'description' => 'Default project for operational helpdesk tickets.',
]
);
}
/**
* Seed default professional workflow email templates.
*
* @return void
*/
private function seedWorkflowTemplates(): void
{
foreach ([
['name' => 'Ticket raised requester notice', 'event' => 'ticket.created', 'subject' => 'Ticket {{ ticket.reference }} has been raised', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been raised successfully.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket updated requester notice', 'event' => 'ticket.updated', 'subject' => 'Ticket {{ ticket.reference }} has been updated', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been updated.</p><p><strong>Status:</strong> {{ ticket.status }}</p><p><strong>Priority:</strong> {{ ticket.priority }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket response requester notice', 'event' => 'ticket.commented', 'subject' => 'New response on ticket {{ ticket.reference }}', 'html_body' => '<p>Hello {{ requester.name }},</p><p>A new response has been added to <strong>{{ ticket.reference }}</strong>.</p><p><strong>Subject:</strong> {{ ticket.subject }}</p>', 'to_role' => 'requester'],
['name' => 'Ticket closed requester notice', 'event' => 'ticket.closed', 'subject' => 'Ticket {{ ticket.reference }} has been closed', 'html_body' => '<p>Hello {{ requester.name }},</p><p>Your ticket <strong>{{ ticket.reference }}</strong> has been closed.</p><p>Thank you for using the support desk.</p>', 'to_role' => 'requester'],
] as $template) {
WorkflowTemplate::query()->updateOrCreate(
['name' => $template['name'], 'event' => $template['event']],
$template + ['text_body' => null, 'cc' => null, 'bcc' => null, 'is_active' => true]
);
}
}
}
@@ -7,6 +7,7 @@ namespace Modules\Helpdesk\Services;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Helpdesk\Models\Category; use Modules\Helpdesk\Models\Category;
use Modules\Helpdesk\Models\Ticket;
final class AgentAssignmentService final class AgentAssignmentService
{ {
@@ -22,9 +23,13 @@ final class AgentAssignmentService
->where(static function (Builder $query): void { ->where(static function (Builder $query): void {
$query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true); $query->where('helpdesk_agent', true)->orWhere('helpdesk_admin', true);
}) })
->withCount(['helpdeskAssignedTickets as open_helpdesk_tickets_count' => static function (Builder $query): void { ->selectSub(
$query->whereNull('completed_at'); Ticket::query()
}]); ->selectRaw('count(*)')
->whereColumn('helpdesk_tickets.agent_id', 'users.id')
->whereNull('helpdesk_tickets.completed_at'),
'open_helpdesk_tickets_count'
);
if ($categoryId !== null) { if ($categoryId !== null) {
$category = Category::query()->find($categoryId); $category = Category::query()->find($categoryId);
+20 -17
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Modules\Helpdesk\Services; namespace Modules\Helpdesk\Services;
use App\Models\User; use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Modules\Helpdesk\Models\Comment; use Modules\Helpdesk\Models\Comment;
use Modules\Helpdesk\Models\Status; use Modules\Helpdesk\Models\Status;
@@ -17,21 +18,32 @@ final class TicketService
private readonly AgentAssignmentService $assignment, private readonly AgentAssignmentService $assignment,
private readonly AttachmentService $attachments, private readonly AttachmentService $attachments,
private readonly WorkflowNotificationService $workflow, private readonly WorkflowNotificationService $workflow,
) { ) {}
}
/** /**
* Create a ticket and store initial attachments. * Create a ticket and store initial attachments.
* *
* @param User $user * @param array<string, mixed> $data
* @param array<string, mixed> $data * @param array<int, UploadedFile> $files
* @param array<int, \Illuminate\Http\UploadedFile> $files
* @return Ticket
*/ */
public function create(User $user, array $data, array $files = []): Ticket public function create(User $user, array $data, array $files = []): Ticket
{ {
return DB::transaction(function () use ($user, $data, $files): Ticket { return DB::transaction(function () use ($user, $data, $files): Ticket {
$status = Status::query()->where('slug', config('helpdesk.default_status_slug'))->firstOrFail(); $status = Status::query()
->where('slug', config('helpdesk.default_status_slug', 'open'))
->first();
if (! $status) {
$status = Status::query()->create([
'name' => 'Open',
'slug' => config('helpdesk.default_status_slug', 'open'),
'colour' => '#2563eb',
'sort_order' => 1,
'is_default' => true,
'is_closed' => false,
]);
}
/** @var Ticket $ticket */ /** @var Ticket $ticket */
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
@@ -62,12 +74,7 @@ final class TicketService
/** /**
* Add a public or internal comment and optional attachments. * Add a public or internal comment and optional attachments.
* *
* @param Ticket $ticket * @param array<int, UploadedFile> $files
* @param User $user
* @param string $body
* @param bool $internal
* @param array<int, \Illuminate\Http\UploadedFile> $files
* @return Comment
*/ */
public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null): Comment public function comment(Ticket $ticket, User $user, string $body, bool $internal = false, array $files = [], ?int $parentId = null): Comment
{ {
@@ -90,10 +97,6 @@ final class TicketService
/** /**
* Close a ticket. * Close a ticket.
*
* @param Ticket $ticket
* @param User $user
* @return Ticket
*/ */
public function close(Ticket $ticket, User $user): Ticket public function close(Ticket $ticket, User $user): Ticket
{ {
Regular → Executable
+9 -4
View File
@@ -31,9 +31,14 @@ versions:
"1.0.5": "1.0.5":
changed: changed:
- Reworked ticket creation to use a normal HTTP POST route instead of the Livewire update endpoint, preventing proxy or hashed Livewire update-route 404s from blocking ticket creation. - Added the missing POST helpdesk.tickets.store route so native form submission to /helpdesk/tickets no longer returns 404.
- Added a dedicated TicketStoreController for standard ticket creation validation, attachments and redirects. - Added TicketStoreController as a safe non-JavaScript fallback while keeping the Livewire ticket create flow intact.
- Changed the ticket-create Blade form to use real form field names, CSRF protection and a non-Livewire rich HTML editor payload. - Updated the ticket create form with method, action, CSRF, multipart support and named fields for fallback posting.
- Removed the module-level Livewire update route override so the Helpdesk module no longer changes the application's global Livewire endpoint.
- Kept module.json version in sync with changelog.yaml. - Kept module.json version in sync with changelog.yaml.
"1.0.6":
changed:
- Rebuilt the Helpdesk database seeder with the requested Categories, Team, Priority and Status lookup values.
- Added contrasting colour assignments for seeded lookup records.
- Corrected the seeder namespace to Modules\\Helpdesk\\Database\\Seeders for PSR-4 module autoloading.
- Kept module.json version in sync with changelog.yaml.
+1 -1
View File
@@ -13,5 +13,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider" "Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
], ],
"files": [], "files": [],
"version": "1.0.5" "version": "1.0.6"
} }
+5 -1
View File
@@ -59,7 +59,11 @@ return [
'throw' => false, 'throw' => false,
'report' => false, 'report' => false,
], ],
'helpdesk' => [
'driver' => 'local',
'root' => storage_path('app/helpdesk'),
'throw' => false,
],
], ],
/* /*