feat: ticket views, statuses, participants, merge; mobile layout fixes (#5)

- New migrations: ticket views, statuses, participants, merge support
- New models: TicketView, TicketStatus, TicketParticipant
- New seeder: EmailTemplatesSeeder
- Console commands for ticketing
- Mobile: sidebar min-w-0/overflow-hidden, tab nav overflow-x-auto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joel Wedemire
2026-04-19 22:22:45 -07:00
parent ffb64078d8
commit dd0a458250
17 changed files with 1399 additions and 260 deletions

View File

@@ -34,6 +34,9 @@
{ "key": "ticketing.create", "label": "Create Tickets", "description": "Create tickets on behalf of other users" }, { "key": "ticketing.create", "label": "Create Tickets", "description": "Create tickets on behalf of other users" },
{ "key": "ticketing.manage", "label": "Manage Tickets", "description": "View, assign, and resolve all tickets" }, { "key": "ticketing.manage", "label": "Manage Tickets", "description": "View, assign, and resolve all tickets" },
{ "key": "ticketing.settings", "label": "Manage Settings", "description": "Configure groups, priorities, and integrations" } { "key": "ticketing.settings", "label": "Manage Settings", "description": "Configure groups, priorities, and integrations" }
],
"seeders": [
"Dashboard\\Ticketing\\Database\\Seeders\\EmailTemplatesSeeder"
] ]
} }
} }

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Deduplicate before removing group_id:
// If the same priority name exists multiple times (once per group), keep the first
// and delete duplicates, then reassign any tickets that referenced the deleted ones.
$names = DB::table('ticketing_priority_levels')
->select('name')
->groupBy('name')
->havingRaw('COUNT(*) > 1')
->pluck('name');
foreach ($names as $name) {
$rows = DB::table('ticketing_priority_levels')
->where('name', $name)
->orderBy('id')
->get();
$keepId = $rows->first()->id;
foreach ($rows->skip(1) as $row) {
// Reassign tickets pointing at the duplicate
DB::table('tickets')
->where('priority_id', $row->id)
->update(['priority_id' => $keepId]);
DB::table('ticketing_priority_levels')->where('id', $row->id)->delete();
}
}
Schema::table('ticketing_priority_levels', function (Blueprint $table) {
// Drop the foreign key and column
$table->dropForeign(['group_id']);
$table->dropColumn('group_id');
});
}
public function down(): void
{
Schema::table('ticketing_priority_levels', function (Blueprint $table) {
$table->foreignId('group_id')->nullable()->constrained('ticketing_groups')->nullOnDelete();
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_views', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete();
$table->unsignedBigInteger('user_id');
$table->timestamp('viewed_at');
$table->index(['ticket_id', 'user_id']);
$table->index('viewed_at');
});
}
public function down(): void
{
Schema::dropIfExists('ticket_views');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Create ticket_participants table
Schema::create('ticket_participants', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('added_by')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->unique(['ticket_id', 'user_id']);
});
// 2. Add merged_into_id to tickets
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedBigInteger('merged_into_id')->nullable()->after('due_date');
$table->foreign('merged_into_id')->references('id')->on('tickets')->nullOnDelete();
});
// 3. Expand status ENUM to include 'merged'
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed','merged') DEFAULT 'open'");
}
public function down(): void
{
// Reverse ENUM change (remove merged)
DB::statement("UPDATE tickets SET status = 'closed' WHERE status = 'merged'");
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed') DEFAULT 'open'");
Schema::table('tickets', function (Blueprint $table) {
$table->dropForeign(['merged_into_id']);
$table->dropColumn('merged_into_id');
});
Schema::dropIfExists('ticket_participants');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Create ticketing_statuses table
Schema::create('ticketing_statuses', function (Blueprint $table) {
$table->id();
$table->string('slug', 50)->unique(); // machine key — immutable after creation
$table->string('name', 100); // user-facing display name
$table->string('color', 7)->default('#6b7280'); // hex color
$table->unsignedSmallInteger('sort_order')->default(0);
$table->boolean('is_closed')->default(false); // counts as "resolved/closed" in filters
$table->boolean('is_system')->default(false); // cannot be deleted
$table->timestamps();
});
// 2. Seed default statuses
$now = now();
DB::table('ticketing_statuses')->insert([
['slug' => 'open', 'name' => 'Open', 'color' => '#3b82f6', 'sort_order' => 1, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'in_progress', 'name' => 'In Progress', 'color' => '#7c3aed', 'sort_order' => 2, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'pending', 'name' => 'Pending', 'color' => '#d97706', 'sort_order' => 3, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'resolved', 'name' => 'Resolved', 'color' => '#16a34a', 'sort_order' => 4, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'closed', 'name' => 'Closed', 'color' => '#6b7280', 'sort_order' => 5, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'merged', 'name' => 'Merged', 'color' => '#9ca3af', 'sort_order' => 99, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
]);
// 3. Change tickets.status from ENUM to VARCHAR(50) — existing slug values are kept as-is
DB::statement("ALTER TABLE tickets MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'open'");
}
public function down(): void
{
// Restore ENUM (data already uses these slugs so no data loss)
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed','merged') DEFAULT 'open'");
Schema::dropIfExists('ticketing_statuses');
}
};

View File

@@ -0,0 +1,33 @@
<?php
namespace Dashboard\Ticketing\Console\Commands;
use App\Models\Setting;
use Dashboard\Ticketing\Models\Ticket;
use Illuminate\Console\Command;
class AutoCloseTickets extends Command
{
protected $signature = 'ticketing:auto-close';
protected $description = 'Close resolved tickets that have not been updated within the configured grace period.';
public function handle(): int
{
$days = (int) Setting::get('ticketing.auto_close_days', 0);
if ($days <= 0) {
$this->info('Auto-close is disabled (ticketing.auto_close_days = 0).');
return 0;
}
$cutoff = now()->subDays($days);
$count = Ticket::where('status', 'resolved')
->where('updated_at', '<=', $cutoff)
->update(['status' => 'closed']);
$this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago.");
return 0;
}
}

View File

