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:
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
33
src/Console/Commands/AutoCloseTickets.php
Normal file
33
src/Console/Commands/AutoCloseTickets.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
372
src/Database/Seeders/EmailTemplatesSeeder.php
Normal file
372
src/Database/Seeders/EmailTemplatesSeeder.php
Normal 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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' => 'Can’t 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.'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/Models/TicketParticipant.php
Normal file
20
src/Models/TicketParticipant.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Models/TicketStatus.php
Normal file
23
src/Models/TicketStatus.php
Normal 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
20
src/Models/TicketView.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
Reference in New Issue
Block a user