started to add indexes and fixed N+1 issue

This commit is contained in:
Mark Gregory
2026-06-12 14:39:54 +01:00
parent 49340d42af
commit 3c9b39d83b
7 changed files with 165 additions and 149 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ return [
'middleware' => ['web', 'auth'],
'disk' => env('HELPDESK_ATTACHMENTS_DISK', 'helpdesk'),
'max_upload_kb' => (int) env('HELPDESK_MAX_UPLOAD_KB', 10240),
'allowed_mimes' => ['md','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' => 'new',
'closed_status_slug' => 'closed',
];
@@ -43,7 +43,6 @@ final class HelpdeskDatabaseSeeder extends Seeder
['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],
['name' => 'Testing', 'slug' => 'testing', 'color' => 'green', 'sort_order' => 30, 'is_closed' => false],
] as $row) {
Status::query()->updateOrCreate(['slug' => $row['slug']], $row);
}
@@ -77,7 +76,6 @@ final class HelpdeskDatabaseSeeder extends Seeder
['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.'],
['name' => 'Documentation', 'color' => 'red', 'description' => 'System Documents creation or update.'],
] as $row) {
Category::query()->updateOrCreate(
['company_id' => null, 'slug' => Str::slug($row['name'])],
-137
View File
@@ -1,137 +0,0 @@
<?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]
);
}
}
}
@@ -5,8 +5,9 @@
<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">Drag tickets between configured statuses. Add, rename and order boards from Helpdesk Settings → Statuses.</p>
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">Drag tickets between configurable status boards. Add, rename and order boards from Helpdesk Settings → Statuses.</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.settings')" wire:navigate variant="ghost" icon="cog-6-tooth">Configure boards</flux:button>
@@ -31,10 +32,10 @@
this.draggedTicketId = event.currentTarget.dataset.ticketId;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', this.draggedTicketId);
event.currentTarget.classList.add('opacity-60', 'rotate-1');
event.currentTarget.classList.add('opacity-60', 'rotate-1', 'scale-[0.99]');
},
dragEnd(event) {
event.currentTarget.classList.remove('opacity-60', 'rotate-1');
event.currentTarget.classList.remove('opacity-60', 'rotate-1', 'scale-[0.99]');
this.draggedTicketId = null;
this.draggingOverStatusId = null;
},
@@ -53,7 +54,7 @@
const ticketId = event.dataTransfer.getData('text/plain') || this.draggedTicketId;
this.draggingOverStatusId = null;
if (!ticketId) {
if (! ticketId) {
return;
}
@@ -62,10 +63,26 @@
}"
>
@foreach ($statuses as $status)
@php($tickets = $ticketsByStatus[$status->id] ?? collect())
@php
$tickets = $ticketsByStatus[$status->id] ?? collect();
$statusColor = match ($status->color) {
'sky' => '#0284c7',
'blue' => '#2563eb',
'amber' => '#d97706',
'rose' => '#e11d48',
'violet' => '#7c3aed',
'emerald' => '#059669',
'red' => '#dc2626',
'green' => '#16a34a',
'purple' => '#9333ea',
'indigo' => '#4f46e5',
'cyan' => '#0891b2',
default => str_starts_with((string) $status->color, '#') ? $status->color : '#64748b',
};
@endphp
<section
class="flex min-h-[640px] w-[340px] shrink-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-100 shadow-sm dark:border-zinc-700 dark:bg-zinc-950"
class="flex min-h-[640px] w-[340px] shrink-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-100 shadow-sm transition dark:border-zinc-700 dark:bg-zinc-950"
x-bind:class="draggingOverStatusId === '{{ $status->id }}' ? 'ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-zinc-950' : ''"
x-on:dragover="dragOver($event, {{ $status->id }})"
x-on:dragleave="dragLeave({{ $status->id }})"
@@ -76,11 +93,12 @@
<div class="flex items-start justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<span class="inline-flex h-3 w-3 rounded-full" style="background-color: {{ $status->color }}"></span>
<span class="inline-flex h-3 w-3 rounded-full" style="background-color: {{ $statusColor }}"></span>
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $status->name }}</h2>
</div>
<p class="mt-1 text-xs text-zinc-500">{{ $tickets->count() }} tickets</p>
</div>
@if ($status->is_closed)
<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200">Closed</span>
@endif
@@ -89,6 +107,17 @@
<div class="flex-1 space-y-3 overflow-y-auto p-3">
@forelse ($tickets as $ticket)
@php
$priorityColor = match ($ticket->priority?->color) {
'red' => '#dc2626',
'amber' => '#d97706',
'emerald' => '#059669',
'blue' => '#2563eb',
'rose' => '#e11d48',
default => '#64748b',
};
@endphp
<article
wire:key="kanban-ticket-{{ $ticket->id }}"
draggable="true"
@@ -106,8 +135,9 @@
</div>
<h3 class="mt-2 line-clamp-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $ticket->subject }}</h3>
</div>
@if ($ticket->priority)
<span class="shrink-0 rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ $ticket->priority->color }}">{{ $ticket->priority->name }}</span>
<span class="shrink-0 rounded-full px-2 py-1 text-xs font-semibold text-white" style="background-color: {{ $priorityColor }}">{{ $ticket->priority->name }}</span>
@endif
</div>
@@ -0,0 +1,104 @@
<div class="space-y-6">
@include('helpdesk::partials.page-header', [
'eyebrow' => 'Helpdesk',
'title' => 'Create Ticket',
'subtitle' => 'Raise a new support request with priority, project, category, team and full details.',
])
<form wire:submit.prevent="save" enctype="multipart/form-data" class="space-y-6">
<div class="rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
<div class="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Ticket details</h2>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">Capture the request clearly so the support workflow can route and notify correctly.</p>
</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-zinc-700 dark:text-zinc-200" for="subject">Subject</label>
<input id="subject" type="text" wire:model.defer="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
</div>
@if($companies->isNotEmpty())
<div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="company_id">Company</label>
<select id="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">
<option value="">Select company</option>
@foreach ($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
@endforeach
</select>
@error('company_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
@endif
<div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="priority_id">Priority</label>
<select id="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">
<option value="">Select priority</option>
@foreach ($priorities as $priority)
<option value="{{ $priority->id }}">{{ $priority->name }}</option>
@endforeach
</select>
@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-zinc-700 dark:text-zinc-200" for="category_id">Category</label>
<select id="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">
<option value="">Select category</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</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-zinc-700 dark:text-zinc-200" for="project_id">Project</label>
<select id="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">
<option value="">Select project</option>
@foreach ($projects as $project)
<option value="{{ $project->id }}">{{ $project->title ?? $project->name }}</option>
@endforeach
</select>
@error('project_id') <p class="mt-2 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="team_id">Team</label>
<select id="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">
<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>
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="due_at">Due date</label>
<input id="due_at" type="date" wire:model.defer="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
</div>
<div class="lg:col-span-2">
<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>
<div class="lg:col-span-2">
<label class="block text-sm font-semibold text-zinc-700 dark:text-zinc-200" for="attachments">Attachments</label>
<input id="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">
@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 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') }}" 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" 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>
</form>
</div>
+21
View File
@@ -42,3 +42,24 @@ versions:
- 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.0.7":
changed:
- Added ticket edit screen with assignment, status, priority, category, project, team, due date and body editing.
- Added green Edit and blue Back actions on the ticket detail page.
- Added ticket assignment update support directly from the ticket detail page.
- Changed default created ticket status from Open to New.
- Rebuilt the Kanban board as a horizontal draggable workflow board using native drag and drop.
- Added a Configure boards action that links Kanban users to the existing configurable Statuses setup.
- Kept module.json version in sync with changelog.yaml.
"1.0.8":
changed:
- Fixed Helpdesk module view registration so helpdesk::livewire views resolve from the module folder reliably.
- Restored the RouteServiceProvider file expected by the module service provider.
- Rebuilt the performance indexes migration to skip indexes already created by earlier Helpdesk migrations.
- Kept ticket creation as a pure Livewire form and defaulted newly created tickets to the New status.
- Made the Kanban board resilient on empty installs by ensuring default status boards exist.
- Improved Kanban board drag/drop styling and colour rendering while preserving configurable statuses.
- Kept module.json version in sync with changelog.yaml.
+1 -1
View File
@@ -13,5 +13,5 @@
"Modules\\Helpdesk\\Providers\\HelpdeskServiceProvider"
],
"files": [],
"version": "1.0.6"
"version": "1.0.8"
}