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

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

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

View File

@@ -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()
@@ -101,14 +101,16 @@ class TicketingSettingsController extends Controller
: EmailConnection::whereIn('group_id', $myGroupIds)->get();
return Inertia::render('Ticketing/Settings', [
'groups' => $groups,
'agents' => $agents,
'priorities' => $priorities,
'projects' => $projects,
'groups' => $groups,
'agents' => $agents,
'priorities' => $priorities,
'projects' => $projects,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'emailConnections' => $emailConnections,
'myGroupIds' => $myGroupIds,
'isBootstrap' => $isBootstrap,
'isSiteAdmin' => $this->isSiteAdmin(),
'myGroupIds' => $myGroupIds,
'isBootstrap' => $isBootstrap,
'isSiteAdmin' => $this->isSiteAdmin(),
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
]);
}
@@ -150,11 +152,10 @@ class TicketingSettingsController extends Controller
];
foreach ($defaults as $d) {
PriorityLevel::create([
'group_id' => $group->id,
'name' => $d['name'],
'color' => $d['color'],
'name' => $d['name'],
'color' => $d['color'],
'description' => null,
'sort_order' => $d['sort_order'],
'sort_order' => $d['sort_order'],
]);
}
}
@@ -209,21 +210,17 @@ class TicketingSettingsController extends Controller
public function storePriority(Request $request)
{
$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}$/',
'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',
'sort_order' => 'integer|min:0',
]);
// 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.');
}
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}$/',
'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]),
],
'sort_order' => 'required|integer|min:0',
]);
if (!empty($validated['group_id'])) {
$this->requireManagerAccess($validated['group_id']);
}
$priority->update($validated);
return back()->with('success', 'Priority level updated.');
@@ -265,15 +246,8 @@ 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 (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
if ($priority->tickets()->exists()) {
@@ -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.');
}
}