@@ -0,0 +1,372 @@
<?php
namespace Dashboard\Ticketing\Database\Seeders;
use Illuminate\Support\Facades\DB;
/**
* Seeds all standard ticketing email templates into the shell's email_templates table.
* Uses updateOrCreate on slug so re-running on reinstall is safe — existing customisations
* to subject/body are NOT overwritten (only missing templates are inserted).
*/
class EmailTemplatesSeeder
{
public function run(): void
{
foreach ($this->templates() as $template) {
// Only insert if the slug doesn't exist yet — don't overwrite user edits
$exists = DB::table('email_templates')->where('slug', $template['slug'])->exists();
if (! $exists) {
DB::table('email_templates')->insert(array_merge($template, [
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]));
}
}
}
private function templates(): array
{
return [
// ── Submitter-facing ─────────────────────────────────────────────────
[
'slug' => 'ticketing.ticket_received',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Received — Confirmation',
'subject' => '[{{ticket_number}}] We received your request: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{submitter_name}},
Thank you for reaching out. We've received your request and created a ticket for you.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Team: {{group_name}}
You can view your ticket and any updates here:
{{ticket_url}}
We'll be in touch as soon as possible.
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_closed',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Closed — Resolution Notice',
'subject' => '[{{ticket_number}}] Your ticket has been resolved',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket has been marked as resolved.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Team: {{group_name}}
{{resolution_note}}
If you have further questions or the issue has not been fully resolved, you can reply to this email or reopen your ticket here:
{{ticket_url}}
Thank you,
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.status_changed',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Status Changed — Submitter Notice',
'subject' => '[{{ticket_number}}] Your ticket status changed to {{new_status}}',
'body' => <<<'BODY'
Hi {{submitter_name}},
The status of your ticket has been updated.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Previous status: {{old_status}}
New status: {{new_status}}
View your ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_reopened_submitter',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Reopened — Submitter Notice',
'subject' => '[{{ticket_number}}] Your ticket has been reopened',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket has been reopened and is being looked into again.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
View your ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.agent_reply',
'snap_in_slug' => 'ticketing',
'name' => 'Agent Reply to Submitter',
'subject' => '[{{ticket_number}}] Update on your ticket: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{submitter_name}},
{{agent_name}} has replied to your ticket:
---
{{reply_body}}
---
View and reply to your ticket here:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.pending_waiting',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Pending — Waiting on Submitter',
'subject' => '[{{ticket_number}}] We need more information from you',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket is currently on hold while we wait for additional information from you.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Please reply with the requested information so we can continue helping you:
{{ticket_url}}
If we don't hear back within a few days, this ticket may be closed automatically.
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.priority_changed_submitter',
'snap_in_slug' => 'ticketing',
'name' => 'Priority Changed Submitter Notice',
'subject' => '[{{ticket_number}}] Priority updated on your ticket',
'body' => <<<'BODY'
Hi {{submitter_name}},
The priority of your ticket has been updated.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Previous priority: {{old_priority}}
New priority: {{new_priority}}
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_escalated_submitter',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Escalated Submitter Notice',
'subject' => '[{{ticket_number}}] Your ticket has been escalated',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket has been escalated to ensure a faster resolution.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
{{escalation_reason}}
We're treating this as a priority. You can track progress here:
{{ticket_url}}
{{app_name}} Support
BODY,
],
// ── Agent / internal notifications ───────────────────────────────────
[
'slug' => 'ticketing.ticket_assigned_agent',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Assigned to Agent',
'subject' => '[{{ticket_number}}] New ticket assigned to you: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
A ticket has been assigned to you.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Submitted by: {{submitter_name}} ({{submitter_email}})
Team: {{group_name}}
Priority: {{priority_name}}
View ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.submitter_reply_agent',
'snap_in_slug' => 'ticketing',
'name' => 'Submitter Replied — Agent Notification',
'subject' => '[{{ticket_number}}] {{submitter_name}} replied: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
{{submitter_name}} has replied to their ticket.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
---
{{reply_body}}
---
View and respond:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.internal_note_agents',
'snap_in_slug' => 'ticketing',
'name' => 'Internal Note Posted — Agent Notification',
'subject' => '[{{ticket_number}}] New internal note by {{note_author}}',
'body' => <<<'BODY'
Hi,
{{note_author}} posted an internal note on ticket {{ticket_number}}.
Subject: {{ticket_subject}}
---
{{note_body}}
---
View ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_reopened_agent',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Reopened — Agent Notification',
'subject' => '[{{ticket_number}}] Ticket reopened: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
A ticket you worked on has been reopened.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Submitted by: {{submitter_name}}
View ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.unassigned_alert',
'snap_in_slug' => 'ticketing',
'name' => 'Unassigned Ticket Alert — Manager Notification',
'subject' => '[{{ticket_number}}] Unassigned ticket needs attention: {{ticket_subject}}',
'body' => <<<'BODY'
Hi,
A ticket in {{group_name}} has not been assigned to an agent.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Submitted: {{submitted_at}}
Please assign this ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_escalated_agents',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Escalated — Agent Notification',
'subject' => '[ESCALATED] [{{ticket_number}}] {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
Ticket {{ticket_number}} has been escalated.
Subject: {{ticket_subject}}
Submitted by: {{submitter_name}} ({{submitter_email}})
Priority: {{priority_name}}
Reason:
{{escalation_reason}}
This ticket requires immediate attention:
{{ticket_url}}
{{app_name}} Support
BODY,
],
// ── Email-to-ticket auto-reply ────────────────────────────────────────
[
'slug' => 'ticketing.email_autoreply',
'snap_in_slug' => 'ticketing',
'name' => 'Email Inbound — Auto Reply',
'subject' => '[{{ticket_number}}] We received your message: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{sender_name}},
Thank you for your email. We've created a support ticket on your behalf.
Ticket: {{ticket_number}}
Team: {{group_name}}
You can track your request online here:
{{ticket_url}}
Please do not reply to this message use the link above to add updates to your ticket.
{{app_name}} Support
BODY,
],
];
}
}

View File

