Compare commits
13 Commits
9527147c32
...
v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| e24124b6d4 | |||
| 82892deafe | |||
| afbbd3fcde | |||
| 5a83263f7a | |||
| 4719cbab8b | |||
| 1175c20a57 | |||
| 9ce5b45b79 | |||
| 0be06694b2 | |||
|
|
a54c01453d | ||
| dcfbf20ab0 | |||
| e49a1e2ac9 | |||
|
|
dd0a458250 | ||
|
|
ffb64078d8 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dashboard/ticketing",
|
||||
"description": "Help desk ticketing snap-in for the Dashboard platform",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.4",
|
||||
"type": "library",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
@@ -19,6 +19,8 @@
|
||||
"providers": ["Dashboard\\Ticketing\\TicketingServiceProvider"]
|
||||
},
|
||||
"dashboard": {
|
||||
"tier": "optional",
|
||||
"stability": "beta",
|
||||
"nav_folder": {
|
||||
"label": "Help Desk",
|
||||
"icon": "ticket",
|
||||
@@ -34,6 +36,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
15
config/ticketing.php
Normal file
15
config/ticketing.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
* The filesystem disk ticket attachments are stored on. Read at runtime via
|
||||
* config('ticketing.storage_disk') — NOT env() — so it survives config:cache
|
||||
* in production. Before this file existed the controllers read
|
||||
* env('TICKETING_STORAGE_DISK') directly, which returns null once the config
|
||||
* is cached, silently forcing every attachment onto the 'local' disk and
|
||||
* orphaning anything an operator had pointed elsewhere (secrets spec S3).
|
||||
*/
|
||||
'storage_disk' => env('TICKETING_STORAGE_DISK', 'local'),
|
||||
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Groups can be retired without destroying ticket history.
|
||||
*
|
||||
* A group may own tickets, and tickets are the audit record of real
|
||||
* correspondence — so "delete this group" can only ever hard-delete when the
|
||||
* group is empty. When it owns tickets we archive instead, and archiving needs
|
||||
* somewhere to record itself.
|
||||
*
|
||||
* Expand-only: one nullable timestamp added to an existing table. Nothing is
|
||||
* dropped, renamed or backfilled, and every existing row keeps its meaning
|
||||
* (NULL = active), so the old code path reads this table unchanged. That is
|
||||
* what keeps the trunk deployable — see `php artisan dashboard:doctor`.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ticketing_groups', function (Blueprint $table) {
|
||||
$table->timestamp('archived_at')->nullable()->after('prefix');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Deliberate NO-OP, matching the warnings-column precedent in
|
||||
// dashboard-myedpuller-sync: DashboardDoctor greps the WHOLE migration file
|
||||
// for drop/rename patterns, so a dropColumn even in down() raises the
|
||||
// "pending destructive migration" advisory on every host where this is
|
||||
// still pending — a confusing warning for a purely additive change. A
|
||||
// nullable column costs nothing to leave behind.
|
||||
}
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<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">Submit a Ticket</h1>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap / No groups state -->
|
||||
<div v-if="isBootstrap" class="bg-amber-50 border border-amber-300 rounded-xl px-5 py-6 text-center">
|
||||
<p class="text-amber-800 font-semibold text-base mb-2">📦 Ticketing isn't set up yet</p>
|
||||
<p class="text-sm text-amber-700 mb-4">An admin needs to create at least one group before tickets can be submitted.</p>
|
||||
<Link v-if="isSiteAdmin" :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700">Go to Settings</Link>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
||||
<!-- Group -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Group <span class="text-red-500">*</span></label>
|
||||
<select
|
||||
v-model="form.group_id"
|
||||
required
|
||||
class="w-full border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Select a group</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||
</select>
|
||||
<p v-if="form.errors.group_id" class="text-xs text-red-600 mt-1">{{ form.errors.group_id }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Brief summary of the issue"
|
||||
class="w-full border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
required
|
||||
rows="5"
|
||||
placeholder="Describe the issue in detail..."
|
||||
class="w-full border-gray-300 rounded-lg text-sm"
|
||||
></textarea>
|
||||
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<select
|
||||
v-model="form.priority_id"
|
||||
class="w-full border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option :value="null">No priority</option>
|
||||
<option v-for="p in filteredPriorities" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Due Date <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||
<input
|
||||
v-model="form.due_date"
|
||||
type="date"
|
||||
class="w-full border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex justify-end pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="inline-flex items-center gap-2 bg-gray-900 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-60 transition"
|
||||
>
|
||||
<span v-if="form.processing">Submitting…</span>
|
||||
<span v-else>Submit Ticket</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
|
||||
const props = defineProps({
|
||||
groups: Array,
|
||||
priorities: Array,
|
||||
isBootstrap: Boolean,
|
||||
isSiteAdmin: Boolean,
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
group_id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
priority_id: null,
|
||||
due_date: '',
|
||||
})
|
||||
|
||||
const filteredPriorities = computed(() => {
|
||||
if (!form.group_id) return props.priorities.filter(p => !p.group_id)
|
||||
return props.priorities.filter(p => !p.group_id || p.group_id === Number(form.group_id))
|
||||
})
|
||||
|
||||
// Reset priority if it's no longer valid for the newly selected group
|
||||
watch(() => form.group_id, () => {
|
||||
const valid = filteredPriorities.value.some(p => p.id === form.priority_id)
|
||||
if (!valid) {
|
||||
form.priority_id = null
|
||||
}
|
||||
})
|
||||
|
||||
function submit() {
|
||||
form.post(route('ticketing.store'))
|
||||
}
|
||||
</script>
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<Link :href="route('ticketing.show', { ticket: ticket.id })" class="text-sm text-indigo-600 hover:underline">← Back to ticket</Link>
|
||||
<h1 class="text-xl font-bold text-gray-900 mt-2">
|
||||
Edit <span class="font-mono text-base">{{ ticket.number }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<input v-model="form.title" type="text" required class="w-full border-gray-300 rounded-lg text-sm" />
|
||||
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea v-model="form.description" rows="6" required class="w-full border-gray-300 rounded-lg text-sm"></textarea>
|
||||
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select v-model="form.status" class="w-full border-gray-300 rounded-lg text-sm">
|
||||
<option value="open">Open</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
|
||||
<select v-model="form.priority_id" class="w-full border-gray-300 rounded-lg text-sm">
|
||||
<option :value="null">No priority</option>
|
||||
<option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Assignee -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
|
||||
<select v-model="form.assigned_to" class="w-full border-gray-300 rounded-lg text-sm">
|
||||
<option :value="null">Unassigned</option>
|
||||
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Project</label>
|
||||
<select v-model="form.project_id" class="w-full border-gray-300 rounded-lg text-sm">
|
||||
<option :value="null">No project</option>
|
||||
<option v-for="proj in projects" :key="proj.id" :value="proj.id">{{ proj.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
|
||||
<input v-model="form.due_date" type="date" class="w-full border-gray-300 rounded-lg text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<Link
|
||||
:href="route('ticketing.show', { ticket: ticket.id })"
|
||||
class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>Cancel</Link>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="form.processing"
|
||||
class="px-5 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-60 transition"
|
||||
>
|
||||
{{ form.processing ? 'Saving…' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import { Link, useForm } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
ticket: Object,
|
||||
priorities: Array,
|
||||
agents: Array,
|
||||
projects: Array,
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
title: props.ticket.title,
|
||||
description: props.ticket.description,
|
||||
status: props.ticket.status,
|
||||
priority_id: props.ticket.priority_id,
|
||||
assigned_to: props.ticket.assigned_to,
|
||||
project_id: props.ticket.project_id,
|
||||
due_date: props.ticket.due_date,
|
||||
})
|
||||
|
||||
function submit() {
|
||||
form.put(route('ticketing.update', { ticket: props.ticket.id }), {
|
||||
onSuccess: () => {
|
||||
// redirect happens server side
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,798 +0,0 @@
|
||||
<template>
|
||||
<div class="flex 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">
|
||||
<div class="max-w-md">
|
||||
<div class="text-5xl mb-4">🎫</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-2">Ticketing isn’t set up yet</h2>
|
||||
<p class="text-gray-500 mb-6">Create your first group in Settings to get started. Default priorities will be seeded automatically.</p>
|
||||
<Link :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-700 transition">Go to Settings</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
|
||||
@click="showSidebar = false"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<Link
|
||||
:href="route('ticketing.create')"
|
||||
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700:bg-indigo-400"
|
||||
>
|
||||
New
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 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
|
||||
v-model="activeGroupId"
|
||||
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm shadow-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
|
||||
@change="applyFilters({ group_id: normalizeNullable(activeGroupId) })"
|
||||
>
|
||||
<option :value="null">All groups</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Views</h2>
|
||||
<span class="text-xs text-gray-400">{{ filteredTickets.length }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="view in visibleViews"
|
||||
:key="view.key"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-2xl px-3 py-3 text-sm transition"
|
||||
:class="currentFilter === view.key
|
||||
? 'bg-gray-900 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
||||
@click="applyFilters({ filter: view.key === 'all' ? 'all' : view.key })"
|
||||
>
|
||||
<span>{{ view.label }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="currentFilter === view.key ? 'bg-white/15 text-white' : 'bg-gray-200 text-gray-600'"
|
||||
>
|
||||
{{ viewCount(view.key) }}
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Priority</h2>
|
||||
<button
|
||||
v-if="filters.priority_id"
|
||||
type="button"
|
||||
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
|
||||
@click="applyFilters({ priority_id: undefined })"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="priority in priorities"
|
||||
:key="priority.id"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition"
|
||||
:class="Number(filters.priority_id) === priority.id ? 'shadow-sm' : 'border-gray-200 text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
||||
:style="priorityChipStyle(priority, Number(filters.priority_id) === priority.id)"
|
||||
@click="togglePriority(priority.id)"
|
||||
>
|
||||
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: priority.color || '#94a3b8' }"></span>
|
||||
{{ priority.name }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Projects</h2>
|
||||
<button
|
||||
v-if="filters.project_id"
|
||||
type="button"
|
||||
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
|
||||
@click="applyFilters({ project_id: undefined })"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
|
||||
:class="!filters.project_id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
||||
@click="applyFilters({ project_id: undefined })"
|
||||
>
|
||||
<span>All projects</span>
|
||||
<span class="text-xs text-gray-400">{{ totalProjectCount }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
|
||||
:class="Number(filters.project_id) === project.id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
|
||||
@click="applyFilters({ project_id: Number(filters.project_id) === project.id ? undefined : project.id })"
|
||||
>
|
||||
<span class="truncate">{{ project.name }}</span>
|
||||
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">{{ project.ticket_count ?? 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
class="min-w-0 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">
|
||||
<div class="border-b border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Queue</p>
|
||||
<h2 class="mt-1 text-xl font-semibold tracking-tight">{{ currentViewTitle }}</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
|
||||
@click="showSidebar = true"
|
||||
>
|
||||
Filters
|
||||
</button>
|
||||
<Link
|
||||
:href="route('ticketing.create')"
|
||||
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700 lg:hidden"
|
||||
>
|
||||
New
|
||||
</Link>
|
||||
<div class="hidden rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-500 sm:block">
|
||||
{{ tickets.total }} total
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-gray-500 sm:hidden">
|
||||
{{ tickets.total }} total
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search tickets, people, or numbers"
|
||||
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
v-model="sortBy"
|
||||
class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
|
||||
>
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="priority">Priority</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div v-if="filteredTickets.length === 0" class="flex h-full flex-col items-center justify-center px-6 text-center">
|
||||
<div class="rounded-3xl border border-dashed border-gray-300 px-8 py-10">
|
||||
<p class="text-sm font-medium text-gray-500">Nothing matches the current filters.</p>
|
||||
<p class="mt-2 text-sm text-gray-400">Try a different view, search, or priority chip.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
type="button"
|
||||
class="flex w-full gap-4 border-b border-gray-100 px-4 py-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/60 sm:px-5"
|
||||
:class="selectedTicketId === ticket.id ? 'bg-indigo-50/80' : ''"
|
||||
@click="selectTicket(ticket.id)"
|
||||
>
|
||||
<div class="w-1 self-stretch rounded-full" :style="{ backgroundColor: ticket.priority?.color || ticket.group?.color || '#cbd5e1' }"></div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-gray-400">{{ ticket.number }}</span>
|
||||
<span :class="badgeClass(ticket.status)" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold">{{ statusLabel(ticket.status) }}</span>
|
||||
<span v-if="hasUnread(ticket)" class="h-2.5 w-2.5 rounded-full bg-sky-500"></span>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 truncate text-sm font-semibold text-gray-900">{{ ticket.title }}</p>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3 text-xs text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="avatar-ring bg-gray-200 text-gray-700">{{ initials(ticket.submitter?.name || ticket.submitter?.email || '?') }}</span>
|
||||
<span class="truncate">{{ ticket.submitter?.name || ticket.submitter?.email || 'Unknown' }}</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>{{ ageLabel(ticket.created_at) }}</span>
|
||||
</div>
|
||||
</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="flex items-center gap-2">
|
||||
<span class="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'"
|
||||
>
|
||||
{{ ticket.assignee ? initials(ticket.assignee.name || ticket.assignee.email) : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tickets.last_page > 1" class="flex items-center justify-between border-t border-gray-200 px-5 py-3 text-sm">
|
||||
<span class="text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Link
|
||||
v-if="tickets.prev_page_url"
|
||||
:href="tickets.prev_page_url"
|
||||
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
|
||||
>Prev</Link>
|
||||
<Link
|
||||
v-if="tickets.next_page_url"
|
||||
:href="tickets.next_page_url"
|
||||
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
|
||||
>Next</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section
|
||||
class="min-w-0 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">
|
||||
<div class="max-w-md rounded-3xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">Conversation</p>
|
||||
<h3 class="mt-3 text-2xl font-semibold tracking-tight">Pick a ticket</h3>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-500">
|
||||
The right panel becomes the full thread view: metadata, internal notes, and reply composer included. Miracles do happen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="border-b border-gray-200 bg-white px-4 py-5 sm:px-6">
|
||||
<div class="mb-4 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100"
|
||||
@click="backToList"
|
||||
>
|
||||
← Back to list
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold text-white" :style="{ backgroundColor: ticketDetail.group?.color || '#6366f1' }">{{ ticketDetail.number }}</span>
|
||||
<span :class="badgeClass(ticketDetail.status)" class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold">{{ statusLabel(ticketDetail.status) }}</span>
|
||||
<span v-if="ticketDetail.priority" class="inline-flex items-center gap-2 rounded-full border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-600">
|
||||
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: ticketDetail.priority.color || '#94a3b8' }"></span>
|
||||
{{ ticketDetail.priority.name }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-5">
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Assignee</div>
|
||||
<div class="mt-2 flex items-center gap-2 text-sm font-medium">
|
||||
<span class="avatar-ring bg-indigo-100 text-indigo-700">{{ ticketDetail.assignee ? initials(ticketDetail.assignee.name || ticketDetail.assignee.email) : '—' }}</span>
|
||||
<span class="truncate">{{ ticketDetail.assignee?.name || ticketDetail.assignee?.email || 'Unassigned' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Due date</div>
|
||||
<div class="mt-2 text-sm font-medium">{{ formatDate(ticketDetail.due_date, 'No due date') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Project</div>
|
||||
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.project?.name || 'No project' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Submitter</div>
|
||||
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.submitter?.name || ticketDetail.submitter?.email || 'Unknown' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Opened</div>
|
||||
<div class="mt-2 text-sm font-medium">{{ ageLabel(ticketDetail.created_at) }}</div>
|
||||
</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>
|
||||
<span>{{ ticketDetail.messages?.length || 0 }} messages</span>
|
||||
<span>•</span>
|
||||
<span>{{ ticketDetail.attachments?.length || 0 }} attachments</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isAgent" class="mt-5 grid gap-3 lg:grid-cols-4">
|
||||
<select v-model="metaForm.status" class="meta-select" @change="saveMeta">
|
||||
<option v-for="status in statuses" :key="status.value" :value="status.value">{{ status.label }}</option>
|
||||
</select>
|
||||
|
||||
<select v-model="metaForm.priority_id" class="meta-select" @change="saveMeta">
|
||||
<option :value="null">No priority</option>
|
||||
<option v-for="priority in priorities" :key="priority.id" :value="priority.id">{{ priority.name }}</option>
|
||||
</select>
|
||||
|
||||
<select v-model="metaForm.assigned_to" class="meta-select" @change="saveMeta">
|
||||
<option :value="null">Unassigned</option>
|
||||
<option v-for="agent in detailAgents" :key="agent.id" :value="agent.id">{{ agent.name || agent.email }}</option>
|
||||
</select>
|
||||
|
||||
<input v-model="metaForm.due_date" type="date" class="meta-select" @change="saveMeta" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
|
||||
<div v-if="ticketDetail.description" class="mb-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div class="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Description</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-6 text-gray-700">{{ ticketDetail.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!ticketDetail.messages?.length" class="rounded-3xl border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500">
|
||||
No messages yet.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="message in ticketDetail.messages"
|
||||
:key="message.id"
|
||||
class="flex"
|
||||
:class="messageAlignment(message)"
|
||||
>
|
||||
<div class="max-w-3xl rounded-3xl px-5 py-4 shadow-sm" :class="messageCardClass(message)">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs font-medium opacity-80">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="avatar-ring bg-white/70 text-gray-700">{{ initials(messageAuthor(message)) }}</span>
|
||||
{{ messageRoleLabel(message) }}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{{ message.author?.name || message.author_email || 'Unknown' }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ preciseDate(message.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-6">{{ message.body }}</p>
|
||||
|
||||
<div v-if="message.attachments?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<a
|
||||
v-for="attachment in message.attachments"
|
||||
:key="attachment.id"
|
||||
:href="route('ticketing.attachments.show', { attachment: attachment.id })"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-current/20 px-3 py-1.5 text-xs font-medium transition hover:bg-white/30:bg-gray-900/30"
|
||||
target="_blank"
|
||||
>
|
||||
📎 {{ attachment.filename }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 bg-white px-4 py-5 sm:px-6">
|
||||
<div v-if="isAgent" class="mb-4 inline-flex rounded-2xl bg-gray-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
|
||||
:class="replyMode === 'reply' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
|
||||
@click="replyMode = 'reply'"
|
||||
>Reply</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
|
||||
:class="replyMode === 'internal' ? 'bg-amber-500 text-white shadow-sm' : 'text-gray-500'"
|
||||
@click="replyMode = 'internal'"
|
||||
>Internal note</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="sendMessage" class="space-y-3">
|
||||
<textarea
|
||||
v-model="messageForm.body"
|
||||
rows="4"
|
||||
required
|
||||
class="w-full rounded-3xl border px-4 py-3 text-sm outline-none transition focus:ring-2"
|
||||
:class="replyMode === 'internal'
|
||||
? 'border-amber-300 bg-amber-50 text-amber-950 focus:border-amber-400 focus:ring-amber-100:ring-amber-500/20'
|
||||
: 'border-gray-200 bg-gray-50 text-gray-900 focus:border-indigo-400 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20'"
|
||||
:placeholder="replyMode === 'internal' ? 'Internal note for agents only…' : 'Reply to the thread…'"
|
||||
></textarea>
|
||||
|
||||
<div class="flex 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>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="messageForm.processing"
|
||||
class="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-medium text-white transition disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="replyMode === 'internal' ? 'bg-amber-500 hover:bg-amber-600' : 'bg-gray-900 hover:bg-gray-700:bg-indigo-400'"
|
||||
>
|
||||
{{ messageForm.processing ? 'Sending…' : replyMode === 'internal' ? 'Save note' : 'Send reply' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
tickets: Object,
|
||||
groups: Array,
|
||||
priorities: Array,
|
||||
projects: Array,
|
||||
isAgent: Boolean,
|
||||
filters: Object,
|
||||
ticketDetail: Object,
|
||||
detailAgents: Array,
|
||||
viewCounts: Object,
|
||||
isBootstrap: Boolean,
|
||||
})
|
||||
|
||||
const search = ref('')
|
||||
const sortBy = ref('newest')
|
||||
const activeGroupId = ref(props.filters?.group_id ? Number(props.filters.group_id) : null)
|
||||
const replyMode = ref('reply')
|
||||
const showSidebar = ref(!props.ticketDetail)
|
||||
|
||||
const visibleViews = computed(() => {
|
||||
const base = [
|
||||
{ key: 'all', label: 'All Open' },
|
||||
{ key: 'mine', label: 'Mine' },
|
||||
{ key: 'unassigned', label: 'Unassigned' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'resolved', label: 'Resolved' },
|
||||
]
|
||||
|
||||
return props.isAgent ? base : base.filter((view) => ['all', 'pending', 'resolved'].includes(view.key))
|
||||
})
|
||||
|
||||
const currentFilter = computed(() => props.filters?.filter || 'all')
|
||||
const selectedTicketId = computed(() => props.ticketDetail?.id ?? (props.filters?.detail ? Number(props.filters.detail) : null))
|
||||
const currentViewTitle = computed(() => visibleViews.value.find((view) => view.key === currentFilter.value)?.label || 'Tickets')
|
||||
const totalProjectCount = computed(() => props.projects.reduce((sum, project) => sum + Number(project.ticket_count || 0), 0))
|
||||
const showList = computed(() => !props.ticketDetail)
|
||||
const showDetail = computed(() => !!props.ticketDetail)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const priorityWeight = Object.fromEntries(
|
||||
[...props.priorities].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map((priority, index, arr) => [priority.id, arr.length - index])
|
||||
)
|
||||
|
||||
let items = [...(props.tickets?.data || [])]
|
||||
|
||||
if (term) {
|
||||
items = items.filter((ticket) => {
|
||||
const haystack = [
|
||||
ticket.number,
|
||||
ticket.title,
|
||||
ticket.status,
|
||||
ticket.submitter?.name,
|
||||
ticket.submitter?.email,
|
||||
ticket.assignee?.name,
|
||||
ticket.assignee?.email,
|
||||
ticket.priority?.name,
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
|
||||
return haystack.includes(term)
|
||||
})
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
if (sortBy.value === 'oldest') {
|
||||
return new Date(a.created_at) - new Date(b.created_at)
|
||||
}
|
||||
|
||||
if (sortBy.value === 'priority') {
|
||||
return (priorityWeight[b.priority_id] || 0) - (priorityWeight[a.priority_id] || 0) || new Date(b.created_at) - new Date(a.created_at)
|
||||
}
|
||||
|
||||
if (sortBy.value === 'status') {
|
||||
return statusSortValue(a.status) - statusSortValue(b.status) || new Date(b.created_at) - new Date(a.created_at)
|
||||
}
|
||||
|
||||
return new Date(b.created_at) - new Date(a.created_at)
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const metaForm = useForm({
|
||||
status: props.ticketDetail?.status ?? 'open',
|
||||
priority_id: props.ticketDetail?.priority_id ?? null,
|
||||
assigned_to: props.ticketDetail?.assigned_to ?? null,
|
||||
due_date: props.ticketDetail?.due_date ?? null,
|
||||
})
|
||||
|
||||
const messageForm = useForm({
|
||||
body: '',
|
||||
is_internal: false,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.ticketDetail,
|
||||
(detail) => {
|
||||
replyMode.value = 'reply'
|
||||
showSidebar.value = !detail
|
||||
metaForm.defaults({
|
||||
status: detail?.status ?? 'open',
|
||||
priority_id: detail?.priority_id ?? null,
|
||||
assigned_to: detail?.assigned_to ?? null,
|
||||
due_date: detail?.due_date ?? null,
|
||||
})
|
||||
metaForm.reset()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const statuses = [
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
]
|
||||
|
||||
function normalizeNullable(value) {
|
||||
return value === '' || value === null || value === undefined ? undefined : Number(value)
|
||||
}
|
||||
|
||||
function buildParams(overrides = {}) {
|
||||
const params = {
|
||||
...props.filters,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (params[key] === undefined || params[key] === null || params[key] === '') {
|
||||
delete params[key]
|
||||
}
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
function applyFilters(overrides = {}) {
|
||||
const params = buildParams({ ...overrides, detail: overrides.detail === undefined ? props.filters?.detail : overrides.detail })
|
||||
|
||||
router.get(route('ticketing.index'), params, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
function togglePriority(priorityId) {
|
||||
applyFilters({ priority_id: Number(props.filters.priority_id) === priorityId ? undefined : priorityId })
|
||||
}
|
||||
|
||||
function selectTicket(ticketId) {
|
||||
router.get(route('ticketing.index'), buildParams({ detail: ticketId }), {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
only: ['ticketDetail', 'detailAgents', 'filters'],
|
||||
})
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
router.get(route('ticketing.index'), buildParams({ detail: undefined }), {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
only: ['ticketDetail', 'detailAgents', 'filters'],
|
||||
})
|
||||
}
|
||||
|
||||
function saveMeta() {
|
||||
if (!props.ticketDetail) return
|
||||
|
||||
metaForm.transform((data) => ({
|
||||
...data,
|
||||
priority_id: normalizeNullable(data.priority_id),
|
||||
assigned_to: normalizeNullable(data.assigned_to),
|
||||
due_date: data.due_date || null,
|
||||
})).put(route('ticketing.update', { ticket: props.ticketDetail.id }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
if (!props.ticketDetail) return
|
||||
|
||||
messageForm.transform((data) => ({
|
||||
...data,
|
||||
is_internal: props.isAgent && replyMode.value === 'internal',
|
||||
})).post(route('ticketing.messages.store', { ticket: props.ticketDetail.id }), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
messageForm.reset()
|
||||
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function viewCount(key) {
|
||||
return props.viewCounts?.[key] ?? 0
|
||||
}
|
||||
|
||||
function initials(value) {
|
||||
return String(value || '?')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function badgeClass(status) {
|
||||
return {
|
||||
open: 'bg-sky-100 text-sky-700',
|
||||
in_progress: 'bg-violet-100 text-violet-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
resolved: 'bg-emerald-100 text-emerald-700',
|
||||
closed: 'bg-gray-200 text-gray-700',
|
||||
}[status] || 'bg-gray-200 text-gray-700'
|
||||
}
|
||||
|
||||
function priorityChipStyle(priority, active) {
|
||||
const color = priority.color || '#94a3b8'
|
||||
return active
|
||||
? { borderColor: color, backgroundColor: `${color}18`, color }
|
||||
: {}
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return {
|
||||
open: 'Open',
|
||||
in_progress: 'In Progress',
|
||||
pending: 'Pending',
|
||||
resolved: 'Resolved',
|
||||
closed: 'Closed',
|
||||
}[status] || status
|
||||
}
|
||||
|
||||
function statusSortValue(status) {
|
||||
return {
|
||||
open: 1,
|
||||
in_progress: 2,
|
||||
pending: 3,
|
||||
resolved: 4,
|
||||
closed: 5,
|
||||
}[status] || 99
|
||||
}
|
||||
|
||||
function ageLabel(value) {
|
||||
return relativeTime(value)
|
||||
}
|
||||
|
||||
function preciseDate(value) {
|
||||
if (!value) return 'Unknown time'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(value, fallback = '—') {
|
||||
if (!value) return fallback
|
||||
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function relativeTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
const diff = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000))
|
||||
if (diff < 60) return `${diff}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
return `${Math.floor(diff / 604800)}w ago`
|
||||
}
|
||||
|
||||
function hasUnread(ticket) {
|
||||
if (!ticket || !props.ticketDetail) return false
|
||||
return ticket.id !== props.ticketDetail.id && ['open', 'pending'].includes(ticket.status)
|
||||
}
|
||||
|
||||
function messageAlignment(message) {
|
||||
if (message.is_internal) return 'justify-center'
|
||||
return isSubmitterMessage(message) ? 'justify-start' : 'justify-end'
|
||||
}
|
||||
|
||||
function messageCardClass(message) {
|
||||
if (message.is_internal) {
|
||||
return 'border border-amber-200 bg-amber-50 text-amber-950'
|
||||
}
|
||||
|
||||
if (isSubmitterMessage(message)) {
|
||||
return 'border border-gray-200 bg-white text-gray-800'
|
||||
}
|
||||
|
||||
return 'bg-gray-900 text-white'
|
||||
}
|
||||
|
||||
function isSubmitterMessage(message) {
|
||||
return Number(message.user_id) === Number(props.ticketDetail?.submitter_id)
|
||||
}
|
||||
|
||||
function messageRoleLabel(message) {
|
||||
if (message.is_internal) return 'Internal note'
|
||||
return isSubmitterMessage(message) ? 'Submitter' : 'Agent reply'
|
||||
}
|
||||
|
||||
function messageAuthor(message) {
|
||||
return message.author?.name || message.author?.email || message.author_email || 'Unknown'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-ring {
|
||||
@apply inline-flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-semibold;
|
||||
}
|
||||
|
||||
.meta-select {
|
||||
@apply w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 focus:ring-indigo-500/20;
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">My Tickets</h1>
|
||||
<Link :href="route('ticketing.create')" class="inline-flex items-center gap-1 bg-gray-900 text-white text-sm px-4 py-2 rounded-lg hover:bg-gray-700 transition">
|
||||
+ Submit Ticket
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 divide-y divide-gray-100">
|
||||
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 text-sm">
|
||||
You haven't submitted any tickets yet.
|
||||
</div>
|
||||
<Link
|
||||
v-for="ticket in tickets.data"
|
||||
:key="ticket.id"
|
||||
:href="route('ticketing.show', { ticket: ticket.id })"
|
||||
class="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition"
|
||||
>
|
||||
<span
|
||||
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0 mt-0.5"
|
||||
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
||||
>{{ ticket.number }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800 truncate">{{ ticket.title }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ ticket.group?.name }} · {{ timeAgo(ticket.created_at) }}</p>
|
||||
</div>
|
||||
<span :class="statusClass(ticket.status)" class="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0">
|
||||
{{ statusLabel(ticket.status) }}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="tickets.last_page > 1" class="mt-4 flex justify-center gap-4 text-sm">
|
||||
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-indigo-600 hover:underline">← Previous</Link>
|
||||
<span class="text-gray-500">{{ tickets.current_page }} / {{ tickets.last_page }}</span>
|
||||
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-indigo-600 hover:underline">Next →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
|
||||
defineProps({ tickets: Object })
|
||||
|
||||
function statusLabel(status) {
|
||||
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const map = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-purple-100 text-purple-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - date) / 1000)
|
||||
if (diff < 60) return `${diff}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
</script>
|
||||
@@ -1,560 +0,0 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="isBootstrap" class="mb-6 px-5 py-4 bg-amber-50 border border-amber-300 rounded-xl">
|
||||
<h2 class="text-base font-semibold text-amber-800 mb-1">🚀 First-Run Setup</h2>
|
||||
<p class="text-sm text-amber-700">
|
||||
No groups exist yet. Create your first group below to get started.
|
||||
Default priorities (Low, Medium, High, Urgent) will be seeded automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.flash?.success" class="mb-4 px-4 py-2 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
|
||||
{{ $page.props.flash.success }}
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.errors?.priority" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{{ $page.props.errors.priority }}
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.errors?.project" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{{ $page.props.errors.project }}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 border-b border-gray-200 mb-6">
|
||||
<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',
|
||||
activeTab === tab.key
|
||||
? 'border-indigo-600 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
]"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'groups'">
|
||||
<div class="flex justify-between items-center 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' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Prefix (e.g. IT)</label>
|
||||
<input v-model="groupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
|
||||
<input v-model="groupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-if="groups.length === 0" class="text-sm text-gray-400 italic">No groups yet.</div>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-xl"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: group.color }"></span>
|
||||
<div>
|
||||
<p class="font-medium text-sm text-gray-800">{{ group.name }}</p>
|
||||
<p class="text-xs text-gray-400">{{ group.prefix }} · {{ group.email_address || 'No email' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="startEditGroup(group)" class="text-xs text-indigo-600 hover:underline">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Prefix</label>
|
||||
<input v-model="editGroupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
|
||||
<input v-model="editGroupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'agents'">
|
||||
<div class="flex justify-between items-center 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' }}
|
||||
</button>
|
||||
</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">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Group</label>
|
||||
<select v-model="agentForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
|
||||
<option value="">Select group</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Role</label>
|
||||
<select v-model="agentForm.role" class="w-full text-sm border-gray-300 rounded-lg">
|
||||
<option value="agent">Agent</option>
|
||||
<option value="manager">Manager</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-if="agents.length === 0" class="text-sm text-gray-400 italic">No agents configured yet.</div>
|
||||
<div
|
||||
v-for="access in agents"
|
||||
:key="access.id"
|
||||
class="flex items-center justify-between px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">{{ access.user?.name || 'User #' + access.user_id }}</p>
|
||||
<p class="text-xs text-gray-400">{{ access.user?.email }} · {{ access.group?.name || 'Unknown Group' }} · <span class="capitalize">{{ access.role }}</span></p>
|
||||
</div>
|
||||
<button @click="openRemoveAgentModal(access)" class="text-xs text-red-500 hover:underline">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'priorities'">
|
||||
<div class="flex justify-between items-center 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' }}
|
||||
</button>
|
||||
</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">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Sort Order</label>
|
||||
<input v-model="priorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Group (blank = global)</label>
|
||||
<select v-model="priorityForm.group_id" class="w-full text-sm border-gray-300 rounded-lg">
|
||||
<option :value="null">Global</option>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-if="priorities.length === 0" class="text-sm text-gray-400 italic">No priorities defined yet.</div>
|
||||
<div
|
||||
v-for="p in priorities"
|
||||
:key="p.id"
|
||||
class="flex items-center gap-3 px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
|
||||
>
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: p.color }"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-800">{{ p.name }}</p>
|
||||
<p v-if="p.description" class="text-xs text-gray-400">{{ p.description }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ p.group_id ? 'Group-specific' : 'Global' }}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="startEditPriority(p)" class="text-xs text-indigo-600 hover:underline">Edit</button>
|
||||
<button @click="openDeletePriorityModal(p)" class="text-xs text-red-500 hover:underline">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Sort Order</label>
|
||||
<input v-model="editPriorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'projects'">
|
||||
<div class="flex justify-between items-center 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' }}
|
||||
</button>
|
||||
</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">
|
||||
<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">
|
||||
<option value="">Select group</option>
|
||||
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Status</label>
|
||||
<select v-model="projectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
|
||||
<option value="active">Active</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-if="projects.length === 0" class="text-sm text-gray-400 italic">No projects defined yet.</div>
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-xl"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-800">{{ project.name }}</p>
|
||||
<p class="text-xs text-gray-400">{{ groups.find(g => g.id === project.group_id)?.name || 'Unknown Group' }} · {{ project.status }}</p>
|
||||
<p v-if="project.description" class="text-xs text-gray-500 mt-1">{{ project.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="startEditProject(project)" class="text-xs text-indigo-600 hover:underline">Edit</button>
|
||||
<button @click="openDeleteProjectModal(project)" class="text-xs text-red-500 hover:underline">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Status</label>
|
||||
<select v-model="editProjectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
|
||||
<option value="active">Active</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="removeAgentModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Remove agent?</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Remove <strong>{{ pendingAgentRemoval?.user?.name || ('User #' + pendingAgentRemoval?.user_id) }}</strong>
|
||||
from <strong>{{ pendingAgentRemoval?.group?.name }}</strong>?
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button type="button" @click="closeRemoveAgentModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
||||
<button type="button" @click="confirmRemoveAgent" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deletePriorityModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Delete priority?</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Delete <strong>{{ pendingPriorityDelete?.name }}</strong>? This cannot be undone.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button type="button" @click="closeDeletePriorityModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
||||
<button type="button" @click="confirmDeletePriority" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="deleteProjectModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Delete project?</h3>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Delete <strong>{{ pendingProjectDelete?.name }}</strong>? This cannot be undone.
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button type="button" @click="closeDeleteProjectModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
|
||||
<button type="button" @click="confirmDeleteProject" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Link, useForm, router } from '@inertiajs/vue3'
|
||||
|
||||
const props = defineProps({
|
||||
groups: Array,
|
||||
agents: Array,
|
||||
priorities: Array,
|
||||
projects: Array,
|
||||
myGroupIds: Array,
|
||||
isBootstrap: Boolean,
|
||||
isSiteAdmin: Boolean,
|
||||
})
|
||||
|
||||
const activeTab = ref('groups')
|
||||
const tabs = [
|
||||
{ key: 'groups', label: 'Groups' },
|
||||
{ key: 'agents', label: 'Agents' },
|
||||
{ key: 'priorities', label: 'Priorities' },
|
||||
{ key: 'projects', label: 'Projects' },
|
||||
]
|
||||
|
||||
const showAddGroup = ref(false)
|
||||
const showAddAgent = ref(false)
|
||||
const showAddPriority = ref(false)
|
||||
const showAddProject = ref(false)
|
||||
const editingGroup = ref(null)
|
||||
const editingPriority = ref(null)
|
||||
const editingProject = ref(null)
|
||||
const removeAgentModalOpen = ref(false)
|
||||
const pendingAgentRemoval = ref(null)
|
||||
const deletePriorityModalOpen = ref(false)
|
||||
const pendingPriorityDelete = ref(null)
|
||||
const deleteProjectModalOpen = ref(false)
|
||||
const pendingProjectDelete = ref(null)
|
||||
|
||||
const groupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
|
||||
const editGroupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
|
||||
const agentForm = useForm({ user_id: '', group_id: '', role: 'agent' })
|
||||
const priorityForm = useForm({ name: '', color: '#6b7280', description: '', sort_order: 0, group_id: null })
|
||||
const editPriorityForm = useForm({ name: '', color: '#6b7280', description: '', sort_order: 0, group_id: null })
|
||||
const projectForm = useForm({ group_id: '', name: '', description: '', status: 'active' })
|
||||
const editProjectForm = useForm({ name: '', description: '', status: 'active' })
|
||||
|
||||
function submitGroup() {
|
||||
groupForm.post(route('ticketing.settings.groups.store'), {
|
||||
onSuccess: () => { showAddGroup.value = false; groupForm.reset() }
|
||||
})
|
||||
}
|
||||
|
||||
function startEditGroup(group) {
|
||||
editingGroup.value = group
|
||||
editGroupForm.name = group.name
|
||||
editGroupForm.email_address = group.email_address || ''
|
||||
editGroupForm.color = group.color
|
||||
editGroupForm.prefix = group.prefix
|
||||
}
|
||||
|
||||
function submitEditGroup() {
|
||||
editGroupForm.put(route('ticketing.settings.groups.update', { group: editingGroup.value.id }), {
|
||||
onSuccess: () => { editingGroup.value = null }
|
||||
})
|
||||
}
|
||||
|
||||
function submitAgent() {
|
||||
agentForm.post(route('ticketing.settings.agents.store'), {
|
||||
onSuccess: () => { showAddAgent.value = false; agentForm.reset() }
|
||||
})
|
||||
}
|
||||
|
||||
function openRemoveAgentModal(access) {
|
||||
pendingAgentRemoval.value = access
|
||||
removeAgentModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeRemoveAgentModal() {
|
||||
removeAgentModalOpen.value = false
|
||||
pendingAgentRemoval.value = null
|
||||
}
|
||||
|
||||
function confirmRemoveAgent() {
|
||||
if (!pendingAgentRemoval.value) return
|
||||
|
||||
router.delete(route('ticketing.settings.agents.destroy', { access: pendingAgentRemoval.value.id }), {
|
||||
onFinish: closeRemoveAgentModal,
|
||||
})
|
||||
}
|
||||
|
||||
function submitPriority() {
|
||||
priorityForm.post(route('ticketing.settings.priorities.store'), {
|
||||
onSuccess: () => { showAddPriority.value = false; priorityForm.reset() }
|
||||
})
|
||||
}
|
||||
|
||||
function startEditPriority(priority) {
|
||||
editingPriority.value = priority
|
||||
editPriorityForm.name = priority.name
|
||||
editPriorityForm.color = priority.color
|
||||
editPriorityForm.description = priority.description || ''
|
||||
editPriorityForm.sort_order = priority.sort_order
|
||||
editPriorityForm.group_id = priority.group_id
|
||||
}
|
||||
|
||||
function submitEditPriority() {
|
||||
editPriorityForm.put(route('ticketing.settings.priorities.update', { priority: editingPriority.value.id }), {
|
||||
onSuccess: () => { editingPriority.value = null }
|
||||
})
|
||||
}
|
||||
|
||||
function openDeletePriorityModal(priority) {
|
||||
pendingPriorityDelete.value = priority
|
||||
deletePriorityModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeletePriorityModal() {
|
||||
deletePriorityModalOpen.value = false
|
||||
pendingPriorityDelete.value = null
|
||||
}
|
||||
|
||||
function confirmDeletePriority() {
|
||||
if (!pendingPriorityDelete.value) return
|
||||
|
||||
router.delete(route('ticketing.settings.priorities.destroy', { priority: pendingPriorityDelete.value.id }), {
|
||||
onFinish: closeDeletePriorityModal,
|
||||
})
|
||||
}
|
||||
|
||||
function submitProject() {
|
||||
projectForm.post(route('ticketing.settings.projects.store'), {
|
||||
onSuccess: () => { showAddProject.value = false; projectForm.reset() }
|
||||
})
|
||||
}
|
||||
|
||||
function startEditProject(project) {
|
||||
editingProject.value = project
|
||||
editProjectForm.name = project.name
|
||||
editProjectForm.description = project.description || ''
|
||||
editProjectForm.status = project.status
|
||||
}
|
||||
|
||||
function submitEditProject() {
|
||||
editProjectForm.put(route('ticketing.settings.projects.update', { project: editingProject.value.id }), {
|
||||
onSuccess: () => { editingProject.value = null }
|
||||
})
|
||||
}
|
||||
|
||||
function openDeleteProjectModal(project) {
|
||||
pendingProjectDelete.value = project
|
||||
deleteProjectModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeleteProjectModal() {
|
||||
deleteProjectModalOpen.value = false
|
||||
pendingProjectDelete.value = null
|
||||
}
|
||||
|
||||
function confirmDeleteProject() {
|
||||
if (!pendingProjectDelete.value) return
|
||||
|
||||
router.delete(route('ticketing.settings.projects.destroy', { project: pendingProjectDelete.value.id }), {
|
||||
onFinish: closeDeleteProjectModal,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,314 +0,0 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<!-- Ticket Header -->
|
||||
<div class="p-5 border-b border-gray-200">
|
||||
<div class="flex items-start gap-3 flex-wrap">
|
||||
<span
|
||||
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
|
||||
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
||||
>
|
||||
{{ ticket.number }}
|
||||
</span>
|
||||
|
||||
<!-- Title (editable inline for agents) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="titleEdit"
|
||||
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent focus:outline-none"
|
||||
@keyup.enter="saveTitle"
|
||||
@keyup.esc="editingTitle = false"
|
||||
/>
|
||||
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
|
||||
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
|
||||
</div>
|
||||
<h1
|
||||
v-else
|
||||
class="text-xl font-semibold text-gray-900"
|
||||
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
|
||||
@click="isAgent && startEditTitle()"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
<span v-if="isAgent" class="ml-1 text-xs text-gray-400">✏️</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="flex flex-wrap items-center gap-3 mt-3">
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<select
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.status"
|
||||
@change="saveMeta"
|
||||
class="text-xs border-0 rounded-full px-3 py-1 font-medium cursor-pointer"
|
||||
:class="statusClass(metaForm.status)"
|
||||
>
|
||||
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
<span v-else :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-1 rounded-full font-medium">
|
||||
{{ statusLabel(ticket.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<select
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.priority_id"
|
||||
@change="saveMeta"
|
||||
class="text-xs border border-gray-200 rounded-full px-3 py-1"
|
||||
>
|
||||
<option :value="null">No priority</option>
|
||||
<option v-for="p in priorities" :key="p.id" :value="p.id">
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600">
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
|
||||
{{ ticket.priority.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Assignee -->
|
||||
<div v-if="isAgent">
|
||||
<select
|
||||
v-model="metaForm.assigned_to"
|
||||
@change="saveMeta"
|
||||
class="text-xs border border-gray-200 rounded-full px-3 py-1"
|
||||
>
|
||||
<option :value="null">Unassigned</option>
|
||||
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<span>📅</span>
|
||||
<input
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.due_date"
|
||||
type="date"
|
||||
@change="saveMeta"
|
||||
class="text-xs border-0 bg-transparent cursor-pointer p-0"
|
||||
/>
|
||||
<span v-else>{{ ticket.due_date || 'No due date' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Edit / Delete actions -->
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Link
|
||||
v-if="isAgent"
|
||||
:href="route('ticketing.edit', { ticket: ticket.id })"
|
||||
class="text-xs text-indigo-600 hover:underline"
|
||||
>Edit</Link>
|
||||
<button
|
||||
v-if="isManager"
|
||||
@click="confirmDelete"
|
||||
class="text-xs text-red-500 hover:underline"
|
||||
>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Thread -->
|
||||
<div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
<div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
|
||||
No messages yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="msg in ticket.messages"
|
||||
:key="msg.id"
|
||||
:class="['flex', isOwnMessage(msg) ? 'justify-end' : 'justify-start']"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
|
||||
msg.is_internal
|
||||
? 'bg-amber-50 border border-amber-200 text-amber-900'
|
||||
: isOwnMessage(msg)
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
|
||||
<span v-if="msg.is_internal">🔒 Internal Note · </span>
|
||||
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
|
||||
<span>· {{ timeAgo(msg.created_at) }}</span>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reply Area -->
|
||||
<div class="border-t border-gray-200 p-5">
|
||||
<!-- Tab switcher (agents only) -->
|
||||
<div v-if="isAgent" class="flex gap-2 mb-3">
|
||||
<button
|
||||
@click="replyTab = 'reply'"
|
||||
:class="[
|
||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>Reply to submitter</button>
|
||||
<button
|
||||
@click="replyTab = 'internal'"
|
||||
:class="[
|
||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>🔒 Internal Note</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="sendMessage">
|
||||
<textarea
|
||||
v-model="messageForm.body"
|
||||
required
|
||||
rows="3"
|
||||
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
|
||||
:class="[
|
||||
'w-full rounded-lg text-sm border',
|
||||
replyTab === 'internal'
|
||||
? 'border-amber-300 bg-amber-50'
|
||||
: 'border-gray-300'
|
||||
]"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
|
||||
<input type="file" class="hidden" @change="attachFile" />
|
||||
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="messageForm.processing"
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
|
||||
replyTab === 'internal'
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
|
||||
: 'bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-60'
|
||||
]"
|
||||
>
|
||||
{{ messageForm.processing ? 'Sending…' : 'Send' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Link, useForm, router } from '@inertiajs/vue3'
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
|
||||
const props = defineProps({
|
||||
ticket: Object,
|
||||
isAgent: Boolean,
|
||||
isManager: Boolean,
|
||||
agents: Array,
|
||||
priorities: Array,
|
||||
})
|
||||
|
||||
const replyTab = ref('reply')
|
||||
const editingTitle = ref(false)
|
||||
const titleEdit = ref(props.ticket.title)
|
||||
|
||||
const statuses = [
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'resolved', label: 'Resolved' },
|
||||
{ value: 'closed', label: 'Closed' },
|
||||
]
|
||||
|
||||
const metaForm = useForm({
|
||||
status: props.ticket.status,
|
||||
priority_id: props.ticket.priority_id,
|
||||
assigned_to: props.ticket.assigned_to,
|
||||
due_date: props.ticket.due_date,
|
||||
})
|
||||
|
||||
const messageForm = useForm({
|
||||
body: '',
|
||||
is_internal: false,
|
||||
})
|
||||
|
||||
function isOwnMessage(msg) {
|
||||
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
|
||||
}
|
||||
|
||||
function startEditTitle() {
|
||||
titleEdit.value = props.ticket.title
|
||||
editingTitle.value = true
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
|
||||
onSuccess: () => { editingTitle.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
function saveMeta() {
|
||||
metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
messageForm.is_internal = replyTab.value === 'internal'
|
||||
messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
|
||||
onSuccess: () => { messageForm.body = '' }
|
||||
})
|
||||
}
|
||||
|
||||
function attachFile(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
const data = new FormData()
|
||||
data.append('file', file)
|
||||
router.post(route('ticketing.attachments.store', { ticket: props.ticket.id }), data)
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (confirm(`Delete ticket ${props.ticket.number}? This cannot be undone.`)) {
|
||||
router.delete(route('ticketing.destroy', { ticket: props.ticket.id }))
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const map = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-purple-100 text-purple-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - date) / 1000)
|
||||
if (diff < 60) return `${diff}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
</script>
|
||||
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.'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class TicketAttachmentController extends Controller
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
|
||||
$disk = config('ticketing.storage_disk', 'local');
|
||||
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
|
||||
|
||||
TicketAttachment::create([
|
||||
@@ -71,7 +71,7 @@ class TicketAttachmentController extends Controller
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
|
||||
$disk = config('ticketing.storage_disk', 'local');
|
||||
return Storage::disk($disk)->download($attachment->path, $attachment->filename);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -51,12 +58,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 +85,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 +108,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 +146,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 +158,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,11 +173,15 @@ class TicketController extends Controller
|
||||
return $ticket;
|
||||
});
|
||||
|
||||
$groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get();
|
||||
$priorities = PriorityLevel::whereNull('group_id')
|
||||
->orWhereIn('group_id', $agentGroupIds ?: [0])
|
||||
->orderBy('sort_order')
|
||||
// Picker data: the group switcher/filter and the detail pane's transfer
|
||||
// select both read this, so archived groups are excluded. The tickets
|
||||
// themselves are NOT filtered — an archived group's tickets stay in the
|
||||
// list and stay workable; Index.vue re-adds the current group to the
|
||||
// transfer options when it happens to be archived.
|
||||
$groups = TicketingGroup::active()
|
||||
->when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))
|
||||
->get();
|
||||
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
|
||||
->where('status', 'active')
|
||||
->get();
|
||||
@@ -178,6 +206,7 @@ class TicketController extends Controller
|
||||
'project',
|
||||
'messages.attachments',
|
||||
'attachments',
|
||||
'views',
|
||||
])->whereKey($request->detail);
|
||||
|
||||
if ($isAgent) {
|
||||
@@ -189,8 +218,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 +229,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 +257,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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -234,7 +277,10 @@ class TicketController extends Controller
|
||||
$agentGroupIds = $this->agentGroupIds();
|
||||
$isAgent = count($agentGroupIds) > 0;
|
||||
|
||||
$groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
|
||||
// New tickets can only be filed against a live group.
|
||||
$groups = TicketingGroup::active()
|
||||
->when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))
|
||||
->get();
|
||||
|
||||
// No groups at all? Show first-run state.
|
||||
if ($groups->isEmpty() && TicketingGroup::count() === 0) {
|
||||
@@ -246,11 +292,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,
|
||||
@@ -265,8 +307,18 @@ class TicketController extends Controller
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'group_id' => 'required|exists:ticketing_groups,id',
|
||||
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
|
||||
// Archived groups are gone from the picker; they must also be
|
||||
// unreachable by posting a stale id.
|
||||
//
|
||||
// `integer` before `exists` is load-bearing, not tidiness: `exists`
|
||||
// given an ARRAY checks that every element exists, so `group_id: [14]`
|
||||
// sailed through with one valid id and reached the controller as an
|
||||
// array. findOrFail() then returned a Collection, ->nextTicketNumber()
|
||||
// on it fatalled, and the endpoint 500'd instead of 422ing. Only some
|
||||
// array shapes showed it — `['a','b']` was rejected by exists itself,
|
||||
// which is why this hid behind a passing validation rule.
|
||||
'group_id' => ['required', 'integer', Rule::exists('ticketing_groups', 'id')->whereNull('archived_at')],
|
||||
'priority_id' => 'nullable|integer|exists:ticketing_priority_levels,id',
|
||||
'due_date' => 'nullable|date',
|
||||
'attachments' => 'nullable|array|max:5',
|
||||
'attachments.*' => 'file|max:20480',
|
||||
@@ -291,7 +343,7 @@ class TicketController extends Controller
|
||||
]);
|
||||
|
||||
if ($request->hasFile('attachments')) {
|
||||
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
|
||||
$disk = config('ticketing.storage_disk', 'local');
|
||||
foreach ($request->file('attachments') as $file) {
|
||||
if (!$file->isValid()) continue;
|
||||
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
|
||||
@@ -319,12 +371,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 +390,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 +413,7 @@ class TicketController extends Controller
|
||||
'isManager' => $isAgent && $this->isManager($ticket->group_id),
|
||||
'agents' => $agents,
|
||||
'priorities' => $priorities,
|
||||
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -358,8 +424,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,44 +443,139 @@ 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',
|
||||
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
|
||||
'status' => "sometimes|in:{$validStatusSlugs}",
|
||||
// `integer` guards the same array-through-`exists` hole as store().
|
||||
'priority_id' => 'nullable|integer|exists:ticketing_priority_levels,id',
|
||||
'due_date' => 'nullable|date',
|
||||
'project_id' => 'nullable|exists:ticketing_projects,id',
|
||||
'project_id' => 'nullable|integer|exists:ticketing_projects,id',
|
||||
// A ticket may not be transferred INTO an archived group, but a
|
||||
// ticket already sitting in one must still be editable — so its own
|
||||
// group id always validates.
|
||||
'group_id' => ['nullable', 'integer', 'exists:ticketing_groups,id',
|
||||
function ($attribute, $value, $fail) use ($ticket) {
|
||||
if (! $value || (int) $value === (int) $ticket->group_id) {
|
||||
return;
|
||||
}
|
||||
if (TicketingGroup::whereKey($value)->archived()->exists()) {
|
||||
$fail('That group is archived — choose an active group.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'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.');
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #8: capture the previous assignee BEFORE the write, so a genuine
|
||||
// hand-off can be told from a save that merely touched other fields.
|
||||
$previousAssignee = $ticket->assigned_to;
|
||||
|
||||
$ticket->update($validated);
|
||||
|
||||
$newAssignee = $ticket->assigned_to;
|
||||
$handedOver = $newAssignee
|
||||
&& $newAssignee !== $previousAssignee
|
||||
&& $newAssignee !== Auth::id(); // assigning to yourself needs no telling
|
||||
|
||||
if ($handedOver) {
|
||||
$agent = \App\Models\User::find($newAssignee);
|
||||
$email = trim((string) ($agent?->email ?? ''));
|
||||
|
||||
if ($email !== '') {
|
||||
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
||||
\Dashboard\Ticketing\Support\TicketingNotifier::ASSIGNED,
|
||||
$email,
|
||||
"[{$ticket->number}] Assigned to you: {$ticket->title}",
|
||||
'<p>'.e(Auth::user()?->name ?? 'Somebody').' assigned this ticket to you.</p>'
|
||||
.'<p><a href="'.e(route('ticketing.show', $ticket->id)).'">View the ticket</a></p>',
|
||||
$ticket,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 +586,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())
|
||||
|
||||
@@ -62,9 +62,34 @@ class TicketMessageController extends Controller
|
||||
return back()->with('success', 'Message sent.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #8: this was an empty placeholder, so nobody has ever been told their
|
||||
* ticket was answered. Goes through the Notifications platform so it lands in the
|
||||
* shared Notification Log and honours the person's preferences, rather than
|
||||
* reaching for Mail:: directly as the old comment suggested.
|
||||
*
|
||||
* Only ever called for a non-internal agent reply — see the caller.
|
||||
*/
|
||||
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
|
||||
{
|
||||
// Placeholder for email notification
|
||||
// In production: Mail::to($submitterEmail)->send(new TicketReplyMail($ticket, $message));
|
||||
$submitter = \App\Models\User::find($ticket->submitter_id);
|
||||
$email = trim((string) ($submitter?->email ?? ''));
|
||||
|
||||
if ($email === '') {
|
||||
return; // a ticket raised on behalf of somebody with no address
|
||||
}
|
||||
|
||||
$url = route('ticketing.show', $ticket->id);
|
||||
$author = $message->user_id ? (\App\Models\User::find($message->user_id)?->name ?? 'An agent') : 'An agent';
|
||||
|
||||
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
||||
\Dashboard\Ticketing\Support\TicketingNotifier::REPLIED,
|
||||
$email,
|
||||
"[{$ticket->number}] {$ticket->title}",
|
||||
'<p>'.e($author).' replied to your ticket:</p>'
|
||||
.'<blockquote>'.nl2br(e($message->body)).'</blockquote>'
|
||||
.'<p><a href="'.e($url).'">View the ticket</a></p>',
|
||||
$ticket,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -63,7 +64,7 @@ class TicketingSettingsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function index(): Response
|
||||
public function index(?string $tab = null): Response
|
||||
{
|
||||
$this->requireAgentAccess();
|
||||
|
||||
@@ -71,13 +72,22 @@ class TicketingSettingsController extends Controller
|
||||
$isBootstrap = $this->isBootstrapState();
|
||||
$myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id');
|
||||
|
||||
// Archived groups stay in this list — this is the one place they must
|
||||
// remain visible, so a manager can see what happened and restore it.
|
||||
// Actives first, then archived, each alphabetical.
|
||||
$groups = $isBootstrap
|
||||
? collect()
|
||||
: TicketingGroup::whereIn('id', $myGroupIds)->get();
|
||||
: TicketingGroup::whereIn('id', $myGroupIds)
|
||||
// tickets_count decides archive-vs-delete, so the UI can warn
|
||||
// the operator which one their click is about to do.
|
||||
->withCount('tickets')
|
||||
->orderByRaw('archived_at IS NOT NULL')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$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 +97,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 +110,17 @@ class TicketingSettingsController extends Controller
|
||||
: EmailConnection::whereIn('group_id', $myGroupIds)->get();
|
||||
|
||||
return Inertia::render('Ticketing/Settings', [
|
||||
'activeTab' => $tab,
|
||||
'groups' => $groups,
|
||||
'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 +162,6 @@ class TicketingSettingsController extends Controller
|
||||
];
|
||||
foreach ($defaults as $d) {
|
||||
PriorityLevel::create([
|
||||
'group_id' => $group->id,
|
||||
'name' => $d['name'],
|
||||
'color' => $d['color'],
|
||||
'description' => null,
|
||||
@@ -178,13 +189,113 @@ class TicketingSettingsController extends Controller
|
||||
return back()->with('success', 'Group updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire a group — hard delete when it is empty, archive when it is not.
|
||||
*
|
||||
* Tickets are the audit record of real correspondence, so a group that owns
|
||||
* any ticket can never be deleted: `tickets.group_id` is a RESTRICT foreign
|
||||
* key, which is the backstop, and this method is the polite front door. It
|
||||
* archives instead, leaving every ticket byte-identical and readable.
|
||||
*
|
||||
* An empty group has nothing to protect, so it really is deleted — which is
|
||||
* what lets an operator undo a typo and lets the e2e CRUD spec clean up the
|
||||
* throwaway group it creates as its setup step (issue #7).
|
||||
*
|
||||
* The caller is told which of the two happened, explicitly: an `outcome` of
|
||||
* `deleted` or `archived` in the JSON body for API callers, and distinct
|
||||
* flash wording for the Inertia UI. Nobody has to guess from a 302.
|
||||
*/
|
||||
public function destroyGroup(Request $request, TicketingGroup $group)
|
||||
{
|
||||
$this->requireManagerAccess($group->id);
|
||||
|
||||
$ticketCount = $group->tickets()->count();
|
||||
|
||||
if ($ticketCount > 0) {
|
||||
// Idempotent: archiving an already-archived group is not an error,
|
||||
// it just re-reports the same outcome.
|
||||
if ($group->archived_at === null) {
|
||||
$group->archived_at = now();
|
||||
$group->save();
|
||||
}
|
||||
|
||||
return $this->groupRetirementResponse($request, 'archived', $group, $ticketCount);
|
||||
}
|
||||
|
||||
// Empty group: agent access, projects and email connections all cascade
|
||||
// on delete (see their migrations), so nothing is left dangling.
|
||||
$group->delete();
|
||||
|
||||
return $this->groupRetirementResponse($request, 'deleted', $group, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Un-archive a group so it can take new tickets again.
|
||||
*/
|
||||
public function restoreGroup(Request $request, TicketingGroup $group)
|
||||
{
|
||||
$this->requireManagerAccess($group->id);
|
||||
|
||||
if ($group->archived_at !== null) {
|
||||
$group->archived_at = null;
|
||||
$group->save();
|
||||
}
|
||||
|
||||
if ($this->wantsJsonBody($request)) {
|
||||
return response()->json([
|
||||
'outcome' => 'restored',
|
||||
'group_id' => $group->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Group “{$group->name}” restored.");
|
||||
}
|
||||
|
||||
/**
|
||||
* One shape for "what happened to that group", for both kinds of caller.
|
||||
*/
|
||||
private function groupRetirementResponse(Request $request, string $outcome, TicketingGroup $group, int $ticketCount)
|
||||
{
|
||||
if ($this->wantsJsonBody($request)) {
|
||||
return response()->json([
|
||||
'outcome' => $outcome,
|
||||
'group_id' => $group->id,
|
||||
'ticket_count' => $ticketCount,
|
||||
]);
|
||||
}
|
||||
|
||||
$message = $outcome === 'deleted'
|
||||
? "Group “{$group->name}” deleted — it had no tickets."
|
||||
: "Group “{$group->name}” archived — it has {$ticketCount} ticket"
|
||||
. ($ticketCount === 1 ? '' : 's') . ', which stay readable.';
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an API/script caller that wants a JSON body.
|
||||
*
|
||||
* Inertia sends `Accept: text/html` but also XHR headers, so
|
||||
* expectsJson() alone would hijack the UI's redirect-back flow.
|
||||
*/
|
||||
private function wantsJsonBody(Request $request): bool
|
||||
{
|
||||
if ($request->header('X-Inertia')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $request->expectsJson();
|
||||
}
|
||||
|
||||
public function storeAgent(Request $request)
|
||||
{
|
||||
$this->requireAgentAccess();
|
||||
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|exists:users,id',
|
||||
'group_id' => 'required|exists:ticketing_groups,id',
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
// `integer` before `exists`: an array value passes `exists` element-wise,
|
||||
// so [14] would reach the code below as an array. See TicketController::store().
|
||||
'group_id' => 'required|integer|exists:ticketing_groups,id',
|
||||
'role' => 'required|in:agent,manager',
|
||||
]);
|
||||
|
||||
@@ -209,21 +320,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 +338,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 +356,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([
|
||||
@@ -292,7 +376,9 @@ class TicketingSettingsController extends Controller
|
||||
$this->requireAgentAccess();
|
||||
|
||||
$validated = $request->validate([
|
||||
'group_id' => 'required|exists:ticketing_groups,id',
|
||||
// `integer` before `exists`: an array value passes `exists` element-wise,
|
||||
// so [14] would reach the code below as an array. See TicketController::store().
|
||||
'group_id' => 'required|integer|exists:ticketing_groups,id',
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'status' => 'required|in:active,archived',
|
||||
@@ -345,7 +431,9 @@ class TicketingSettingsController extends Controller
|
||||
$this->requireAgentAccess();
|
||||
|
||||
$validated = $request->validate([
|
||||
'group_id' => 'required|exists:ticketing_groups,id',
|
||||
// `integer` before `exists`: an array value passes `exists` element-wise,
|
||||
// so [14] would reach the code below as an array. See TicketController::store().
|
||||
'group_id' => 'required|integer|exists:ticketing_groups,id',
|
||||
'type' => 'required|in:gmail,imap',
|
||||
'active' => 'boolean',
|
||||
'config.host' => 'nullable|string|max:255',
|
||||
@@ -400,4 +488,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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Dashboard\Ticketing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
@@ -9,6 +10,36 @@ class TicketingGroup extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'email_address', 'color', 'prefix'];
|
||||
|
||||
protected $casts = [
|
||||
'archived_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = ['is_archived'];
|
||||
|
||||
/**
|
||||
* Archived groups are deliberately NOT hidden by a global scope.
|
||||
*
|
||||
* The tickets in an archived group are still live correspondence: their
|
||||
* agents must keep working them, `$ticket->group` must still resolve, and
|
||||
* Settings must still list the group so a manager can restore it. Only the
|
||||
* *pickers* — "which group should this new ticket go to", "filter by
|
||||
* group" — exclude archived ones, via this scope, one call site at a time.
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('archived_at');
|
||||
}
|
||||
|
||||
public function scopeArchived(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotNull('archived_at');
|
||||
}
|
||||
|
||||
public function getIsArchivedAttribute(): bool
|
||||
{
|
||||
return $this->archived_at !== null;
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'group_id');
|
||||
|
||||
134
src/Support/TicketingNotifier.php
Normal file
134
src/Support/TicketingNotifier.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Ticketing\Support;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* The ONE place Ticketing reaches into the Notifications platform app (bridge
|
||||
* shape: Dashboard\Tools\Support\ToolsNotifier).
|
||||
*
|
||||
* Issue #8: ticketing had NO notification integration at all — no `ticketing.*`
|
||||
* category existed, so the shared Notification Log listed a category for every
|
||||
* other app and nothing for this one. Note this was not a mail sweep like the
|
||||
* other apps: there was no existing Mail:: call to redirect.
|
||||
* TicketMessageController::notifySubmitter() was an empty stub with the comment
|
||||
* "In production: Mail::to($submitterEmail)->send(new TicketReplyMail(…))", so
|
||||
* nobody has ever been told their ticket was answered.
|
||||
*
|
||||
* TWO CATEGORIES, matching the two moments a person is waiting on somebody else:
|
||||
*
|
||||
* - ticketing.replied → the submitter, when an agent answers. Internal notes are
|
||||
* excluded by the caller: the whole point of an internal note is that the
|
||||
* submitter does not see it.
|
||||
* - ticketing.assigned → the agent a ticket has just been handed to. Skipped when
|
||||
* somebody assigns a ticket to themselves, which needs no telling.
|
||||
*
|
||||
* Neither is mandatory. A reply notice is a convenience, not a security or
|
||||
* financial record, so a person may turn it off in their notification preferences.
|
||||
*
|
||||
* SHIP-DARK SAFETY. available() is false when the platform is absent, and every
|
||||
* caller treats a false return as "nothing was sent" rather than an error — there is
|
||||
* no direct-mail fallback to run, because there was never any direct mail here.
|
||||
* DELIBERATELY NOT a manifest dependency: requires.apps is a HARD requirement (a
|
||||
* merely-disabled dep fails dashboard:doctor, which gates update.sh and promote.sh),
|
||||
* and Notifications is a beta app that ships dark. Add it once Notifications is
|
||||
* stability: stable.
|
||||
*/
|
||||
final class TicketingNotifier
|
||||
{
|
||||
public const REPLIED = 'ticketing.replied';
|
||||
|
||||
public const ASSIGNED = 'ticketing.assigned';
|
||||
|
||||
/** @var array<string, array{0:string,1:string}> key => [label, description] */
|
||||
public const CATEGORIES = [
|
||||
self::REPLIED => [
|
||||
'Your help desk ticket was answered',
|
||||
'Sent to the person who raised a ticket when an agent replies. Internal notes never trigger this.',
|
||||
['staff', 'student'],
|
||||
],
|
||||
self::ASSIGNED => [
|
||||
'A help desk ticket was assigned to you',
|
||||
'Sent to an agent when a ticket is assigned to them by somebody else.',
|
||||
['staff'],
|
||||
],
|
||||
];
|
||||
|
||||
/** Test hook — behave as though Notifications isn't installed. */
|
||||
private static bool $pretendMissing = false;
|
||||
|
||||
public static function available(): bool
|
||||
{
|
||||
return ! self::$pretendMissing
|
||||
&& class_exists(\Central\Notifications\Support\Notify::class)
|
||||
&& class_exists(\Central\Notifications\Support\Message::class);
|
||||
}
|
||||
|
||||
public static function pretendPlatformMissing(bool $missing = true): void
|
||||
{
|
||||
self::$pretendMissing = $missing;
|
||||
}
|
||||
|
||||
/** Declare the categories. Called from TicketingServiceProvider::boot(). */
|
||||
public static function registerCategories(): void
|
||||
{
|
||||
if (! class_exists(\Central\Notifications\Support\NotificationRegistry::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach (self::CATEGORIES as $key => [$label, $description, $audience]) {
|
||||
\Central\Notifications\Support\NotificationRegistry::register(
|
||||
$key, $label, $description, ['mail'], mandatory: false, audience: $audience,
|
||||
);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Ticketing: could not register notification categories: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one email through the platform.
|
||||
*
|
||||
* @param string $to recipient address, verbatim
|
||||
* @return array{handled:bool,status:?string,error:?string,id:?int}
|
||||
*/
|
||||
public static function deliver(
|
||||
string $category,
|
||||
string $to,
|
||||
string $subject,
|
||||
string $bodyHtml,
|
||||
?Model $context = null,
|
||||
): array {
|
||||
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
|
||||
|
||||
if (! self::available() || trim($to) === '') {
|
||||
return $unhandled;
|
||||
}
|
||||
|
||||
try {
|
||||
$row = \Central\Notifications\Support\Notify::send(new \Central\Notifications\Support\Message(
|
||||
category: $category,
|
||||
to: $to,
|
||||
subject: $subject,
|
||||
body: $bodyHtml,
|
||||
context: $context,
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a notification failure break the action that caused it:
|
||||
// sending a reply must succeed even if the email cannot be queued.
|
||||
Log::warning("Ticketing: notification '{$category}' could not be queued: ".$e->getMessage());
|
||||
|
||||
return $unhandled;
|
||||
}
|
||||
|
||||
return [
|
||||
'handled' => true,
|
||||
'status' => $row->status,
|
||||
'error' => $row->status === 'failed' ? ($row->error ?: 'The notification could not be queued.') : null,
|
||||
'id' => $row->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,27 @@ use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class TicketingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void {}
|
||||
public function register(): void
|
||||
{
|
||||
// Merge so config('ticketing.*') resolves even when the host app has not
|
||||
// published a copy — and, crucially, so the value is captured by
|
||||
// config:cache instead of being read from env() at runtime (S3).
|
||||
$this->mergeConfigFrom(__DIR__.'/../config/ticketing.php', 'ticketing');
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
||||
$this->loadRoutesFrom(__DIR__.'/routes/ticketing.php');
|
||||
|
||||
// Declare ticketing.* notification categories (issue #8). Safe to call when
|
||||
// Notifications is absent or dark — the notifier no-ops.
|
||||
\Dashboard\Ticketing\Support\TicketingNotifier::registerCategories();
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,20 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
||||
|
||||
// ── Fixed paths first (before any /{ticket} wildcards) ────────────────
|
||||
|
||||
// my-tickets and prefs carried only `app.access:ticketing`, which means "you hold
|
||||
// ANY ticketing.* permission" — so ticketing.settings alone opened them. They are
|
||||
// the submitter's own surface, and ticketing.view is literally described as
|
||||
// "Submit tickets and view own ticket history", so it is the right key; managers
|
||||
// hold it implicitly via the same either-or the rest of this file uses.
|
||||
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
|
||||
Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets');
|
||||
Route::post('/prefs', [TicketController::class, 'savePrefs']) ->name('prefs.save');
|
||||
});
|
||||
|
||||
// Attachments stay ungated ON PURPOSE: TicketAttachmentController::show already
|
||||
// authorises submitter-or-agent per attachment, which is finer than any
|
||||
// permission could be. A key here would either duplicate that check or
|
||||
// contradict it.
|
||||
Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show');
|
||||
|
||||
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
|
||||
@@ -28,8 +41,12 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
||||
// ── Settings (must be before /{ticket} wildcard) ──────────────────────
|
||||
Route::middleware('permission:ticketing.settings')->group(function () {
|
||||
Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings');
|
||||
Route::get('/settings/{tab}', [TicketingSettingsController::class, 'index'])
|
||||
->where('tab', 'groups|agents|priorities|statuses|projects|automation')->name('settings.tab');
|
||||
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store');
|
||||
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
|
||||
Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');
|
||||
Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore');
|
||||
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
|
||||
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
|
||||
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');
|
||||
@@ -41,6 +58,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) ──────────
|
||||
@@ -51,7 +72,13 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
|
||||
Route::middleware('permission:ticketing.manage')->group(function () {
|
||||
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::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