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

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

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

View File

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

View File

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

View File

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

View File

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