@@ -17,15 +17,19 @@ class TicketingDemoSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
$admin = DB::table('users')->where('email', 'admin@vancouverchristian.org')->first(); // Use the first super admin, or fall back to any existing user
$micah = DB::table('users')->where('email', 'micah@qa.test')->first(); $admin = DB::table('users')->where('is_super_admin', 1)->first()
$nahum = DB::table('users')->where('email', 'nahum@qa.test')->first(); ?? DB::table('users')->first();
if (! $admin) { if (! $admin) {
$this->command?->warn('TicketingDemoSeeder skipped: admin user not found.'); $this->command?->warn('TicketingDemoSeeder skipped: no users found in the database.');
return; return;
} }
// Additional agents (QA test accounts if they exist)
$micah = DB::table('users')->where('email', 'micah@qa.test')->first();
$nahum = DB::table('users')->where('email', 'nahum@qa.test')->first();
$users = collect([$admin, $micah, $nahum])->filter()->values(); $users = collect([$admin, $micah, $nahum])->filter()->values();
// Clear prior demo/QA ticketing data so the demo state stays coherent instead of // Clear prior demo/QA ticketing data so the demo state stays coherent instead of
@@ -38,9 +42,9 @@ class TicketingDemoSeeder extends Seeder
TicketingGroup::query()->delete(); TicketingGroup::query()->delete();
$groups = [ $groups = [
['name' => 'IT Helpdesk', 'prefix' => 'IT', 'email_address' => 'helpdesk@vcs.local', 'color' => '#2563eb'], ['name' => 'IT Helpdesk', 'prefix' => 'IT', 'email_address' => 'helpdesk@vcs.local', 'color' => '#2563eb'],
['name' => 'Facilities', 'prefix' => 'FAC', 'email_address' => 'facilities@vcs.local', 'color' => '#059669'], ['name' => 'Facilities', 'prefix' => 'FAC', 'email_address' => 'facilities@vcs.local', 'color' => '#059669'],
['name' => 'HR', 'prefix' => 'HR', 'email_address' => 'hr@vcs.local', 'color' => '#7c3aed'], ['name' => 'HR', 'prefix' => 'HR', 'email_address' => 'hr@vcs.local', 'color' => '#7c3aed'],
]; ];
$groupModels = collect(); $groupModels = collect();
@@ -52,94 +56,95 @@ class TicketingDemoSeeder extends Seeder
foreach ($users as $index => $user) { foreach ($users as $index => $user) {
TicketingAgentAccess::firstOrCreate([ TicketingAgentAccess::firstOrCreate([
'user_id' => $user->id, 'user_id' => $user->id,
'group_id' => $group->id, 'group_id' => $group->id,
], [ ], [
'role' => $index === 0 ? 'manager' : 'agent', 'role' => $index === 0 ? 'manager' : 'agent',
]); ]);
} }
foreach ([ // Global priorities — only seed once (first group iteration)
['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1], if (PriorityLevel::count() === 0) {
['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2], foreach ([
['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3], ['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1],
['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4], ['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2],
] as $priorityData) { ['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3],
PriorityLevel::firstOrCreate([ ['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4],
'group_id' => $group->id, ] as $priorityData) {
'name' => $priorityData['name'], PriorityLevel::firstOrCreate(
], [ ['name' => $priorityData['name']],
'color' => $priorityData['color'], ['color' => $priorityData['color'], 'description' => null, 'sort_order' => $priorityData['sort_order']]
'description' => null, );
'sort_order' => $priorityData['sort_order'], }
]);
} }
$projects = match ($group->prefix) { $projects = match ($group->prefix) {
'IT' => [ 'IT' => [
['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'], ['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'],
['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'], ['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'],
['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'], ['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'],
['name' => 'Network & Wi-Fi', 'description' => 'Connectivity and access point issues'],
], ],
'FAC' => [ 'FAC' => [
['name' => 'Work Orders', 'description' => 'General campus maintenance requests'], ['name' => 'Work Orders', 'description' => 'General campus maintenance requests'],
['name' => 'HVAC', 'description' => 'Heating and cooling issues'], ['name' => 'HVAC', 'description' => 'Heating and cooling issues'],
['name' => 'Events Setup', 'description' => 'Room setup and teardown support'], ['name' => 'Events Setup', 'description' => 'Room setup and teardown support'],
['name' => 'Grounds', 'description' => 'Exterior and landscaping requests'],
], ],
default => [ default => [
['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'], ['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'],
['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'], ['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'],
['name' => 'Policy Questions', 'description' => 'Staff handbook and policy clarifications'], ['name' => 'Policy Questions', 'description' => 'Staff handbook and policy clarifications'],
['name' => 'Payroll', 'description' => 'Pay queries and corrections'],
], ],
}; };
foreach ($projects as $projectData) { foreach ($projects as $projectData) {
TicketingProject::firstOrCreate([ TicketingProject::firstOrCreate([
'group_id' => $group->id, 'group_id' => $group->id,
'name' => $projectData['name'], 'name' => $projectData['name'],
], [ ], [
'description' => $projectData['description'], 'description' => $projectData['description'],
'status' => 'active', 'status' => 'active',
'created_by' => $admin->id, 'created_by' => $admin->id,
]); ]);
} }
$groupModels->push($group->fresh(['priorityLevels', 'projects', 'agentAccess'])); $groupModels->push($group->fresh(['priorityLevels', 'projects', 'agentAccess']));
} }
$submitterPool = $this->ensureSubmitterPool(); $submitterPool = $this->ensureSubmitterPool();
$statuses = ['open', 'in_progress', 'pending', 'resolved', 'closed']; $statuses = ['open', 'in_progress', 'pending', 'resolved', 'closed'];
$ticketBlueprints = $this->ticketBlueprints(); $blueprints = $this->ticketBlueprints();
$blueprintCount = count($blueprints);
$ticketCount = 45;
foreach (range(1, 50) as $i) { foreach (range(1, $ticketCount) as $i) {
$group = $groupModels[($i - 1) % $groupModels->count()]; $group = $groupModels[($i - 1) % $groupModels->count()];
$priority = $group->priorityLevels->random(); $priority = $group->priorityLevels->random();
$project = $group->projects->random(); $project = $group->projects->random();
$submitter = $submitterPool[($i - 1) % count($submitterPool)]; $submitter = $submitterPool[($i - 1) % count($submitterPool)];
$assigneeId = $group->agentAccess->random()->user_id; $assigneeId = $group->agentAccess->random()->user_id;
$blueprint = $ticketBlueprints[($i - 1) % count($ticketBlueprints)]; $blueprint = $blueprints[($i - 1) % $blueprintCount];
$createdAt = Carbon::now()->subDays(rand(0, 35))->subHours(rand(0, 23))->subMinutes(rand(0, 59)); $createdAt = Carbon::now()->subDays(rand(0, 45))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
$status = $statuses[array_rand($statuses)]; $status = $statuses[array_rand($statuses)];
$title = $blueprint['title'];
if ($i > count($ticketBlueprints)) {
$title .= ' #' . $i;
}
$ticket = Ticket::updateOrCreate( $ticket = Ticket::updateOrCreate(
['number' => $group->prefix . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT)], ['number' => $group->prefix . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT)],
[ [
'group_id' => $group->id, 'group_id' => $group->id,
'submitter_id' => $submitter['id'], 'submitter_id' => $submitter['id'],
'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0 ? null : $assigneeId, 'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0
'project_id' => $project->id, ? null
'title' => $title, : $assigneeId,
'project_id' => $project->id,
'title' => $blueprint['title'],
'description' => $blueprint['description'], 'description' => $blueprint['description'],
'status' => $status, 'status' => $status,
'priority_id' => $priority->id, 'priority_id' => $priority->id,
'due_date' => rand(0, 1) ? $createdAt->copy()->addDays(rand(2, 14))->toDateString() : null, 'due_date' => rand(0, 1) ? $createdAt->copy()->addDays(rand(2, 14))->toDateString() : null,
'created_at' => $createdAt, 'created_at' => $createdAt,
'updated_at' => $createdAt, 'updated_at' => $createdAt,
] ]
); );
@@ -154,14 +159,14 @@ class TicketingDemoSeeder extends Seeder
private function ensureSubmitterPool(): array private function ensureSubmitterPool(): array
{ {
$defaults = [ $defaults = [
['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local', 'role' => 'staff'], ['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local'],
['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local', 'role' => 'staff'], ['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local'],
['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local', 'role' => 'staff'], ['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local'],
['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local', 'role' => 'staff'], ['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local'],
['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local', 'role' => 'admin'], ['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local'],
['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local', 'role' => 'staff'], ['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local'],
['name' => 'Grace Library', 'email' => 'grace.library@vcs.local', 'role' => 'staff'], ['name' => 'Grace Library', 'email' => 'grace.library@vcs.local'],
['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local', 'role' => 'staff'], ['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local'],
]; ];
$pool = []; $pool = [];
@@ -171,14 +176,14 @@ class TicketingDemoSeeder extends Seeder
if (! $userId) { if (! $userId) {
$userId = DB::table('users')->insertGetId([ $userId = DB::table('users')->insertGetId([
'name' => $person['name'], 'name' => $person['name'],
'email' => $person['email'], 'email' => $person['email'],
'google_id' => 'demo-' . Str::slug($person['email']), 'google_id' => 'demo-' . Str::slug($person['email']),
'role' => $person['role'], 'is_super_admin' => 0,
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => bcrypt(Str::random(32)), 'password' => bcrypt(Str::random(32)),
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
} }
@@ -190,64 +195,75 @@ class TicketingDemoSeeder extends Seeder
private function seedThread(Ticket $ticket, array $submitter, TicketingGroup $group, string $status, Carbon $createdAt): void private function seedThread(Ticket $ticket, array $submitter, TicketingGroup $group, string $status, Carbon $createdAt): void
{ {
$agents = TicketingAgentAccess::where('group_id', $group->id)->pluck('user_id')->all(); $agents = TicketingAgentAccess::where('group_id', $group->id)->pluck('user_id')->all();
$agentId = $ticket->assigned_to ?: ($agents[0] ?? null); $agentId = $ticket->assigned_to ?: ($agents[0] ?? null);
$messages = [ $messages = [
[ [
'user_id' => $submitter['id'], 'user_id' => $submitter['id'],
'author_email' => $submitter['email'], 'author_email' => $submitter['email'],
'body' => $ticket->description, 'body' => $ticket->description,
'is_internal' => false, 'is_internal' => false,
'source' => rand(0, 3) === 0 ? 'email' : 'web', 'source' => rand(0, 3) === 0 ? 'email' : 'web',
'created_at' => $createdAt, 'created_at' => $createdAt,
], ],
]; ];
if ($agentId) { if ($agentId) {
$messages[] = [ $messages[] = [
'user_id' => $agentId, 'user_id' => $agentId,
'author_email' => null, 'author_email' => null,
'body' => $this->agentReplyForStatus($status), 'body' => $this->agentReplyForStatus($status),
'is_internal' => false, 'is_internal' => false,
'source' => 'web', 'source' => 'web',
'created_at' => $createdAt->copy()->addHours(rand(1, 8)), 'created_at' => $createdAt->copy()->addHours(rand(1, 8)),
]; ];
} }
if (in_array($status, ['in_progress', 'pending', 'resolved', 'closed']) && $agentId) { if (in_array($status, ['in_progress', 'pending', 'resolved', 'closed']) && $agentId) {
$messages[] = [ $messages[] = [
'user_id' => $agentId, 'user_id' => $agentId,
'author_email' => null, 'author_email' => null,
'body' => $this->internalNoteForGroup($group->prefix), 'body' => $this->internalNoteForGroup($group->prefix),
'is_internal' => true, 'is_internal' => true,
'source' => 'web', 'source' => 'web',
'created_at' => $createdAt->copy()->addHours(rand(2, 16)), 'created_at' => $createdAt->copy()->addHours(rand(2, 16)),
];
}
if (in_array($status, ['pending']) && $agentId) {
$messages[] = [
'user_id' => $submitter['id'],
'author_email' => $submitter['email'],
'body' => 'Thanks for looking into this. To answer your question — it started about a week ago and it does happen consistently.',
'is_internal' => false,
'source' => 'web',
'created_at' => $createdAt->copy()->addHours(rand(3, 20)),
]; ];
} }
if (in_array($status, ['resolved', 'closed'])) { if (in_array($status, ['resolved', 'closed'])) {
$messages[] = [ $messages[] = [
'user_id' => $submitter['id'], 'user_id' => $submitter['id'],
'author_email' => $submitter['email'], 'author_email' => $submitter['email'],
'body' => 'Thanks — that fixed it. Appreciate the quick help.', 'body' => 'Thanks — that fixed it. Appreciate the quick help.',
'is_internal' => false, 'is_internal' => false,
'source' => 'web', 'source' => 'web',
'created_at' => $createdAt->copy()->addHours(rand(10, 36)), 'created_at' => $createdAt->copy()->addHours(rand(10, 36)),
]; ];
} }
foreach ($messages as $message) { foreach ($messages as $message) {
TicketMessage::create([ TicketMessage::create([
'ticket_id' => $ticket->id, 'ticket_id' => $ticket->id,
'user_id' => $message['user_id'], 'user_id' => $message['user_id'],
'author_email' => $message['author_email'], 'author_email' => $message['author_email'],
'body' => $message['body'], 'body' => $message['body'],
'is_internal' => $message['is_internal'], 'is_internal' => $message['is_internal'],
'source' => $message['source'], 'source' => $message['source'],
'email_message_id' => $message['source'] === 'email' ? '<' . Str::uuid() . '@vcs.local>' : null, 'email_message_id' => $message['source'] === 'email' ? '<' . Str::uuid() . '@vcs.local>' : null,
'created_at' => $message['created_at'], 'created_at' => $message['created_at'],
'updated_at' => $message['created_at'], 'updated_at' => $message['created_at'],
]); ]);
} }
} }
@@ -255,48 +271,139 @@ class TicketingDemoSeeder extends Seeder
private function agentReplyForStatus(string $status): string private function agentReplyForStatus(string $status): string
{ {
return match ($status) { return match ($status) {
'open' => 'Got it. We have this in the queue and will take a look shortly.', 'open' => 'Got it. We have this in the queue and will take a look shortly.',
'in_progress' => 'We are actively working on this now. I will update you once I have confirmed the fix.', 'in_progress' => 'We are actively working on this now. I will update you once I have confirmed the fix.',
'pending' => 'We need one more detail before we can continue. When did you first notice the issue?', 'pending' => 'We need one more detail before we can continue. When did you first notice the issue?',
'resolved' => 'This should now be fixed on our side. Please test when you have a minute.', 'resolved' => 'This should now be fixed on our side. Please test when you have a minute.',
'closed' => 'Closing this out since the issue appears resolved. Reopen any time if it comes back.', 'closed' => 'Closing this out since the issue appears resolved. Reopen any time if it comes back.',
default => 'Thanks, we are on it.', default => 'Thanks, we are on it.',
}; };
} }
private function internalNoteForGroup(string $prefix): string private function internalNoteForGroup(string $prefix): string
{ {
return match ($prefix) { $notes = match ($prefix) {
'IT' => 'Internal: likely permissions/device state issue. If no response by tomorrow, follow up and verify the user can reproduce on a second device.', 'IT' => [
'FAC' => 'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.', 'Internal: likely permissions/device state issue. If no response by tomorrow, follow up and verify the user can reproduce on a second device.',
'HR' => 'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.', 'Internal: checked MDM — device is enrolled. May need remote wipe if issue persists.',
default => 'Internal: triage complete, waiting on next action.', 'Internal: similar issue came up last month, possibly related to the Chrome policy push from 2 weeks ago.',
'Internal: this looks like a profile sync error. Tried clearing cache remotely, should be resolved.',
],
'FAC' => [
'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.',
'Internal: parts are on order, ETA 3 business days. Keep submitter updated.',
'Internal: assigned to weekend crew, no disruption to classes expected.',
'Internal: this is the third report from this room — worth doing a full inspection.',
],
default => [
'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.',
'Internal: escalated to payroll for confirmation. Waiting on their response before replying to the submitter.',
'Internal: reviewed file, looks like a data entry error on our end. Will correct and follow up.',
'Internal: HR director has been looped in. Response expected by end of week.',
],
}; };
return $notes[array_rand($notes)];
} }
private function ticketBlueprints(): array private function ticketBlueprints(): array
{ {
return [ return [
['title' => 'Projector not turning on in Room 204', 'description' => 'The classroom projector in Room 204 is unresponsive this morning. Power button flashes once, then nothing.'], // ── IT ────────────────────────────────────────────────────────────
['title' => 'Need access to new staff shared drive', 'description' => 'I was added to the literacy team but still cannot see the shared Google Drive folder for curriculum planning.'], ['title' => 'Projector not turning on in Room 204',
['title' => 'Chromebook cart missing three chargers', 'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'], 'description' => 'The classroom projector in Room 204 is unresponsive this morning. Power button flashes once, then nothing.'],
['title' => 'Gym thermostat is stuck at 27°C', 'description' => 'The gym feels like a greenhouse. Thermostat shows 27 degrees and the cooling does not seem to be kicking in.'], ['title' => 'Need access to new staff shared drive',
['title' => 'Cant print to office copier', 'description' => 'Print jobs sit in queue and then disappear. I have tried restarting my laptop and reconnecting to Wi-Fi.'], 'description' => 'I was added to the literacy team but still cannot see the shared Google Drive folder for curriculum planning.'],
['title' => 'New employee onboarding checklist incomplete', 'description' => 'Our new EA starts Monday and I cannot find confirmation that payroll, email, and building access were submitted.'], ['title' => 'Chromebook cart missing three chargers',
['title' => 'Classroom speakers crackling during assemblies', 'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'], 'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'],
['title' => 'Request for additional key fob', 'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'], ['title' => 'Can\'t print to office copier',
['title' => 'Google account keeps asking for password again', 'description' => 'My browser signs me out repeatedly and prompts for my password several times a day on the same Mac.'], 'description' => 'Print jobs sit in queue and then disappear. I have tried restarting my laptop and reconnecting to Wi-Fi.'],
['title' => 'Benefits question about dependent coverage', 'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'], ['title' => 'Classroom speakers crackling during assemblies',
['title' => 'Broken desk in portable 3', 'description' => 'One of the student desks has a cracked support and is wobbling badly.'], 'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'],
['title' => 'Staff laptop camera not detected in Meet', 'description' => 'Google Meet says no camera found even though the MacBook camera works in Photo Booth.'], ['title' => 'Google account keeps asking for password again',
['title' => 'Request setup for parent info night', 'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'], 'description' => 'My browser signs me out repeatedly and prompts for my password several times a day on the same Mac.'],
['title' => 'Payroll question on missed stipend', 'description' => 'A coaching stipend does not appear on my latest pay statement and I need to know whether it is pending.'], ['title' => 'Staff laptop camera not detected in Meet',
['title' => 'Door closer slamming in west hallway', 'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'], 'description' => 'Google Meet says no camera found even though the MacBook camera works in Photo Booth.'],
['title' => 'Need substitute teacher account reactivated', 'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'], ['title' => 'Need substitute teacher account reactivated',
['title' => 'Student device filter blocking approved site', 'description' => 'An approved curriculum site is being blocked for students during class and I need it for tomorrow.'], 'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'],
['title' => 'Request ergonomic chair assessment', 'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'], ['title' => 'Student device filter blocking approved site',
['title' => 'Water fountain on second floor leaking', 'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'], 'description' => 'An approved curriculum site is being blocked for students during class and I need it for tomorrow.'],
['title' => 'Need permission to edit school calendar', 'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'], ['title' => 'Need permission to edit school calendar',
'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'],
['title' => 'Smartboard touch screen not responding',
'description' => 'The SMART Board in room 115 powers on and displays correctly but touch input stopped working this morning.'],
['title' => 'Wi-Fi dropping in the library wing',
'description' => 'Multiple staff have reported losing Wi-Fi connection in the library every afternoon around 1:30 PM.'],
['title' => 'Password reset needed for new EA',
'description' => 'Our new educational assistant started today and cannot log in. It looks like the account was created but the password was never sent.'],
['title' => 'Laptop won\'t connect to school network',
'description' => 'My personal MacBook was connecting to the staff network last week but now shows "cannot join" whenever I try.'],
['title' => 'Grade 8 iPads not syncing with MDM',
'description' => 'The Grade 8 iPad cart shows 12 devices as out of compliance in our MDM. Apps are not installing.'],
['title' => 'Classroom Apple TV not showing up',
'description' => 'The Apple TV in Room 309 disappeared from AirPlay device list on all our Macs. Tried a reboot — still not showing.'],
// ── Facilities ────────────────────────────────────────────────────
['title' => 'Gym thermostat is stuck at 27°C',
'description' => 'The gym feels like a greenhouse. Thermostat shows 27 degrees and the cooling does not seem to be kicking in.'],
['title' => 'Request for additional key fob',
'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'],
['title' => 'Broken desk in portable 3',
'description' => 'One of the student desks has a cracked support and is wobbling badly.'],
['title' => 'Request setup for parent info night',
'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'],
['title' => 'Door closer slamming in west hallway',
'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'],
['title' => 'Water fountain on second floor leaking',
'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'],
['title' => 'Bathroom stall door hinge broken — girls\' washroom',
'description' => 'The middle stall door in the main floor girls\' washroom has a broken hinge and cannot close properly.'],
['title' => 'Light bulbs out in staff parking lot',
'description' => 'Three of the five overhead lights in the north staff parking lot are burnt out. Safety concern after dark.'],
['title' => 'Ceiling tile collapsed in room 112',
'description' => 'A ceiling tile in the corner of Room 112 fell overnight. Debris on the floor but no one was hurt.'],
['title' => 'Exterior gate latch not catching',
'description' => 'The latch on the playground gate by the K-3 yard is not catching properly. Gate swings open on its own.'],
['title' => 'Plumbing noise in staff washroom',
'description' => 'There is a loud knocking/banging noise coming from the pipes in the main floor staff washroom when the toilet is flushed.'],
['title' => 'Cafeteria exhaust fan broken',
'description' => 'The exhaust fan in the cafeteria kitchen has stopped working. It is getting smoky during lunch prep.'],
['title' => 'Window blinds broken in Grade 4 room',
'description' => 'Two of the three window blinds in Grade 4 classroom cannot be lowered. Afternoon sun is making it impossible to see the board.'],
['title' => 'Graffiti on east exterior wall',
'description' => 'There is graffiti on the east-facing exterior wall near the secondary entrance that needs to be cleaned before the parent open house.'],
// ── HR ────────────────────────────────────────────────────────────
['title' => 'New employee onboarding checklist incomplete',
'description' => 'Our new EA starts Monday and I cannot find confirmation that payroll, email, and building access were submitted.'],
['title' => 'Benefits question about dependent coverage',
'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'],
['title' => 'Payroll question on missed stipend',
'description' => 'A coaching stipend does not appear on my latest pay statement and I need to know whether it is pending.'],
['title' => 'Request ergonomic chair assessment',
'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'],
['title' => 'Update emergency contact information',
'description' => 'I recently moved and need to update my emergency contact and home address on file.'],
['title' => 'Parental leave documentation request',
'description' => 'I am planning parental leave starting in June and need to know what forms to submit and by when.'],
['title' => 'Professional development funding approval',
'description' => 'I would like to attend a literacy conference in May and need to know if PD funding is still available for this year.'],
['title' => 'Request for letter of employment',
'description' => 'My bank is requesting a letter of employment for a mortgage application. Can HR provide this?'],
['title' => 'Sick leave balance inquiry',
'description' => 'I am not sure how many sick days I have remaining this year. The employee portal is not showing my balance.'],
['title' => 'Contract renewal question for part-time staff',
'description' => 'My contract ends June 30 and I have not received any communication about renewal. Can someone confirm the status?'],
['title' => 'Request to change payroll deposit account',
'description' => 'I recently switched banks and need to update my direct deposit information before the next pay cycle.'],
['title' => 'Reference letter request for former employee',
'description' => 'A former staff member has asked us to provide a reference letter. Who should handle this and what is the process?'],
['title' => 'Vacation carryover from previous school year',
'description' => 'I had unused vacation days from last year that were supposed to carry over. They are not showing up in my current balance.'],
['title' => 'Volunteer supervision policy clarification',
'description' => 'We have a parent volunteer joining our classroom regularly. Do they need a criminal record check and what is our sign-in process?'],
['title' => 'Request for resignation acknowledgement letter',
'description' => 'I submitted my resignation two weeks ago and have not received any written acknowledgement from the school.'],
]; ];
} }
} }

View File

@@ -2,11 +2,17 @@
namespace Dashboard\Ticketing\Http\Controllers; namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use Dashboard\Ticketing\Models\PriorityLevel; use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\Ticket; use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketAttachment;
use Dashboard\Ticketing\Models\TicketingAgentAccess; use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup; use Dashboard\Ticketing\Models\TicketingGroup;
use Dashboard\Ticketing\Models\TicketingProject; use Dashboard\Ticketing\Models\TicketingProject;
use Dashboard\Ticketing\Models\TicketMessage;
use Dashboard\Ticketing\Models\TicketParticipant;
use Dashboard\Ticketing\Models\TicketStatus;
use Dashboard\Ticketing\Models\TicketView;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -51,29 +57,46 @@ class TicketController extends Controller
return $messages->filter(fn($m) => !$m->is_internal)->values(); return $messages->filter(fn($m) => !$m->is_internal)->values();
} }
private function loadUserPrefs(): array
{
$raw = Setting::get('ticketing.pref.' . Auth::id(), '{}');
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
public function index(Request $request): Response public function index(Request $request): Response
{ {
$user = Auth::user(); $user = Auth::user();
$agentGroupIds = $this->agentGroupIds(); $agentGroupIds = $this->agentGroupIds();
$isAgent = count($agentGroupIds) > 0; $isAgent = count($agentGroupIds) > 0;
// Merge saved prefs as defaults — URL params always win
$pref = $this->loadUserPrefs();
if (! $request->filled('filter') && ! empty($pref['filter'])) {
$request->merge(['filter' => $pref['filter']]);
}
// If no groups exist at all, render a first-run / bootstrap state // If no groups exist at all, render a first-run / bootstrap state
$totalGroups = TicketingGroup::count(); $totalGroups = TicketingGroup::count();
if ($totalGroups === 0) { if ($totalGroups === 0) {
return Inertia::render('Ticketing/Index', [ return Inertia::render('Ticketing/Index', [
'tickets' => ['data' => [], 'total' => 0, 'per_page' => 20, 'current_page' => 1, 'last_page' => 1], 'tickets' => ['data' => [], 'total' => 0, 'per_page' => 20, 'current_page' => 1, 'last_page' => 1],
'groups' => [], 'groups' => [],
'priorities' => [], 'priorities' => [],
'projects' => [], 'projects' => [],
'isAgent' => $isAgent, 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'filters' => [], 'isAgent' => $isAgent,
'ticketDetail' => null, 'filters' => [],
'detailAgents' => [], 'ticketDetail' => null,
'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0], 'detailAgents' => [],
'isBootstrap' => true, 'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0],
'isBootstrap' => true,
'userPrefs' => ['view_mode' => 'list', 'filter' => 'all'],
]); ]);
} }
$closedSlugs = TicketStatus::where('is_closed', true)->pluck('slug')->toArray();
$baseQuery = Ticket::query(); $baseQuery = Ticket::query();
if ($isAgent) { if ($isAgent) {
@@ -84,18 +107,18 @@ class TicketController extends Controller
$viewCounts = $isAgent $viewCounts = $isAgent
? [ ? [
'all' => (clone $baseQuery)->whereNotIn('status', ['resolved', 'closed'])->count(), 'all' => (clone $baseQuery)->whereNotIn('status', $closedSlugs)->count(),
'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed'])->count(), 'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs)->count(),
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed'])->count(), 'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(),
'pending' => (clone $baseQuery)->where('status', 'pending')->count(), 'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(), 'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
] ]
: [ : [
'all' => (clone $baseQuery)->count(), 'all' => (clone $baseQuery)->count(),
'mine' => 0, 'mine' => 0,
'unassigned' => 0, 'unassigned' => 0,
'pending' => (clone $baseQuery)->where('status', 'pending')->count(), 'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(), 'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
]; ];
$query = Ticket::with(['group', 'priority', 'project']); $query = Ticket::with(['group', 'priority', 'project']);
@@ -122,9 +145,9 @@ class TicketController extends Controller
} elseif ($request->filter === 'pending') { } elseif ($request->filter === 'pending') {
$query->where('status', 'pending'); $query->where('status', 'pending');
} elseif ($request->filter === 'resolved') { } elseif ($request->filter === 'resolved') {
$query->whereIn('status', ['resolved', 'closed']); $query->whereIn('status', $closedSlugs);
} elseif (!$request->filled('status')) { } elseif (!$request->filled('status')) {
$query->whereNotIn('status', ['resolved', 'closed']); $query->whereNotIn('status', $closedSlugs);
} }
} else { } else {
$query->where('submitter_id', $user->id); $query->where('submitter_id', $user->id);
@@ -134,7 +157,7 @@ class TicketController extends Controller
if ($request->filter === 'pending') { if ($request->filter === 'pending') {
$query->where('status', 'pending'); $query->where('status', 'pending');
} elseif ($request->filter === 'resolved') { } elseif ($request->filter === 'resolved') {
$query->whereIn('status', ['resolved', 'closed']); $query->whereIn('status', $closedSlugs);
} }
} }
@@ -150,10 +173,7 @@ class TicketController extends Controller
}); });
$groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get(); $groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get();
$priorities = PriorityLevel::whereNull('group_id') $priorities = PriorityLevel::orderBy('sort_order')->get();
->orWhereIn('group_id', $agentGroupIds ?: [0])
->orderBy('sort_order')
->get();
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds)) $projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
->where('status', 'active') ->where('status', 'active')
->get(); ->get();
@@ -178,6 +198,7 @@ class TicketController extends Controller
'project', 'project',
'messages.attachments', 'messages.attachments',
'attachments', 'attachments',
'views',
])->whereKey($request->detail); ])->whereKey($request->detail);
if ($isAgent) { if ($isAgent) {
@@ -189,42 +210,56 @@ class TicketController extends Controller
$dt = $detailQuery->first(); $dt = $detailQuery->first();
if ($dt) { if ($dt) {
$viewUserIds = $isAgent ? $dt->views->pluck('user_id') : collect();
$detailUserIds = collect([$dt->submitter_id, $dt->assigned_to]) $detailUserIds = collect([$dt->submitter_id, $dt->assigned_to])
->merge($dt->messages->pluck('user_id')) ->merge($dt->messages->pluck('user_id'))
->merge($viewUserIds)
->filter() ->filter()
->unique(); ->unique();
$detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id'); $detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id');
$dt->submitter = $detailUsers[$dt->submitter_id] ?? null; $dt->submitter = $detailUsers[$dt->submitter_id] ?? null;
$dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null; $dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null;
// Bug #2: Filter internal notes for non-agents // Filter internal notes for non-agents
$visibleMessages = $this->filterMessagesForRole($dt->messages, $isAgent); $visibleMessages = $this->filterMessagesForRole($dt->messages, $isAgent);
$visibleMessages->each(function ($msg) use ($detailUsers) { $visibleMessages->each(function ($msg) use ($detailUsers) {
$msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null; $msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null;
}); });
$dt->setRelation('messages', $visibleMessages); $dt->setRelation('messages', $visibleMessages);
// Augment views with user names (agents only — don't expose to submitters)
if ($isAgent) {
$dt->views->each(fn($v) => $v->user = $detailUsers[$v->user_id] ?? null);
} else {
$dt->setRelation('views', collect());
}
$ticketDetail = $dt; $ticketDetail = $dt;
if ($isAgent) { if ($isAgent) {
$agentIds = TicketingAgentAccess::where('group_id', $dt->group_id)->pluck('user_id'); $agentIds = TicketingAgentAccess::where('group_id', $dt->group_id)->pluck('user_id');
$detailAgents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); $detailAgents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
} }
} }
} }
return Inertia::render('Ticketing/Index', [ return Inertia::render('Ticketing/Index', [
'tickets' => $tickets, 'tickets' => $tickets,
'groups' => $groups, 'groups' => $groups,
'priorities' => $priorities, 'priorities' => $priorities,
'projects' => $projects, 'projects' => $projects,
'isAgent' => $isAgent, 'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']), 'isAgent' => $isAgent,
'ticketDetail' => $ticketDetail, 'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']),
'detailAgents' => $detailAgents, 'ticketDetail' => $ticketDetail,
'viewCounts' => $viewCounts, 'detailAgents' => $detailAgents,
'isBootstrap' => false, 'viewCounts' => $viewCounts,
'isBootstrap' => false,
'userPrefs' => [
'view_mode' => $pref['view_mode'] ?? 'list',
'filter' => $pref['filter'] ?? 'all',
],
]); ]);
} }
@@ -246,11 +281,7 @@ class TicketController extends Controller
]); ]);
} }
// Only pass global priorities + priorities scoped to accessible groups $priorities = PriorityLevel::orderBy('sort_order')->get();
$groupIds = $groups->pluck('id')->toArray();
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $groupIds))
->orderBy('sort_order')
->get();
return Inertia::render('Ticketing/Create', [ return Inertia::render('Ticketing/Create', [
'groups' => $groups, 'groups' => $groups,
@@ -319,12 +350,17 @@ class TicketController extends Controller
abort(403); abort(403);
} }
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']); $ticket->load(['group', 'priority', 'project', 'messages', 'attachments', 'participants']);
// Bug #2: Filter internal notes for submitters // Bug #2: Filter internal notes for submitters
$visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent); $visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent);
$userIds = $visibleMessages->pluck('user_id')->filter()->unique(); $participantUserIds = $ticket->participants->pluck('user_id');
$userIds = $visibleMessages->pluck('user_id')
->merge($participantUserIds)
->push($ticket->submitter_id)
->push($ticket->assigned_to)
->filter()->unique();
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id'); $users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
$visibleMessages->each(function ($msg) use ($users) { $visibleMessages->each(function ($msg) use ($users) {
@@ -333,21 +369,30 @@ class TicketController extends Controller
$ticket->setRelation('messages', $visibleMessages); $ticket->setRelation('messages', $visibleMessages);
// Attach submitter and assignee info
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
$ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null;
// Augment participants with user info
$ticket->participants->each(function ($p) use ($users) {
$p->user = $users[$p->user_id] ?? null;
});
$agents = []; $agents = [];
if ($isAgent) { if ($isAgent) {
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id'); $agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); $agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
} }
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id)) $priorities = PriorityLevel::orderBy('sort_order')->get();
->orderBy('sort_order')->get();
return Inertia::render('Ticketing/Show', [ return Inertia::render('Ticketing/Show', [
'ticket' => $ticket, 'ticket' => $ticket,
'isAgent' => $isAgent, 'isAgent' => $isAgent,
'isManager' => $isAgent && $this->isManager($ticket->group_id), 'isManager' => $isAgent && $this->isManager($ticket->group_id),
'agents' => $agents, 'agents' => $agents,
'priorities' => $priorities, 'priorities' => $priorities,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
]); ]);
} }
@@ -358,8 +403,7 @@ class TicketController extends Controller
} }
$ticket->load(['group', 'priority', 'project']); $ticket->load(['group', 'priority', 'project']);
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id)) $priorities = PriorityLevel::orderBy('sort_order')->get();
->orderBy('sort_order')->get();
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id'); $agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']); $agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
$projects = TicketingProject::where('group_id', $ticket->group_id)->get(); $projects = TicketingProject::where('group_id', $ticket->group_id)->get();
@@ -378,35 +422,48 @@ class TicketController extends Controller
abort(403); abort(403);
} }
// Bug #3: Valid assignees are users with agent access to this group // Determine effective group (may be changing via a transfer)
$validAssigneeIds = TicketingAgentAccess::where('group_id', $ticket->group_id) $targetGroupId = $request->filled('group_id')
? (int) $request->input('group_id')
: $ticket->group_id;
// Valid assignees are agents of the target group
$validAssigneeIds = TicketingAgentAccess::where('group_id', $targetGroupId)
->pluck('user_id') ->pluck('user_id')
->toArray(); ->toArray();
// Build valid status list from DB (exclude 'merged' — set only by the merge operation)
$validStatusSlugs = TicketStatus::where('slug', '!=', 'merged')->pluck('slug')->implode(',');
$rules = [ $rules = [
'title' => 'sometimes|required|string|max:255', 'title' => 'sometimes|required|string|max:255',
'description' => 'sometimes|required|string', 'description' => 'sometimes|required|string',
'status' => 'sometimes|in:open,in_progress,pending,resolved,closed', 'status' => "sometimes|in:{$validStatusSlugs}",
'priority_id' => 'nullable|exists:ticketing_priority_levels,id', 'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date', 'due_date' => 'nullable|date',
'project_id' => 'nullable|exists:ticketing_projects,id', 'project_id' => 'nullable|exists:ticketing_projects,id',
'group_id' => 'nullable|exists:ticketing_groups,id',
'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]), 'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]),
]; ];
$validated = $request->validate($rules); $validated = $request->validate($rules);
// Bug #4: priority_id must belong to this group or be global // Group transfer: requester must be a manager of the target group
if (!empty($validated['priority_id'])) { if (!empty($validated['group_id']) && $validated['group_id'] !== $ticket->group_id) {
$priority = PriorityLevel::find($validated['priority_id']); if (! $this->isManager((int) $validated['group_id'])) {
if ($priority && $priority->group_id !== null && $priority->group_id !== $ticket->group_id) { abort(403, 'You must be a manager of the destination group to transfer tickets.');
abort(422, 'Priority does not belong to this ticket\'s group.');
} }
// Clear assignee and project if they don't belong to the new group
$validated['assigned_to'] = null;
$validated['project_id'] = null;
} else {
unset($validated['group_id']); // no-op if unchanged
} }
// Bug #4: project_id must belong to this group // project_id must belong to the effective group
if (!empty($validated['project_id'])) { if (!empty($validated['project_id'])) {
$project = TicketingProject::find($validated['project_id']); $project = TicketingProject::find($validated['project_id']);
if ($project && $project->group_id !== $ticket->group_id) { if ($project && $project->group_id !== ($validated['group_id'] ?? $ticket->group_id)) {
abort(422, 'Project does not belong to this ticket\'s group.'); abort(422, 'Project does not belong to this ticket\'s group.');
} }
} }
@@ -416,6 +473,50 @@ class TicketController extends Controller
return back()->with('success', 'Ticket updated.'); return back()->with('success', 'Ticket updated.');
} }
/**
* Record that an agent viewed this ticket (called after 10s of the pane being open).
* Throttled: at most one record per user per 2 hours per ticket.
*/
public function recordView(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
return response()->json(['ok' => false], 403);
}
$alreadySeen = TicketView::where('ticket_id', $ticket->id)
->where('user_id', Auth::id())
->where('viewed_at', '>=', now()->subHours(2))
->exists();
if (! $alreadySeen) {
TicketView::create([
'ticket_id' => $ticket->id,
'user_id' => Auth::id(),
'viewed_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
public function savePrefs(Request $request)
{
$validated = $request->validate([
'view_mode' => 'sometimes|in:list,kanban',
'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved',
]);
if (empty($validated)) {
return response()->json(['ok' => false], 422);
}
$key = 'ticketing.pref.' . Auth::id();
$current = json_decode(Setting::get($key, '{}'), true) ?: [];
Setting::set($key, json_encode(array_merge($current, $validated)));
return response()->json(['ok' => true]);
}
public function destroy(Ticket $ticket) public function destroy(Ticket $ticket)
{ {
if (!$this->isManager($ticket->group_id)) { if (!$this->isManager($ticket->group_id)) {
@@ -426,6 +527,204 @@ class TicketController extends Controller
return redirect()->route('ticketing.index'); return redirect()->route('ticketing.index');
} }
/**
* Merge another ticket INTO this one.
* The source ticket's messages and attachments move here; source is marked merged.
*/
public function merge(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$validated = $request->validate([
'source_number' => 'required|string',
]);
$source = Ticket::where('number', $validated['source_number'])->first();
if (! $source) {
return back()->withErrors(['source_number' => 'Ticket not found.']);
}
if ($source->id === $ticket->id) {
return back()->withErrors(['source_number' => 'Cannot merge a ticket into itself.']);
}
if ($source->status === 'merged') {
return back()->withErrors(['source_number' => 'That ticket has already been merged.']);
}
if (! $this->isAgent($source->group_id)) {
return back()->withErrors(['source_number' => 'You do not have access to that ticket.']);
}
DB::transaction(function () use ($ticket, $source) {
// Move messages to this ticket
TicketMessage::where('ticket_id', $source->id)
->update(['ticket_id' => $ticket->id]);
// Move attachments to this ticket
TicketAttachment::where('ticket_id', $source->id)
->update(['ticket_id' => $ticket->id]);
// Add a system note marking the merge
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => Auth::id(),
'body' => "Merged from ticket {$source->number}.",
'is_internal' => true,
'source' => 'system',
]);
// Add source's submitter as a participant (if not already the submitter of target)
if ($source->submitter_id !== $ticket->submitter_id) {
TicketParticipant::firstOrCreate(
['ticket_id' => $ticket->id, 'user_id' => $source->submitter_id],
['added_by' => Auth::id()]
);
}
// Mark source as merged
$source->update([
'status' => 'merged',
'merged_into_id' => $ticket->id,
]);
});
return redirect()->route('ticketing.show', $ticket)
->with('success', "Ticket {$source->number} merged in.");
}
/**
* Split this ticket — create a copy with a new number, same metadata, duplicated messages.
*/
public function split(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$newTicket = DB::transaction(function () use ($ticket) {
$group = TicketingGroup::findOrFail($ticket->group_id);
$number = $group->nextTicketNumber();
$newTicket = Ticket::create([
'number' => $number,
'group_id' => $ticket->group_id,
'submitter_id' => $ticket->submitter_id,
'assigned_to' => $ticket->assigned_to,
'project_id' => $ticket->project_id,
'title' => $ticket->title . ' (split)',
'description' => $ticket->description,
'status' => 'open',
'priority_id' => $ticket->priority_id,
'due_date' => $ticket->due_date,
]);
// Duplicate messages (exclude system notes)
$ticket->load('messages');
foreach ($ticket->messages as $msg) {
TicketMessage::create([
'ticket_id' => $newTicket->id,
'user_id' => $msg->user_id,
'author_email' => $msg->author_email,
'body' => $msg->body,
'is_internal' => $msg->is_internal,
'source' => $msg->source,
]);
}
// Cross-reference notes on both tickets
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => Auth::id(),
'body' => "Split: new ticket {$newTicket->number} created from this ticket.",
'is_internal' => true,
'source' => 'system',
]);
TicketMessage::create([
'ticket_id' => $newTicket->id,
'user_id' => Auth::id(),
'body' => "Split from ticket {$ticket->number}.",
'is_internal' => true,
'source' => 'system',
]);
return $newTicket;
});
return redirect()->route('ticketing.show', $newTicket)
->with('success', "Ticket {$newTicket->number} created from split.");
}
/**
* Add a participant to a ticket.
*/
public function addParticipant(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$validated = $request->validate([
'user_id' => 'required|integer|exists:users,id',
]);
TicketParticipant::firstOrCreate(
['ticket_id' => $ticket->id, 'user_id' => $validated['user_id']],
['added_by' => Auth::id()]
);
return back()->with('success', 'Participant added.');
}
/**
* Remove a participant from a ticket.
*/
public function removeParticipant(Ticket $ticket, int $userId)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
TicketParticipant::where('ticket_id', $ticket->id)
->where('user_id', $userId)
->delete();
return back()->with('success', 'Participant removed.');
}
/**
* Search users by name/email — for the participant picker.
* Returns up to 10 matches, excluding existing participants and the submitter.
*/
public function searchUsers(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$q = $request->input('q', '');
if (strlen($q) < 2) {
return response()->json([]);
}
$excludeIds = $ticket->participants->pluck('user_id')
->push($ticket->submitter_id)
->filter()
->unique()
->values();
$users = DB::table('users')
->where(function ($query) use ($q) {
$query->where('name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%");
})
->whereNotIn('id', $excludeIds)
->limit(10)
->get(['id', 'name', 'email']);
return response()->json($users);
}
public function myTickets(): Response public function myTickets(): Response
{ {
$tickets = Ticket::where('submitter_id', Auth::id()) $tickets = Ticket::where('submitter_id', Auth::id())

View File

@@ -2,12 +2,13 @@
namespace Dashboard\Ticketing\Http\Controllers; namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use Dashboard\Ticketing\Models\EmailConnection; use Dashboard\Ticketing\Models\EmailConnection;
use Dashboard\Ticketing\Models\PriorityLevel; use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\TicketingAgentAccess; use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup; use Dashboard\Ticketing\Models\TicketingGroup;
use Dashboard\Ticketing\Models\TicketingProject; use Dashboard\Ticketing\Models\TicketingProject;
use Illuminate\Validation\Rule; use Dashboard\Ticketing\Models\TicketStatus;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -77,7 +78,7 @@ class TicketingSettingsController extends Controller
$agents = $isBootstrap $agents = $isBootstrap
? collect() ? collect()
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->get(); : TicketingAgentAccess::whereIn('group_id', $myGroupIds)->with('group')->get();
if ($agents->isNotEmpty()) { if ($agents->isNotEmpty()) {
$agentUserIds = $agents->pluck('user_id')->unique(); $agentUserIds = $agents->pluck('user_id')->unique();
@@ -87,8 +88,7 @@ class TicketingSettingsController extends Controller
$priorities = $isBootstrap $priorities = $isBootstrap
? collect() ? collect()
: PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $myGroupIds)) : PriorityLevel::orderBy('sort_order')->get();
->orderBy('sort_order')->get();
$projects = $isBootstrap $projects = $isBootstrap
? collect() ? collect()
@@ -101,14 +101,16 @@ class TicketingSettingsController extends Controller
: EmailConnection::whereIn('group_id', $myGroupIds)->get(); : EmailConnection::whereIn('group_id', $myGroupIds)->get();
return Inertia::render('Ticketing/Settings', [ return Inertia::render('Ticketing/Settings', [
'groups' => $groups, 'groups' => $groups,
'agents' => $agents, 'agents' => $agents,
'priorities' => $priorities, 'priorities' => $priorities,
'projects' => $projects, 'projects' => $projects,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'emailConnections' => $emailConnections, 'emailConnections' => $emailConnections,
'myGroupIds' => $myGroupIds, 'myGroupIds' => $myGroupIds,
'isBootstrap' => $isBootstrap, 'isBootstrap' => $isBootstrap,
'isSiteAdmin' => $this->isSiteAdmin(), 'isSiteAdmin' => $this->isSiteAdmin(),
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
]); ]);
} }
@@ -150,11 +152,10 @@ class TicketingSettingsController extends Controller
]; ];
foreach ($defaults as $d) { foreach ($defaults as $d) {
PriorityLevel::create([ PriorityLevel::create([
'group_id' => $group->id, 'name' => $d['name'],
'name' => $d['name'], 'color' => $d['color'],
'color' => $d['color'],
'description' => null, 'description' => null,
'sort_order' => $d['sort_order'], 'sort_order' => $d['sort_order'],
]); ]);
} }
} }
@@ -209,21 +210,17 @@ class TicketingSettingsController extends Controller
public function storePriority(Request $request) public function storePriority(Request $request)
{ {
$this->requireAgentAccess(); if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:100', 'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'description' => 'nullable|string', 'description' => 'nullable|string',
'sort_order' => 'integer|min:0', 'sort_order' => 'integer|min:0',
'group_id' => 'nullable|exists:ticketing_groups,id',
]); ]);
// If group_id given, caller must be manager of that group
if (!empty($validated['group_id'])) {
$this->requireManagerAccess($validated['group_id']);
}
PriorityLevel::create($validated); PriorityLevel::create($validated);
return back()->with('success', 'Priority level created.'); return back()->with('success', 'Priority level created.');
@@ -231,33 +228,17 @@ class TicketingSettingsController extends Controller
public function updatePriority(Request $request, PriorityLevel $priority) public function updatePriority(Request $request, PriorityLevel $priority)
{ {
$this->requireAgentAccess(); if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
} }
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:100', 'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/', 'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'description' => 'nullable|string', 'description' => 'nullable|string',
'sort_order' => 'required|integer|min:0', 'sort_order' => 'required|integer|min:0',
'group_id' => [
'nullable',
'exists:ticketing_groups,id',
Rule::in([$priority->group_id, null]),
],
]); ]);
if (!empty($validated['group_id'])) {
$this->requireManagerAccess($validated['group_id']);
}
$priority->update($validated); $priority->update($validated);
return back()->with('success', 'Priority level updated.'); return back()->with('success', 'Priority level updated.');
@@ -265,15 +246,8 @@ class TicketingSettingsController extends Controller
public function destroyPriority(PriorityLevel $priority) public function destroyPriority(PriorityLevel $priority)
{ {
$this->requireAgentAccess(); if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
} }
if ($priority->tickets()->exists()) { if ($priority->tickets()->exists()) {
@@ -400,4 +374,97 @@ class TicketingSettingsController extends Controller
$connection->delete(); $connection->delete();
return back()->with('success', 'Email connection removed.'); return back()->with('success', 'Email connection removed.');
} }
public function storeStatus(Request $request)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'sort_order' => 'required|integer|min:0',
'is_closed' => 'boolean',
]);
// Derive a slug from the name (lowercase, underscores) — must be unique
$slug = \Str::slug($validated['name'], '_');
if (TicketStatus::where('slug', $slug)->exists()) {
$slug = $slug . '_' . time();
}
TicketStatus::create([
'slug' => $slug,
'name' => $validated['name'],
'color' => $validated['color'],
'sort_order' => $validated['sort_order'],
'is_closed' => $request->boolean('is_closed', false),
'is_system' => false,
]);
return back()->with('success', 'Status created.');
}
public function updateStatus(Request $request, TicketStatus $status)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'sort_order' => 'required|integer|min:0',
'is_closed' => 'boolean',
]);
// System statuses: only allow name, color, sort_order to be changed — not is_closed
$update = [
'name' => $validated['name'],
'color' => $validated['color'],
'sort_order' => $validated['sort_order'],
];
if (! $status->is_system) {
$update['is_closed'] = $request->boolean('is_closed', false);
}
$status->update($update);
return back()->with('success', 'Status updated.');
}
public function destroyStatus(TicketStatus $status)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
if ($status->is_system) {
return back()->withErrors(['status' => 'System statuses cannot be deleted.']);
}
if ($status->tickets()->exists()) {
return back()->withErrors(['status' => 'Cannot delete a status that is in use by tickets.']);
}
$status->delete();
return back()->with('success', 'Status removed.');
}
public function updateAutomation(Request $request)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage automation settings.');
}
$validated = $request->validate([
'auto_close_days' => 'required|integer|min:0|max:365',
]);
Setting::set('ticketing.auto_close_days', $validated['auto_close_days']);
return back()->with('success', 'Automation settings saved.');
}
} }

