Compare commits
2 Commits
9527147c32
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0a458250 | ||
|
|
ffb64078d8 |
@@ -34,6 +34,9 @@
|
||||
{ "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.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');
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden bg-gray-100 text-gray-900">
|
||||
<div class="flex flex-col lg:flex-row h-screen overflow-hidden bg-gray-100 text-gray-900">
|
||||
|
||||
<!-- Bootstrap / first-run state -->
|
||||
<div v-if="isBootstrap" class="flex flex-col items-center justify-center w-full h-full text-center px-6">
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
<template v-else>
|
||||
<aside
|
||||
class="w-full shrink-0 border-r border-gray-200 bg-white/90 backdrop-blur lg:flex lg:w-72 lg:flex-col"
|
||||
class="min-w-0 w-full shrink-0 border-r border-gray-200 bg-white/90 backdrop-blur lg:flex lg:w-72 lg:flex-col overflow-y-auto lg:overflow-visible"
|
||||
:class="showSidebar ? 'flex flex-col' : 'hidden'"
|
||||
>
|
||||
<div class="border-b border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-gray-400">Shared inbox</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Ticketing</h1>
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-gray-400 truncate">Shared inbox</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight truncate">Ticketing</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-6 overflow-y-auto p-5">
|
||||
<div class="flex-1 min-w-0 space-y-6 overflow-y-auto p-5">
|
||||
<section class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Group switcher</label>
|
||||
<select
|
||||
@@ -149,7 +149,7 @@
|
||||
</aside>
|
||||
|
||||
<main
|
||||
class="min-w-0 flex-1 border-r border-gray-200 bg-white xl:max-w-[34rem]"
|
||||
class="min-w-0 w-full flex-1 border-r border-gray-200 bg-white xl:max-w-[34rem]"
|
||||
:class="showList ? 'flex' : 'hidden lg:flex'"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
@@ -242,9 +242,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-col items-end justify-between gap-3">
|
||||
<div class="text-xs font-medium text-gray-500">{{ ticket.priority?.name || 'No priority' }}</div>
|
||||
<div class="text-xs font-medium text-gray-500 truncate max-w-[7rem]">{{ ticket.priority?.name || 'No priority' }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] uppercase tracking-[0.16em] text-gray-400">Assignee</span>
|
||||
<span class="hidden sm:inline text-[11px] uppercase tracking-[0.16em] text-gray-400">Assignee</span>
|
||||
<span
|
||||
class="avatar-ring"
|
||||
:class="ticket.assignee ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-200 text-gray-500'"
|
||||
@@ -275,7 +275,7 @@
|
||||
</main>
|
||||
|
||||
<section
|
||||
class="min-w-0 flex-1 flex-col bg-gray-50"
|
||||
class="min-w-0 w-full flex-1 flex-col bg-gray-50"
|
||||
:class="showDetail ? 'flex' : 'hidden lg:flex'"
|
||||
>
|
||||
<div v-if="!ticketDetail" class="flex h-full items-center justify-center p-8">
|
||||
@@ -311,9 +311,9 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-start gap-3">
|
||||
<h3 class="min-w-0 flex-1 text-2xl font-semibold tracking-tight text-gray-900">{{ ticketDetail.title }}</h3>
|
||||
<Link :href="route('ticketing.show', { ticket: ticketDetail.id })" class="text-sm font-medium text-indigo-600 transition hover:text-indigo-500">Open full page</Link>
|
||||
<div class="mt-3 flex flex-wrap items-start gap-3">
|
||||
<h3 class="min-w-0 flex-1 text-xl font-semibold tracking-tight text-gray-900 sm:text-2xl">{{ ticketDetail.title }}</h3>
|
||||
<Link :href="route('ticketing.show', { ticket: ticketDetail.id })" class="shrink-0 text-sm font-medium text-indigo-600 transition hover:text-indigo-500">Open full page</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,11 +348,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||
<span>Group: <strong class="text-gray-700">{{ ticketDetail.group?.name || 'Unknown' }}</strong></span>
|
||||
<span>•</span>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500">
|
||||
<span class="truncate">Group: <strong class="text-gray-700">{{ ticketDetail.group?.name || 'Unknown' }}</strong></span>
|
||||
<span aria-hidden="true" class="hidden sm:inline">•</span>
|
||||
<span>{{ ticketDetail.messages?.length || 0 }} messages</span>
|
||||
<span>•</span>
|
||||
<span aria-hidden="true">•</span>
|
||||
<span>{{ ticketDetail.attachments?.length || 0 }} attachments</span>
|
||||
</div>
|
||||
|
||||
@@ -450,7 +450,7 @@
|
||||
:placeholder="replyMode === 'internal' ? 'Internal note for agents only…' : 'Reply to the thread…'"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ replyMode === 'internal' ? 'Only agents can see internal notes.' : 'Replies are visible in the ticket thread.' }}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="max-w-5xl mx-auto py-8 px-4">
|
||||
<div class="mb-6">
|
||||
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mt-2">Ticketing Settings</h1>
|
||||
<h1 class="text-xl font-bold text-gray-900 mt-2 sm:text-2xl">Ticketing Settings</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isBootstrap" class="mb-6 px-5 py-4 bg-amber-50 border border-amber-300 rounded-xl">
|
||||
@@ -25,13 +25,13 @@
|
||||
{{ $page.props.errors.project }}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 border-b border-gray-200 mb-6">
|
||||
<div class="flex gap-1 border-b border-gray-200 mb-6 overflow-x-auto scrollbar-none -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
:class="[
|
||||
'px-4 py-2.5 text-sm font-medium border-b-2 transition',
|
||||
'shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 transition',
|
||||
activeTab === tab.key
|
||||
? 'border-indigo-600 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'groups'">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Groups</h2>
|
||||
<button @click="showAddGroup = !showAddGroup" class="text-sm text-indigo-600 hover:underline">
|
||||
{{ showAddGroup ? 'Cancel' : '+ Add Group' }}
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<div v-if="showAddGroup" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700">New Group</h3>
|
||||
<form @submit.prevent="submitGroup" class="grid grid-cols-2 gap-3">
|
||||
<form @submit.prevent="submitGroup" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input v-model="groupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
@@ -66,7 +66,7 @@
|
||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
||||
<input v-model="groupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end">
|
||||
<div class="col-span-1 sm:col-span-2 flex justify-end">
|
||||
<button type="submit" :disabled="groupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
||||
Create Group
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
<div v-if="editingGroup" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Edit: {{ editingGroup.name }}</h3>
|
||||
<form @submit.prevent="submitEditGroup" class="grid grid-cols-2 gap-3">
|
||||
<form @submit.prevent="submitEditGroup" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input v-model="editGroupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
@@ -111,7 +111,7 @@
|
||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
||||
<input v-model="editGroupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end gap-2">
|
||||
<div class="col-span-1 sm:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" @click="editingGroup = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
||||
<button type="submit" :disabled="editGroupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
|
||||
</div>
|
||||
@@ -120,7 +120,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'agents'">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Agents</h2>
|
||||
<button @click="showAddAgent = !showAddAgent" class="text-sm text-indigo-600 hover:underline">
|
||||
{{ showAddAgent ? 'Cancel' : '+ Add Agent' }}
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="showAddAgent" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
||||
<form @submit.prevent="submitAgent" class="grid grid-cols-3 gap-3">
|
||||
<form @submit.prevent="submitAgent" class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">User ID</label>
|
||||
<input v-model="agentForm.user_id" required type="number" placeholder="User ID" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
@@ -147,7 +147,7 @@
|
||||
<option value="manager">Manager</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-3 flex justify-end">
|
||||
<div class="col-span-1 sm:col-span-3 flex justify-end">
|
||||
<button type="submit" :disabled="agentForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
||||
Add Agent
|
||||
</button>
|
||||
@@ -172,7 +172,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'priorities'">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Priority Levels</h2>
|
||||
<button @click="showAddPriority = !showAddPriority" class="text-sm text-indigo-600 hover:underline">
|
||||
{{ showAddPriority ? 'Cancel' : '+ Add Priority' }}
|
||||
@@ -180,7 +180,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="showAddPriority" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
||||
<form @submit.prevent="submitPriority" class="grid grid-cols-2 gap-3">
|
||||
<form @submit.prevent="submitPriority" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input v-model="priorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
@@ -189,7 +189,7 @@
|
||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
||||
<input v-model="priorityForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<div class="col-span-1 sm:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||
<input v-model="priorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
@@ -204,7 +204,7 @@
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end">
|
||||
<div class="col-span-1 sm:col-span-2 flex justify-end">
|
||||
<button type="submit" :disabled="priorityForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
||||
Create Priority
|
||||
</button>
|
||||
@@ -234,7 +234,7 @@
|
||||
|
||||
<div v-if="editingPriority" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Edit Priority: {{ editingPriority.name }}</h3>
|
||||
<form @submit.prevent="submitEditPriority" class="grid grid-cols-2 gap-3">
|
||||
<form @submit.prevent="submitEditPriority" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input v-model="editPriorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
@@ -243,7 +243,7 @@
|
||||
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
||||
<input v-model="editPriorityForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<div class="col-span-1 sm:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||
<input v-model="editPriorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
@@ -255,7 +255,7 @@
|
||||
<label class="block text-xs text-gray-500 mb-1">Scope</label>
|
||||
<input :value="editingPriority.group_id ? 'Group-specific' : 'Global'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end gap-2">
|
||||
<div class="col-span-1 sm:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" @click="editingPriority = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
||||
<button type="submit" :disabled="editPriorityForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
|
||||
</div>
|
||||
@@ -264,7 +264,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'projects'">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Projects</h2>
|
||||
<button @click="showAddProject = !showAddProject" class="text-sm text-indigo-600 hover:underline">
|
||||
{{ showAddProject ? 'Cancel' : '+ Add Project' }}
|
||||
@@ -272,7 +272,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="showAddProject" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
|
||||
<form @submit.prevent="submitProject" class="grid grid-cols-2 gap-3">
|
||||
<form @submit.prevent="submitProject" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Group</label>
|
||||
<select v-model="projectForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
|
||||
@@ -287,15 +287,15 @@
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<div class="col-span-1 sm:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input v-model="projectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<div class="col-span-1 sm:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||
<textarea v-model="projectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end">
|
||||
<div class="col-span-1 sm:col-span-2 flex justify-end">
|
||||
<button type="submit" :disabled="projectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
|
||||
Create Project
|
||||
</button>
|
||||
@@ -324,8 +324,8 @@
|
||||
|
||||
<div v-if="editingProject" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-700">Edit Project: {{ editingProject.name }}</h3>
|
||||
<form @submit.prevent="submitEditProject" class="grid grid-cols-2 gap-3">
|
||||
<div class="col-span-2">
|
||||
<form @submit.prevent="submitEditProject" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="col-span-1 sm:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input v-model="editProjectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
@@ -340,11 +340,11 @@
|
||||
<label class="block text-xs text-gray-500 mb-1">Group</label>
|
||||
<input :value="groups.find(g => g.id === editingProject.group_id)?.name || 'Unknown Group'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<div class="col-span-1 sm:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Description</label>
|
||||
<textarea v-model="editProjectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end gap-2">
|
||||
<div class="col-span-1 sm:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" @click="editingProject = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
||||
<button type="submit" :disabled="editProjectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
|
||||
</div>
|
||||
|
||||
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
|
||||
{
|
||||
$admin = DB::table('users')->where('email', 'admin@vancouverchristian.org')->first();
|
||||
$micah = DB::table('users')->where('email', 'micah@qa.test')->first();
|
||||
$nahum = DB::table('users')->where('email', 'nahum@qa.test')->first();
|
||||
// Use the first super admin, or fall back to any existing user
|
||||
$admin = DB::table('users')->where('is_super_admin', 1)->first()
|
||||
?? DB::table('users')->first();
|
||||
|
||||
if (! $admin) {
|
||||
$this->command?->warn('TicketingDemoSeeder skipped: admin user not found.');
|
||||
$this->command?->warn('TicketingDemoSeeder skipped: no users found in the database.');
|
||||
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();
|
||||
|
||||
// Clear prior demo/QA ticketing data so the demo state stays coherent instead of
|
||||
@@ -59,20 +63,19 @@ class TicketingDemoSeeder extends Seeder
|
||||
]);
|
||||
}
|
||||
|
||||
// Global priorities — only seed once (first group iteration)
|
||||
if (PriorityLevel::count() === 0) {
|
||||
foreach ([
|
||||
['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1],
|
||||
['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2],
|
||||
['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3],
|
||||
['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4],
|
||||
] as $priorityData) {
|
||||
PriorityLevel::firstOrCreate([
|
||||
'group_id' => $group->id,
|
||||
'name' => $priorityData['name'],
|
||||
], [
|
||||
'color' => $priorityData['color'],
|
||||
'description' => null,
|
||||
'sort_order' => $priorityData['sort_order'],
|
||||
]);
|
||||
PriorityLevel::firstOrCreate(
|
||||
['name' => $priorityData['name']],
|
||||
['color' => $priorityData['color'], 'description' => null, 'sort_order' => $priorityData['sort_order']]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$projects = match ($group->prefix) {
|
||||
@@ -80,16 +83,19 @@ class TicketingDemoSeeder extends Seeder
|
||||
['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'],
|
||||
['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'],
|
||||
['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'],
|
||||
['name' => 'Network & Wi-Fi', 'description' => 'Connectivity and access point issues'],
|
||||
],
|
||||
'FAC' => [
|
||||
['name' => 'Work Orders', 'description' => 'General campus maintenance requests'],
|
||||
['name' => 'HVAC', 'description' => 'Heating and cooling issues'],
|
||||
['name' => 'Events Setup', 'description' => 'Room setup and teardown support'],
|
||||
['name' => 'Grounds', 'description' => 'Exterior and landscaping requests'],
|
||||
],
|
||||
default => [
|
||||
['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'],
|
||||
['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'],
|
||||
['name' => 'Policy Questions', 'description' => 'Staff handbook and policy clarifications'],
|
||||
['name' => 'Payroll', 'description' => 'Pay queries and corrections'],
|
||||
],
|
||||
};
|
||||
|
||||
@@ -109,31 +115,30 @@ class TicketingDemoSeeder extends Seeder
|
||||
|
||||
$submitterPool = $this->ensureSubmitterPool();
|
||||
$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()];
|
||||
$priority = $group->priorityLevels->random();
|
||||
$project = $group->projects->random();
|
||||
$submitter = $submitterPool[($i - 1) % count($submitterPool)];
|
||||
$assigneeId = $group->agentAccess->random()->user_id;
|
||||
$blueprint = $ticketBlueprints[($i - 1) % count($ticketBlueprints)];
|
||||
$createdAt = Carbon::now()->subDays(rand(0, 35))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
|
||||
$blueprint = $blueprints[($i - 1) % $blueprintCount];
|
||||
$createdAt = Carbon::now()->subDays(rand(0, 45))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
|
||||
$status = $statuses[array_rand($statuses)];
|
||||
|
||||
$title = $blueprint['title'];
|
||||
if ($i > count($ticketBlueprints)) {
|
||||
$title .= ' #' . $i;
|
||||
}
|
||||
|
||||
$ticket = Ticket::updateOrCreate(
|
||||
['number' => $group->prefix . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT)],
|
||||
[
|
||||
'group_id' => $group->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
|
||||
? null
|
||||
: $assigneeId,
|
||||
'project_id' => $project->id,
|
||||
'title' => $title,
|
||||
'title' => $blueprint['title'],
|
||||
'description' => $blueprint['description'],
|
||||
'status' => $status,
|
||||
'priority_id' => $priority->id,
|
||||
@@ -154,14 +159,14 @@ class TicketingDemoSeeder extends Seeder
|
||||
private function ensureSubmitterPool(): array
|
||||
{
|
||||
$defaults = [
|
||||
['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local', 'role' => 'staff'],
|
||||
['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local', 'role' => 'staff'],
|
||||
['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local', 'role' => 'staff'],
|
||||
['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local', 'role' => 'staff'],
|
||||
['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local', 'role' => 'admin'],
|
||||
['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local', 'role' => 'staff'],
|
||||
['name' => 'Grace Library', 'email' => 'grace.library@vcs.local', 'role' => 'staff'],
|
||||
['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local', 'role' => 'staff'],
|
||||
['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local'],
|
||||
['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local'],
|
||||
['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local'],
|
||||
['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local'],
|
||||
['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local'],
|
||||
['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local'],
|
||||
['name' => 'Grace Library', 'email' => 'grace.library@vcs.local'],
|
||||
['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local'],
|
||||
];
|
||||
|
||||
$pool = [];
|
||||
@@ -174,7 +179,7 @@ class TicketingDemoSeeder extends Seeder
|
||||
'name' => $person['name'],
|
||||
'email' => $person['email'],
|
||||
'google_id' => 'demo-' . Str::slug($person['email']),
|
||||
'role' => $person['role'],
|
||||
'is_super_admin' => 0,
|
||||
'email_verified_at' => now(),
|
||||
'password' => bcrypt(Str::random(32)),
|
||||
'created_at' => now(),
|
||||
@@ -226,6 +231,17 @@ class TicketingDemoSeeder extends Seeder
|
||||
];
|
||||
}
|
||||
|
||||
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'])) {
|
||||
$messages[] = [
|
||||
'user_id' => $submitter['id'],
|
||||
@@ -266,37 +282,128 @@ class TicketingDemoSeeder extends Seeder
|
||||
|
||||
private function internalNoteForGroup(string $prefix): string
|
||||
{
|
||||
return 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.',
|
||||
'FAC' => 'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.',
|
||||
'HR' => 'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.',
|
||||
default => 'Internal: triage complete, waiting on next action.',
|
||||
$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.',
|
||||
'Internal: checked MDM — device is enrolled. May need remote wipe if issue persists.',
|
||||
'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
|
||||
{
|
||||
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.'],
|
||||
['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' => 'Chromebook cart missing three chargers', 'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'],
|
||||
['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' => '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.'],
|
||||
['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' => 'Classroom speakers crackling during assemblies', 'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'],
|
||||
['title' => 'Request for additional key fob', 'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'],
|
||||
['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.'],
|
||||
['title' => 'Benefits question about dependent coverage', 'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'],
|
||||
['title' => 'Broken desk in portable 3', 'description' => 'One of the student desks has a cracked support and is wobbling badly.'],
|
||||
['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' => 'Request setup for parent info night', 'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'],
|
||||
['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' => 'Door closer slamming in west hallway', 'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'],
|
||||
['title' => 'Need substitute teacher account reactivated', 'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'],
|
||||
['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.'],
|
||||
['title' => 'Request ergonomic chair assessment', 'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'],
|
||||
['title' => 'Water fountain on second floor leaking', 'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'],
|
||||
['title' => 'Need permission to edit school calendar', 'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'],
|
||||
// ── IT ────────────────────────────────────────────────────────────
|
||||
['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.'],
|
||||
['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' => 'Chromebook cart missing three chargers',
|
||||
'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'],
|
||||
['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.'],
|
||||
['title' => 'Classroom speakers crackling during assemblies',
|
||||
'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'],
|
||||
['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.'],
|
||||
['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' => 'Need substitute teacher account reactivated',
|
||||
'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'],
|
||||
['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.'],
|
||||
['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;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Dashboard\Ticketing\Models\PriorityLevel;
|
||||
use Dashboard\Ticketing\Models\Ticket;
|
||||
use Dashboard\Ticketing\Models\TicketAttachment;
|
||||
use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
||||
use Dashboard\Ticketing\Models\TicketingGroup;
|
||||
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\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -51,12 +57,25 @@ class TicketController extends Controller
|
||||
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
|
||||
{
|
||||
$user = Auth::user();
|
||||
$agentGroupIds = $this->agentGroupIds();
|
||||
$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
|
||||
$totalGroups = TicketingGroup::count();
|
||||
if ($totalGroups === 0) {
|
||||
@@ -65,15 +84,19 @@ class TicketController extends Controller
|
||||
'groups' => [],
|
||||
'priorities' => [],
|
||||
'projects' => [],
|
||||
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
||||
'isAgent' => $isAgent,
|
||||
'filters' => [],
|
||||
'ticketDetail' => null,
|
||||
'detailAgents' => [],
|
||||
'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();
|
||||
|
||||
if ($isAgent) {
|
||||
@@ -84,18 +107,18 @@ class TicketController extends Controller
|
||||
|
||||
$viewCounts = $isAgent
|
||||
? [
|
||||
'all' => (clone $baseQuery)->whereNotIn('status', ['resolved', 'closed'])->count(),
|
||||
'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed'])->count(),
|
||||
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed'])->count(),
|
||||
'all' => (clone $baseQuery)->whereNotIn('status', $closedSlugs)->count(),
|
||||
'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs)->count(),
|
||||
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->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(),
|
||||
'mine' => 0,
|
||||
'unassigned' => 0,
|
||||
'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']);
|
||||
@@ -122,9 +145,9 @@ class TicketController extends Controller
|
||||
} elseif ($request->filter === 'pending') {
|
||||
$query->where('status', 'pending');
|
||||
} elseif ($request->filter === 'resolved') {
|
||||
$query->whereIn('status', ['resolved', 'closed']);
|
||||
$query->whereIn('status', $closedSlugs);
|
||||
} elseif (!$request->filled('status')) {
|
||||
$query->whereNotIn('status', ['resolved', 'closed']);
|
||||
$query->whereNotIn('status', $closedSlugs);
|
||||
}
|
||||
} else {
|
||||
$query->where('submitter_id', $user->id);
|
||||
@@ -134,7 +157,7 @@ class TicketController extends Controller
|
||||
if ($request->filter === 'pending') {
|
||||
$query->where('status', 'pending');
|
||||
} 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();
|
||||
$priorities = PriorityLevel::whereNull('group_id')
|
||||
->orWhereIn('group_id', $agentGroupIds ?: [0])
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
|
||||
->where('status', 'active')
|
||||
->get();
|
||||
@@ -178,6 +198,7 @@ class TicketController extends Controller
|
||||
'project',
|
||||
'messages.attachments',
|
||||
'attachments',
|
||||
'views',
|
||||
])->whereKey($request->detail);
|
||||
|
||||
if ($isAgent) {
|
||||
@@ -189,8 +210,10 @@ class TicketController extends Controller
|
||||
$dt = $detailQuery->first();
|
||||
|
||||
if ($dt) {
|
||||
$viewUserIds = $isAgent ? $dt->views->pluck('user_id') : collect();
|
||||
$detailUserIds = collect([$dt->submitter_id, $dt->assigned_to])
|
||||
->merge($dt->messages->pluck('user_id'))
|
||||
->merge($viewUserIds)
|
||||
->filter()
|
||||
->unique();
|
||||
$detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id');
|
||||
@@ -198,13 +221,20 @@ class TicketController extends Controller
|
||||
$dt->submitter = $detailUsers[$dt->submitter_id] ?? 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->each(function ($msg) use ($detailUsers) {
|
||||
$msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null;
|
||||
});
|
||||
$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;
|
||||
|
||||
if ($isAgent) {
|
||||
@@ -219,12 +249,17 @@ class TicketController extends Controller
|
||||
'groups' => $groups,
|
||||
'priorities' => $priorities,
|
||||
'projects' => $projects,
|
||||
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
||||
'isAgent' => $isAgent,
|
||||
'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']),
|
||||
'ticketDetail' => $ticketDetail,
|
||||
'detailAgents' => $detailAgents,
|
||||
'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
|
||||
$groupIds = $groups->pluck('id')->toArray();
|
||||
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $groupIds))
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||
|
||||
return Inertia::render('Ticketing/Create', [
|
||||
'groups' => $groups,
|
||||
@@ -319,12 +350,17 @@ class TicketController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
|
||||
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments', 'participants']);
|
||||
|
||||
// Bug #2: Filter internal notes for submitters
|
||||
$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');
|
||||
|
||||
$visibleMessages->each(function ($msg) use ($users) {
|
||||
@@ -333,14 +369,22 @@ class TicketController extends Controller
|
||||
|
||||
$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 = [];
|
||||
if ($isAgent) {
|
||||
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||
$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))
|
||||
->orderBy('sort_order')->get();
|
||||
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||
|
||||
return Inertia::render('Ticketing/Show', [
|
||||
'ticket' => $ticket,
|
||||
@@ -348,6 +392,7 @@ class TicketController extends Controller
|
||||
'isManager' => $isAgent && $this->isManager($ticket->group_id),
|
||||
'agents' => $agents,
|
||||
'priorities' => $priorities,
|
||||
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -358,8 +403,7 @@ class TicketController extends Controller
|
||||
}
|
||||
|
||||
$ticket->load(['group', 'priority', 'project']);
|
||||
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
|
||||
->orderBy('sort_order')->get();
|
||||
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
||||
$projects = TicketingProject::where('group_id', $ticket->group_id)->get();
|
||||
@@ -378,35 +422,48 @@ class TicketController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Bug #3: Valid assignees are users with agent access to this group
|
||||
$validAssigneeIds = TicketingAgentAccess::where('group_id', $ticket->group_id)
|
||||
// Determine effective group (may be changing via a transfer)
|
||||
$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')
|
||||
->toArray();
|
||||
|
||||
// Build valid status list from DB (exclude 'merged' — set only by the merge operation)
|
||||
$validStatusSlugs = TicketStatus::where('slug', '!=', 'merged')->pluck('slug')->implode(',');
|
||||
|
||||
$rules = [
|
||||
'title' => 'sometimes|required|string|max:255',
|
||||
'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',
|
||||
'due_date' => 'nullable|date',
|
||||
'project_id' => 'nullable|exists:ticketing_projects,id',
|
||||
'group_id' => 'nullable|exists:ticketing_groups,id',
|
||||
'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]),
|
||||
];
|
||||
|
||||
$validated = $request->validate($rules);
|
||||
|
||||
// Bug #4: priority_id must belong to this group or be global
|
||||
if (!empty($validated['priority_id'])) {
|
||||
$priority = PriorityLevel::find($validated['priority_id']);
|
||||
if ($priority && $priority->group_id !== null && $priority->group_id !== $ticket->group_id) {
|
||||
abort(422, 'Priority does not belong to this ticket\'s group.');
|
||||
// Group transfer: requester must be a manager of the target group
|
||||
if (!empty($validated['group_id']) && $validated['group_id'] !== $ticket->group_id) {
|
||||
if (! $this->isManager((int) $validated['group_id'])) {
|
||||
abort(403, 'You must be a manager of the destination group to transfer tickets.');
|
||||
}
|
||||
// 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'])) {
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
@@ -416,6 +473,50 @@ class TicketController extends Controller
|
||||
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)
|
||||
{
|
||||
if (!$this->isManager($ticket->group_id)) {
|
||||
@@ -426,6 +527,204 @@ class TicketController extends Controller
|
||||
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
|
||||
{
|
||||
$tickets = Ticket::where('submitter_id', Auth::id())
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
namespace Dashboard\Ticketing\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Dashboard\Ticketing\Models\EmailConnection;
|
||||
use Dashboard\Ticketing\Models\PriorityLevel;
|
||||
use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
||||
use Dashboard\Ticketing\Models\TicketingGroup;
|
||||
use Dashboard\Ticketing\Models\TicketingProject;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Dashboard\Ticketing\Models\TicketStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -77,7 +78,7 @@ class TicketingSettingsController extends Controller
|
||||
|
||||
$agents = $isBootstrap
|
||||
? collect()
|
||||
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->get();
|
||||
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->with('group')->get();
|
||||
|
||||
if ($agents->isNotEmpty()) {
|
||||
$agentUserIds = $agents->pluck('user_id')->unique();
|
||||
@@ -87,8 +88,7 @@ class TicketingSettingsController extends Controller
|
||||
|
||||
$priorities = $isBootstrap
|
||||
? collect()
|
||||
: PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $myGroupIds))
|
||||
->orderBy('sort_order')->get();
|
||||
: PriorityLevel::orderBy('sort_order')->get();
|
||||
|
||||
$projects = $isBootstrap
|
||||
? collect()
|
||||
@@ -105,10 +105,12 @@ class TicketingSettingsController extends Controller
|
||||
'agents' => $agents,
|
||||
'priorities' => $priorities,
|
||||
'projects' => $projects,
|
||||
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
||||
'emailConnections' => $emailConnections,
|
||||
'myGroupIds' => $myGroupIds,
|
||||
'isBootstrap' => $isBootstrap,
|
||||
'isSiteAdmin' => $this->isSiteAdmin(),
|
||||
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -150,7 +152,6 @@ class TicketingSettingsController extends Controller
|
||||
];
|
||||
foreach ($defaults as $d) {
|
||||
PriorityLevel::create([
|
||||
'group_id' => $group->id,
|
||||
'name' => $d['name'],
|
||||
'color' => $d['color'],
|
||||
'description' => null,
|
||||
@@ -209,21 +210,17 @@ class TicketingSettingsController extends Controller
|
||||
|
||||
public function storePriority(Request $request)
|
||||
{
|
||||
$this->requireAgentAccess();
|
||||
if (!$this->isSiteAdmin()) {
|
||||
abort(403, 'Only site admins can manage global priorities.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
'description' => 'nullable|string',
|
||||
'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);
|
||||
|
||||
return back()->with('success', 'Priority level created.');
|
||||
@@ -231,33 +228,17 @@ class TicketingSettingsController extends Controller
|
||||
|
||||
public function updatePriority(Request $request, PriorityLevel $priority)
|
||||
{
|
||||
$this->requireAgentAccess();
|
||||
|
||||
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([
|
||||
'name' => 'required|string|max:100',
|
||||
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
|
||||
'description' => 'nullable|string',
|
||||
'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);
|
||||
|
||||
return back()->with('success', 'Priority level updated.');
|
||||
@@ -265,16 +246,9 @@ class TicketingSettingsController extends Controller
|
||||
|
||||
public function destroyPriority(PriorityLevel $priority)
|
||||
{
|
||||
$this->requireAgentAccess();
|
||||
|
||||
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()) {
|
||||
return back()->withErrors([
|
||||
@@ -400,4 +374,97 @@ class TicketingSettingsController extends Controller
|
||||
$connection->delete();
|
||||
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;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class PriorityLevel extends Model
|
||||
{
|
||||
protected $table = 'ticketing_priority_levels';
|
||||
|
||||
protected $fillable = ['group_id', 'name', 'color', 'description', 'sort_order'];
|
||||
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketingGroup::class, 'group_id');
|
||||
}
|
||||
protected $fillable = ['name', 'color', 'description', 'sort_order'];
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ class Ticket extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'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 = [
|
||||
@@ -41,4 +41,19 @@ class Ticket extends Model
|
||||
{
|
||||
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->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) ────────────────
|
||||
|
||||
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::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::put('/settings/email/{connection}', [TicketingSettingsController::class, 'updateEmailConnection'])->name('settings.email.update');
|
||||
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) ──────────
|
||||
@@ -52,6 +57,12 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
||||
Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit');
|
||||
Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update');
|
||||
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 () {
|
||||
|
||||
Reference in New Issue
Block a user