View File

@@ -3,19 +3,13 @@
namespace Dashboard\Ticketing\Models; namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
class PriorityLevel extends Model class PriorityLevel extends Model
{ {
protected $table = 'ticketing_priority_levels'; protected $table = 'ticketing_priority_levels';
protected $fillable = ['group_id', 'name', 'color', 'description', 'sort_order']; protected $fillable = ['name', 'color', 'description', 'sort_order'];
public function group(): BelongsTo
{
return $this->belongsTo(TicketingGroup::class, 'group_id');
}
public function tickets(): HasMany public function tickets(): HasMany
{ {

View File

@@ -10,7 +10,7 @@ class Ticket extends Model
{ {
protected $fillable = [ protected $fillable = [
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id', 'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
'title', 'description', 'status', 'priority_id', 'due_date', 'title', 'description', 'status', 'priority_id', 'due_date', 'merged_into_id',
]; ];
protected $casts = [ protected $casts = [
@@ -41,4 +41,19 @@ class Ticket extends Model
{ {
return $this->hasMany(TicketAttachment::class, 'ticket_id'); return $this->hasMany(TicketAttachment::class, 'ticket_id');
} }
public function views(): HasMany
{
return $this->hasMany(TicketView::class, 'ticket_id')->orderBy('viewed_at');
}
public function participants(): HasMany
{
return $this->hasMany(TicketParticipant::class, 'ticket_id');
}
public function mergedInto(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'merged_into_id');
}
} }

View File

@@ -0,0 +1,20 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketParticipant extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
protected $fillable = ['ticket_id', 'user_id', 'added_by'];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TicketStatus extends Model
{
protected $table = 'ticketing_statuses';
protected $fillable = ['slug', 'name', 'color', 'sort_order', 'is_closed', 'is_system'];
protected $casts = [
'is_closed' => 'boolean',
'is_system' => 'boolean',
];
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'status', 'slug');
}
}

20
src/Models/TicketView.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketView extends Model
{
public $timestamps = false;
protected $table = 'ticket_views';
protected $fillable = ['ticket_id', 'user_id', 'viewed_at'];
protected $casts = ['viewed_at' => 'datetime'];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

View File

@@ -12,5 +12,11 @@ class TicketingServiceProvider extends ServiceProvider
{ {
$this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadRoutesFrom(__DIR__.'/routes/ticketing.php'); $this->loadRoutesFrom(__DIR__.'/routes/ticketing.php');
if ($this->app->runningInConsole()) {
$this->commands([
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
]);
}
} }
} }

View File

@@ -14,6 +14,7 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
// ── Fixed paths first (before any /{ticket} wildcards) ──────────────── // ── Fixed paths first (before any /{ticket} wildcards) ────────────────
Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets'); Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets');
Route::post('/prefs', [TicketController::class, 'savePrefs']) ->name('prefs.save');
Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show'); Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show');
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () { Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
@@ -41,6 +42,10 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
Route::post('/settings/email', [TicketingSettingsController::class, 'storeEmailConnection'])->name('settings.email.store'); Route::post('/settings/email', [TicketingSettingsController::class, 'storeEmailConnection'])->name('settings.email.store');
Route::put('/settings/email/{connection}', [TicketingSettingsController::class, 'updateEmailConnection'])->name('settings.email.update'); Route::put('/settings/email/{connection}', [TicketingSettingsController::class, 'updateEmailConnection'])->name('settings.email.update');
Route::delete('/settings/email/{connection}', [TicketingSettingsController::class, 'destroyEmailConnection'])->name('settings.email.destroy'); Route::delete('/settings/email/{connection}', [TicketingSettingsController::class, 'destroyEmailConnection'])->name('settings.email.destroy');
Route::put('/settings/automation', [TicketingSettingsController::class, 'updateAutomation']) ->name('settings.automation.update');
Route::post('/settings/statuses', [TicketingSettingsController::class, 'storeStatus']) ->name('settings.statuses.store');
Route::put('/settings/statuses/{status}', [TicketingSettingsController::class, 'updateStatus']) ->name('settings.statuses.update');
Route::delete('/settings/statuses/{status}', [TicketingSettingsController::class, 'destroyStatus']) ->name('settings.statuses.destroy');
}); });
// ── Wildcard /{ticket} routes (last, after all fixed paths) ────────── // ── Wildcard /{ticket} routes (last, after all fixed paths) ──────────
@@ -49,9 +54,15 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
}); });
Route::middleware('permission:ticketing.manage')->group(function () { Route::middleware('permission:ticketing.manage')->group(function () {
Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit'); Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit');
Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update'); Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update');
Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy'); Route::delete('/{ticket}', [TicketController::class, 'destroy']) ->name('destroy');
Route::post('/{ticket}/view', [TicketController::class, 'recordView']) ->name('tickets.view');
Route::post('/{ticket}/merge', [TicketController::class, 'merge']) ->name('merge');
Route::post('/{ticket}/split', [TicketController::class, 'split']) ->name('split');
Route::post('/{ticket}/participants', [TicketController::class, 'addParticipant']) ->name('participants.store');
Route::delete('/{ticket}/participants/{userId}', [TicketController::class, 'removeParticipant'])->name('participants.destroy');
Route::get('/{ticket}/users/search', [TicketController::class, 'searchUsers']) ->name('users.search');
}); });
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () { Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {