feat: full dashboard-ticketing scaffold with data model, controllers, Vue pages

This commit is contained in:
Joel Wedemire
2026-04-08 17:10:30 -07:00
parent 81d0d54f50
commit 391699220f
33 changed files with 1947 additions and 719 deletions

View File

@@ -1,8 +1,12 @@
{ {
"name": "dashboard/ticketing", "name": "dashboard/ticketing",
"description": "Ticketing / help-desk snap-in for the Dashboard platform", "description": "Ticketing snap-in for dashboard-shell",
"type": "library", "type": "library",
"license": "MIT", "require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0",
"inertiajs/inertia-laravel": "^2.0"
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Dashboard\\Ticketing\\": "src/" "Dashboard\\Ticketing\\": "src/"
@@ -10,14 +14,7 @@
}, },
"extra": { "extra": {
"laravel": { "laravel": {
"providers": [ "providers": ["Dashboard\\Ticketing\\TicketingServiceProvider"]
"Dashboard\\Ticketing\\TicketingServiceProvider"
]
} }
},
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0|^13.0",
"inertiajs/inertia-laravel": "^1.0|^2.0"
} }
} }

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticketing_groups', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email_address')->nullable();
$table->string('color', 7)->default('#6366f1'); // #hex
$table->string('prefix', 10); // e.g. "IT"
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticketing_groups');
}
};

View File

@@ -1,28 +0,0 @@
<?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('tickets', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
$table->string('title');
$table->text('description');
$table->enum('category', ['IT', 'Facilities', 'HR', 'Other'])->default('Other');
$table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium');
$table->enum('status', ['open', 'in_progress', 'resolved', 'closed'])->default('open');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tickets');
}
};

View File

@@ -1,25 +0,0 @@
<?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_comments', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->boolean('is_internal')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticket_comments');
}
};

View File

@@ -0,0 +1,26 @@
<?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('ticketing_priority_levels', function (Blueprint $table) {
$table->id();
$table->foreignId('group_id')->nullable()->constrained('ticketing_groups')->nullOnDelete();
$table->string('name');
$table->string('color', 7)->default('#6b7280');
$table->text('description')->nullable();
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticketing_priority_levels');
}
};

View File

@@ -0,0 +1,26 @@
<?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('ticketing_projects', function (Blueprint $table) {
$table->id();
$table->foreignId('group_id')->constrained('ticketing_groups')->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->enum('status', ['active', 'completed', 'archived'])->default('active');
$table->unsignedBigInteger('created_by');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticketing_projects');
}
};

View File

@@ -0,0 +1,31 @@
<?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('tickets', function (Blueprint $table) {
$table->id();
$table->string('number')->unique(); // e.g. "IT-0042"
$table->foreignId('group_id')->constrained('ticketing_groups');
$table->unsignedBigInteger('submitter_id');
$table->unsignedBigInteger('assigned_to')->nullable();
$table->foreignId('project_id')->nullable()->constrained('ticketing_projects')->nullOnDelete();
$table->string('title');
$table->text('description');
$table->enum('status', ['open', 'in_progress', 'pending', 'resolved', 'closed'])->default('open');
$table->foreignId('priority_id')->nullable()->constrained('ticketing_priority_levels')->nullOnDelete();
$table->date('due_date')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tickets');
}
};

View File

@@ -0,0 +1,28 @@
<?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_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('author_email')->nullable();
$table->text('body');
$table->boolean('is_internal')->default(false);
$table->enum('source', ['web', 'email'])->default('web');
$table->string('email_message_id')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticket_messages');
}
};

View File

@@ -0,0 +1,27 @@
<?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_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete();
$table->foreignId('message_id')->nullable()->constrained('ticket_messages')->nullOnDelete();
$table->string('filename');
$table->string('path');
$table->string('mime_type');
$table->unsignedBigInteger('size');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticket_attachments');
}
};

View File

@@ -0,0 +1,26 @@
<?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('ticketing_agent_access', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->foreignId('group_id')->constrained('ticketing_groups')->cascadeOnDelete();
$table->enum('role', ['agent', 'manager'])->default('agent');
$table->timestamps();
$table->unique(['user_id', 'group_id']);
});
}
public function down(): void
{
Schema::dropIfExists('ticketing_agent_access');
}
};

View File

@@ -0,0 +1,26 @@
<?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('ticketing_email_connections', function (Blueprint $table) {
$table->id();
$table->foreignId('group_id')->constrained('ticketing_groups')->cascadeOnDelete();
$table->enum('type', ['gmail', 'imap'])->default('imap');
$table->json('config'); // credentials
$table->timestamp('last_polled_at')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('ticketing_email_connections');
}
};

View File

@@ -1,112 +1,111 @@
<template> <template>
<AppLayout title="New Ticket"> <div class="max-w-2xl mx-auto py-8 px-4">
<div class="p-6 max-w-2xl"> <div class="mb-6">
<div class="flex items-center gap-3 mb-6"> <Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link>
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline"> Tickets</Link> <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">Submit a Ticket</h1>
<h1 class="text-2xl font-bold text-gray-900">Submit a Ticket</h1> </div>
<form @submit.prevent="submit" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<!-- Group -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Group <span class="text-red-500">*</span></label>
<select
v-model="form.group_id"
required
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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> </div>
<form @submit.prevent="submit" class="bg-white rounded-xl shadow p-6 space-y-5">
<!-- Title --> <!-- Title -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title <span class="text-red-500">*</span></label>
<input <input
v-model="form.title" v-model="form.title"
type="text" type="text"
maxlength="255" required
placeholder="Brief summary of the issue" placeholder="Brief summary of the issue"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
:class="{ 'border-red-400': errors.title }"
/> />
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p> <p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
</div> </div>
<!-- Description --> <!-- Description -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description <span class="text-red-500">*</span></label>
<textarea <textarea
v-model="form.description" v-model="form.description"
required
rows="5" rows="5"
placeholder="Describe the issue in detail" placeholder="Describe the issue in detail..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
:class="{ 'border-red-400': errors.description }" ></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 dark:text-gray-300 mb-1">Priority</label>
<select
v-model="form.priority_id"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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 dark:text-gray-300 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 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
/> />
<p v-if="errors.description" class="mt-1 text-xs text-red-500">{{ errors.description }}</p>
</div> </div>
<!-- Category + Priority --> <!-- Submit -->
<div class="grid grid-cols-2 gap-4"> <div class="flex justify-end pt-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label>
<select
v-model="form.category"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.category }"
>
<option value="">Select category</option>
<option value="IT">IT</option>
<option value="Facilities">Facilities</option>
<option value="HR">HR</option>
<option value="Other">Other</option>
</select>
<p v-if="errors.category" class="mt-1 text-xs text-red-500">{{ errors.category }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priority <span class="text-red-500">*</span></label>
<select
v-model="form.priority"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.priority }"
>
<option value="">Select priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<p v-if="errors.priority" class="mt-1 text-xs text-red-500">{{ errors.priority }}</p>
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<Link :href="route('ticketing.index')" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
Cancel
</Link>
<button <button
type="submit" type="submit"
:disabled="processing" :disabled="form.processing"
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50" class="inline-flex items-center gap-2 bg-indigo-600 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-60 transition"
style="background-color: var(--color-sidebar-active-bg)"
> >
{{ processing ? 'Submitting…' : 'Submit Ticket' }} <span v-if="form.processing">Submitting</span>
<span v-else>Submit Ticket</span>
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</AppLayout>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { computed } from 'vue'
import { Link, useForm } from '@inertiajs/vue3' import { Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const form = useForm({ const props = defineProps({
title: '', groups: Array,
description: '', priorities: Array,
category: '',
priority: 'medium',
}) })
const processing = ref(false) const form = useForm({
const errors = ref({}) 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))
})
function submit() { function submit() {
processing.value = true form.post(route('ticketing.store'))
form.post(route('ticketing.store'), {
onError: (e) => { errors.value = e },
onFinish: () => { processing.value = false },
})
} }
</script> </script>

View File

@@ -1,147 +1,114 @@
<template> <template>
<AppLayout :title="`Edit Ticket #${ticket.id}`"> <div class="max-w-2xl mx-auto py-8 px-4">
<div class="p-6 max-w-2xl"> <div class="mb-6">
<div class="flex items-center gap-3 mb-6"> <Link :href="route('ticketing.show', { ticket: ticket.id })" class="text-sm text-indigo-600 hover:underline"> Back to ticket</Link>
<Link :href="route('ticketing.show', ticket.id)" class="text-sm text-gray-500 hover:underline"> Ticket #{{ ticket.id }}</Link> <h1 class="text-xl font-bold text-gray-900 dark:text-white mt-2">
<h1 class="text-2xl font-bold text-gray-900">Edit Ticket</h1> Edit <span class="font-mono text-base">{{ ticket.number }}</span>
</h1>
</div> </div>
<form @submit.prevent="submit" class="bg-white rounded-xl shadow p-6 space-y-5"> <form @submit.prevent="submit" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<!-- Title --> <!-- Title -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
<input <input v-model="form.title" type="text" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" />
v-model="form.title" <p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
type="text"
maxlength="255"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.title }"
/>
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p>
</div> </div>
<!-- Description --> <!-- Description -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<textarea <textarea v-model="form.description" rows="6" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"></textarea>
v-model="form.description" <p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
rows="5"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.description }"
/>
<p v-if="errors.description" class="mt-1 text-xs text-red-500">{{ errors.description }}</p>
</div> </div>
<!-- Category + Priority --> <!-- Status -->
<div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select <select v-model="form.status" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
v-model="form.category"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.category }"
>
<option value="IT">IT</option>
<option value="Facilities">Facilities</option>
<option value="HR">HR</option>
<option value="Other">Other</option>
</select>
<p v-if="errors.category" class="mt-1 text-xs text-red-500">{{ errors.category }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priority <span class="text-red-500">*</span></label>
<select
v-model="form.priority"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.priority }"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<p v-if="errors.priority" class="mt-1 text-xs text-red-500">{{ errors.priority }}</p>
</div>
</div>
<!-- Admin-only: Status + Assigned To -->
<template v-if="isAdmin">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
v-model="form.status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
<option value="open">Open</option> <option value="open">Open</option>
<option value="in_progress">In Progress</option> <option value="in_progress">In Progress</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option> <option value="resolved">Resolved</option>
<option value="closed">Closed</option> <option value="closed">Closed</option>
</select> </select>
</div> </div>
</div>
</template>
<div class="flex justify-between items-center pt-2"> <!-- Priority -->
<button <div>
type="button" <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
@click="destroy" <select v-model="form.priority_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
class="px-4 py-2 text-sm text-red-600 hover:text-red-800 transition" <option :value="null">No priority</option>
> <option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
Delete Ticket </select>
</button> </div>
<div class="flex gap-3">
<Link :href="route('ticketing.show', ticket.id)" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition"> <!-- Assignee -->
Cancel <div>
</Link> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Assignee</label>
<select v-model="form.assigned_to" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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 dark:text-gray-300 mb-1">Project</label>
<select v-model="form.project_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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 dark:text-gray-300 mb-1">Due Date</label>
<input v-model="form.due_date" type="date" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>Cancel</Link>
<button <button
type="submit" type="submit"
:disabled="processing" :disabled="form.processing"
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50" class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-60 transition"
style="background-color: var(--color-sidebar-active-bg)"
> >
{{ processing ? 'Saving…' : 'Save Changes' }} {{ form.processing ? 'Saving…' : 'Save Changes' }}
</button> </button>
</div> </div>
</div>
</form> </form>
</div> </div>
</AppLayout>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { Link, useForm } from '@inertiajs/vue3'
import { Link, useForm, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({ const props = defineProps({
ticket: Object, ticket: Object,
isAdmin: { type: Boolean, default: false }, priorities: Array,
agents: Array,
projects: Array,
}) })
const form = useForm({ const form = useForm({
title: props.ticket.title, title: props.ticket.title,
description: props.ticket.description, description: props.ticket.description,
category: props.ticket.category,
priority: props.ticket.priority,
status: props.ticket.status, status: props.ticket.status,
assigned_to: props.ticket.assigned_to ?? '', priority_id: props.ticket.priority_id,
assigned_to: props.ticket.assigned_to,
project_id: props.ticket.project_id,
due_date: props.ticket.due_date,
}) })
const processing = ref(false)
const errors = ref({})
function submit() { function submit() {
processing.value = true form.put(route('ticketing.update', { ticket: props.ticket.id }), {
form.put(route('ticketing.update', props.ticket.id), { onSuccess: () => {
onError: (e) => { errors.value = e }, // redirect happens server side
onFinish: () => { processing.value = false }, }
}) })
} }
function destroy() {
if (!confirm('Delete this ticket? This cannot be undone.')) return
router.delete(route('ticketing.destroy', props.ticket.id))
}
</script> </script>

View File

@@ -1,197 +1,255 @@
<template> <template>
<AppLayout title="Ticketing"> <div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
<div class="p-6"> <!-- Left Rail (240px) -->
<div class="flex items-center justify-between mb-6"> <aside class="w-60 flex-shrink-0 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-y-auto">
<h1 class="text-2xl font-bold text-gray-900">Tickets</h1> <div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Ticketing</h1>
</div>
<!-- Group Switcher -->
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 uppercase tracking-wide">Group</label>
<select
v-model="activeGroupId"
@change="applyFilter({ group_id: activeGroupId || undefined })"
class="w-full text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
>
<option :value="null">All Groups</option>
<option v-for="g in groups" :key="g.id" :value="g.id">
<span :style="{ color: g.color }"></span> {{ g.name }}
</option>
</select>
</div>
<!-- Filter Views -->
<nav class="p-3 space-y-1 border-b border-gray-200 dark:border-gray-700">
<button
v-for="view in filterViews"
:key="view.key"
@click="applyFilter({ filter: view.key })"
:class="[
'w-full text-left px-3 py-1.5 rounded-md text-sm transition-colors',
activeFilter === view.key
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200 font-medium'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
]"
>
{{ view.label }}
</button>
</nav>
<!-- Priority Filters -->
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Priority</p>
<div class="flex flex-wrap gap-1">
<button
v-for="p in priorities"
:key="p.id"
@click="applyFilter({ priority_id: filters.priority_id === p.id ? undefined : p.id })"
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors',
filters.priority_id === p.id
? 'border-current font-semibold'
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300'
]"
:style="filters.priority_id === p.id ? { color: p.color, borderColor: p.color } : {}"
>
<span :style="{ color: p.color }"></span> {{ p.name }}
</button>
</div>
</div>
<!-- Projects -->
<div class="p-3">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Projects</p>
<div class="space-y-1">
<p v-if="!projects.length" class="text-xs text-gray-400 italic">No active projects</p>
<button
v-for="proj in projects"
:key="proj.id"
class="w-full text-left px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md truncate"
>
📁 {{ proj.name }}
</button>
</div>
</div>
</aside>
<!-- Middle Panel: Ticket Queue -->
<main class="flex-1 flex flex-col min-w-0 border-r border-gray-200 dark:border-gray-700" style="max-width: 520px;">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
{{ currentViewLabel }}
<span class="ml-1.5 text-xs font-normal text-gray-400">({{ tickets.total }})</span>
</h2>
<Link <Link
:href="route('ticketing.create')" :href="route('ticketing.create')"
class="inline-flex items-center px-4 py-2 text-white rounded-lg transition" class="inline-flex items-center gap-1 text-xs bg-indigo-600 text-white px-3 py-1.5 rounded-md hover:bg-indigo-700 transition"
style="background-color: var(--color-sidebar-active-bg)"
> >
+ New Ticket + New
</Link> </Link>
</div> </div>
<!-- Filters --> <div class="overflow-y-auto flex-1">
<div class="mb-4 flex flex-wrap items-center gap-3"> <div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500 text-sm">
<div class="relative flex-1 min-w-[200px] max-w-md">
<input
v-model="searchInput"
type="text"
placeholder="Search tickets…"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
@keydown.enter="applyFilters"
/>
</div>
<select v-model="filters.status" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value="">All Statuses</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select v-model="filters.priority" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value="">All Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<select v-model="filters.category" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value="">All Categories</option>
<option value="IT">IT</option>
<option value="Facilities">Facilities</option>
<option value="HR">HR</option>
<option value="Other">Other</option>
</select>
<button
@click="applyFilters"
class="px-4 py-2 text-sm text-white rounded-lg transition"
style="background-color: var(--color-sidebar-active-bg)"
>Search</button>
<button
v-if="hasActiveFilters"
@click="clearFilters"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition"
> Clear</button>
</div>
<div class="bg-white rounded-xl shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 uppercase text-xs">
<tr>
<th class="px-6 py-3 text-left">#</th>
<th class="px-6 py-3 text-left">Title</th>
<th class="px-6 py-3 text-left">Category</th>
<th class="px-6 py-3 text-left">Priority</th>
<th class="px-6 py-3 text-left">Status</th>
<th v-if="isAdmin" class="px-6 py-3 text-left">Submitter</th>
<th class="px-6 py-3 text-left">Created</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-if="tickets.data.length === 0">
<td :colspan="isAdmin ? 8 : 7" class="px-6 py-10 text-center text-gray-400">
No tickets found. No tickets found.
</td> </div>
</tr> <button
<tr
v-for="ticket in tickets.data" v-for="ticket in tickets.data"
:key="ticket.id" :key="ticket.id"
class="hover:bg-gray-50 transition" @click="selectTicket(ticket)"
:class="[
'w-full text-left px-4 py-3 border-b border-gray-100 dark:border-gray-700 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700',
selectedTicketId === ticket.id ? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-500' : ''
]"
> >
<td class="px-6 py-4 text-gray-500">{{ ticket.id }}</td> <div class="flex items-start justify-between gap-2">
<td class="px-6 py-4 font-medium text-gray-900 max-w-xs truncate">{{ ticket.title }}</td> <div class="min-w-0 flex-1">
<td class="px-6 py-4 text-gray-600">{{ ticket.category }}</td> <div class="flex items-center gap-2 mb-0.5">
<td class="px-6 py-4"> <!-- Ticket number badge -->
<span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)"> <span
{{ ticket.priority }} class="inline-block text-xs font-mono font-semibold px-1.5 py-0.5 rounded text-white"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>
{{ ticket.number }}
</span> </span>
</td> <!-- Priority dot -->
<td class="px-6 py-4"> <span
<span class="px-2 py-1 rounded text-xs font-medium" :class="statusClass(ticket.status)"> v-if="ticket.priority"
{{ formatStatus(ticket.status) }} class="w-2 h-2 rounded-full flex-shrink-0"
</span> :style="{ backgroundColor: ticket.priority.color }"
</td> :title="ticket.priority.name"
<td v-if="isAdmin" class="px-6 py-4 text-gray-600">{{ ticket.submitter?.name ?? '' }}</td> ></span>
<td class="px-6 py-4 text-gray-500">{{ formatDate(ticket.created_at) }}</td>
<td class="px-6 py-4 text-right space-x-3">
<Link :href="route('ticketing.show', ticket.id)" class="text-xs text-indigo-600 hover:underline">View</Link>
<Link :href="route('ticketing.edit', ticket.id)" class="text-xs text-gray-500 hover:underline">Edit</Link>
</td>
</tr>
</tbody>
</table>
</div> </div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">{{ ticket.title }}</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ ticket.submitter?.name || ticket.submitter?.email || '—' }}
</p>
</div>
<div class="flex-shrink-0 text-right space-y-1">
<span :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-0.5 rounded-full font-medium">
{{ statusLabel(ticket.status) }}
</span>
<p class="text-xs text-gray-400">{{ timeAgo(ticket.created_at) }}</p>
</div>
</div>
</button>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<div v-if="tickets.last_page > 1" class="mt-4 flex items-center justify-between"> <div v-if="tickets.last_page > 1" class="px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex justify-between items-center">
<p class="text-sm text-gray-600"> <span class="text-xs text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
Showing {{ tickets.from }}{{ tickets.to }} of {{ tickets.total }} tickets
</p>
<div class="flex gap-2"> <div class="flex gap-2">
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="px-3 py-1 text-sm rounded border border-gray-300 hover:bg-gray-50 transition"> Prev</Link> <Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-xs text-indigo-600 hover:underline"> Prev</Link>
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="px-3 py-1 text-sm rounded border border-gray-300 hover:bg-gray-50 transition">Next </Link> <Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-xs text-indigo-600 hover:underline">Next </Link>
</div>
</div>
</main>
<!-- Right Panel: Ticket Detail -->
<div class="flex-1 min-w-0 flex flex-col bg-white dark:bg-gray-800">
<div v-if="!selectedTicketId" class="flex items-center justify-center h-full text-gray-400 dark:text-gray-500 text-sm">
Select a ticket to view details
</div>
<div v-else class="flex flex-col h-full">
<!-- We use an iframe-like approach: navigate to show page embedded -->
<!-- For now, reload the show page in a contained div via Inertia visit -->
<div class="p-4 text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2 border-b border-gray-200 dark:border-gray-700">
<span class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded text-white"
:style="{ backgroundColor: selectedTicket?.group?.color || '#6366f1' }">
{{ selectedTicket?.number }}
</span>
<span class="font-medium text-gray-800 dark:text-gray-100 truncate">{{ selectedTicket?.title }}</span>
<Link :href="route('ticketing.show', { ticket: selectedTicketId })" class="ml-auto text-xs text-indigo-600 hover:underline">Open </Link>
</div>
<div class="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
<Link
:href="route('ticketing.show', { ticket: selectedTicketId })"
class="inline-flex items-center gap-2 text-sm text-indigo-600 hover:underline"
>
View full conversation
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
</AppLayout>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Link, router } from '@inertiajs/vue3' import { Link, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({ const props = defineProps({
tickets: Object, tickets: Object,
search: { type: String, default: '' }, groups: Array,
statusFilter: { type: String, default: '' }, priorities: Array,
priorityFilter: { type: String, default: '' }, projects: Array,
categoryFilter: { type: String, default: '' }, isAgent: Boolean,
isAdmin: { type: Boolean, default: false }, filters: Object,
}) })
const searchInput = ref(props.search) const selectedTicketId = ref(null)
const filters = ref({ const activeGroupId = ref(props.filters?.group_id || null)
status: props.statusFilter, const activeFilter = ref(props.filters?.filter || 'all')
priority: props.priorityFilter,
category: props.categoryFilter, const filterViews = [
{ key: 'all', label: '📥 All Open' },
{ key: 'mine', label: '👤 Mine' },
{ key: 'unassigned', label: '🔲 Unassigned' },
{ key: 'pending', label: '⏳ Pending' },
]
const currentViewLabel = computed(() => {
return filterViews.find(v => v.key === activeFilter.value)?.label || 'Tickets'
}) })
const hasActiveFilters = computed(() => const selectedTicket = computed(() => {
searchInput.value || filters.value.status || filters.value.priority || filters.value.category return props.tickets.data.find(t => t.id === selectedTicketId.value) || null
) })
function applyFilters() { function selectTicket(ticket) {
router.get(route('ticketing.index'), { selectedTicketId.value = ticket.id
search: searchInput.value,
status: filters.value.status,
priority: filters.value.priority,
category: filters.value.category,
}, { preserveState: true, replace: true })
} }
function clearFilters() { function applyFilter(newFilters) {
searchInput.value = '' const merged = { ...props.filters, ...newFilters }
filters.value = { status: '', priority: '', category: '' } // Remove undefined/null values
router.get(route('ticketing.index'), {}, { preserveState: true, replace: true }) Object.keys(merged).forEach(k => {
if (merged[k] === undefined || merged[k] === null) delete merged[k]
})
if (newFilters.filter !== undefined) activeFilter.value = newFilters.filter || 'all'
router.get(route('ticketing.index'), merged, { preserveState: true, replace: true })
} }
function formatDate(d) { function statusLabel(status) {
return new Date(d).toLocaleDateString('en-CA')
}
function formatStatus(s) {
const map = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' }
return map[s] ?? s
}
function statusClass(s) {
const map = { const map = {
open: 'bg-blue-100 text-blue-700', open: 'Open',
in_progress: 'bg-yellow-100 text-yellow-700', in_progress: 'In Progress',
resolved: 'bg-green-100 text-green-700', pending: 'Pending',
closed: 'bg-gray-100 text-gray-600', resolved: 'Resolved',
closed: 'Closed',
} }
return map[s] ?? 'bg-gray-100 text-gray-600' return map[status] || status
} }
function priorityClass(p) { function statusClass(status) {
const map = { const map = {
low: 'bg-gray-100 text-gray-600', open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
medium: 'bg-blue-100 text-blue-700', in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
high: 'bg-orange-100 text-orange-700', pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
urgent: 'bg-red-100 text-red-700', resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
} }
return map[p] ?? 'bg-gray-100 text-gray-600' 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> </script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="max-w-3xl mx-auto py-8 px-4">
<div class="mb-6 flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">My Tickets</h1>
<Link :href="route('ticketing.create')" class="inline-flex items-center gap-1 bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition">
+ Submit Ticket
</Link>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
<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 dark:hover:bg-gray-700 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 dark:text-gray-100 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>
</template>
<script setup>
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 dark:bg-blue-900 dark:text-blue-200',
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>

View File

@@ -0,0 +1,299 @@
<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 dark:text-white mt-2">Ticketing Settings</h1>
</div>
<!-- Flash message -->
<div v-if="$page.props.flash?.success" class="mb-4 px-4 py-2 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 text-green-700 dark:text-green-300 rounded-lg text-sm">
{{ $page.props.flash.success }}
</div>
<!-- Tabs -->
<div class="flex gap-1 border-b border-gray-200 dark:border-gray-700 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 dark:text-indigo-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700'
]"
>{{ tab.label }}</button>
</div>
<!-- Groups Tab -->
<div v-if="activeTab === 'groups'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Groups</h2>
<button @click="showAddGroup = !showAddGroup" class="text-sm text-indigo-600 hover:underline">
{{ showAddGroup ? 'Cancel' : '+ Add Group' }}
</button>
</div>
<!-- Add Group Form -->
<div v-if="showAddGroup" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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>
<!-- Groups List -->
<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 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 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 dark:text-gray-100">{{ 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>
<!-- Edit Group Form -->
<div v-if="editingGroup" class="mt-4 bg-gray-50 dark:bg-gray-700 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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>
<!-- Agents Tab -->
<div v-if="activeTab === 'agents'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Agents</h2>
<button @click="showAddAgent = !showAddAgent" class="text-sm text-indigo-600 hover:underline">
{{ showAddAgent ? 'Cancel' : '+ Add Agent' }}
</button>
</div>
<!-- Add Agent Form -->
<div v-if="showAddAgent" class="bg-gray-50 dark:bg-gray-700 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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>
<!-- Agents List -->
<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 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl"
>
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ 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="removeAgent(access)"
class="text-xs text-red-500 hover:underline"
>Remove</button>
</div>
</div>
</div>
<!-- Priorities Tab -->
<div v-if="activeTab === 'priorities'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Priority Levels</h2>
<button @click="showAddPriority = !showAddPriority" class="text-sm text-indigo-600 hover:underline">
{{ showAddPriority ? 'Cancel' : '+ Add Priority' }}
</button>
</div>
<!-- Add Priority Form -->
<div v-if="showAddPriority" class="bg-gray-50 dark:bg-gray-700 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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 dark:border-gray-600 dark:bg-gray-800 dark:text-white 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>
<!-- Priorities List -->
<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 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 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 dark:text-gray-100">{{ 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>
</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,
myGroupIds: Array,
})
const activeTab = ref('groups')
const tabs = [
{ key: 'groups', label: 'Groups' },
{ key: 'agents', label: 'Agents' },
{ key: 'priorities', label: 'Priorities' },
]
const showAddGroup = ref(false)
const showAddAgent = ref(false)
const showAddPriority = ref(false)
const editingGroup = ref(null)
// Forms
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 })
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 removeAgent(access) {
if (confirm('Remove this agent?')) {
router.delete(route('ticketing.settings.agents.destroy', { access: access.id }))
}
}
function submitPriority() {
priorityForm.post(route('ticketing.settings.priorities.store'), {
onSuccess: () => { showAddPriority.value = false; priorityForm.reset() }
})
}
</script>

View File

@@ -1,156 +1,316 @@
<template> <template>
<AppLayout :title="`Ticket #${ticket.id}`"> <div class="max-w-4xl mx-auto py-8 px-4">
<div class="p-6 max-w-3xl"> <!-- Header -->
<div class="flex items-center gap-3 mb-6"> <div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline"> Tickets</Link> <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">Ticket #{{ ticket.id }}</h1>
<span class="px-2 py-1 rounded text-xs font-medium" :class="statusClass(ticket.status)">
{{ formatStatus(ticket.status) }}
</span>
<span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)">
{{ ticket.priority }}
</span>
</div> </div>
<!-- Ticket Details --> <div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div class="bg-white rounded-xl shadow p-6 mb-6"> <!-- Ticket Header -->
<div class="flex items-start justify-between mb-4"> <div class="p-5 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900">{{ ticket.title }}</h2> <div class="flex items-start gap-3 flex-wrap">
<Link :href="route('ticketing.edit', ticket.id)" class="text-sm text-indigo-600 hover:underline">Edit</Link> <span
</div> 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' }"
<p class="text-sm text-gray-700 whitespace-pre-wrap mb-5">{{ ticket.description }}</p>
<div class="grid grid-cols-2 gap-4 text-sm text-gray-600 border-t pt-4">
<div><span class="font-medium">Category:</span> {{ ticket.category }}</div>
<div><span class="font-medium">Priority:</span> {{ ticket.priority }}</div>
<div><span class="font-medium">Submitted by:</span> {{ ticket.submitter?.name ?? '—' }}</div>
<div><span class="font-medium">Assigned to:</span> {{ ticket.assignee?.name ?? 'Unassigned' }}</div>
<div><span class="font-medium">Created:</span> {{ formatDate(ticket.created_at) }}</div>
<div><span class="font-medium">Updated:</span> {{ formatDate(ticket.updated_at) }}</div>
</div>
</div>
<!-- Comments -->
<div class="bg-white rounded-xl shadow p-6 mb-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">
Comments ({{ visibleComments.length }})
</h3>
<div v-if="visibleComments.length === 0" class="text-sm text-gray-400 py-4 text-center">
No comments yet.
</div>
<div class="space-y-4">
<div
v-for="comment in visibleComments"
:key="comment.id"
:class="['rounded-lg p-4 text-sm', comment.is_internal ? 'bg-yellow-50 border border-yellow-200' : 'bg-gray-50']"
> >
<div class="flex items-center justify-between mb-2"> {{ ticket.number }}
<span class="font-medium text-gray-800">{{ comment.author?.name ?? 'Unknown' }}</span> </span>
<span class="text-xs text-gray-400">{{ formatDate(comment.created_at) }}</span>
</div>
<p class="text-gray-700 whitespace-pre-wrap">{{ comment.body }}</p>
<span v-if="comment.is_internal" class="mt-1 inline-block text-xs text-yellow-700 font-medium">Staff note</span>
</div>
</div>
</div>
<!-- Add Comment Form --> <!-- Title (editable inline for agents) -->
<div class="bg-white rounded-xl shadow p-6"> <div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-gray-800 mb-4">Add Comment</h3> <div v-if="editingTitle && isAgent" class="flex items-center gap-2">
<form @submit.prevent="submitComment" class="space-y-4"> <input
<textarea v-model="titleEdit"
v-model="commentForm.body" class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent dark:text-white focus:outline-none"
rows="4" @keyup.enter="saveTitle"
placeholder="Write your comment…" @keyup.esc="editingTitle = false"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': commentErrors.body }"
/> />
<p v-if="commentErrors.body" class="text-xs text-red-500">{{ commentErrors.body }}</p> <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 v-if="isAdmin" class="flex items-center gap-2"> </div>
<input id="is_internal" v-model="commentForm.is_internal" type="checkbox" class="rounded border-gray-300" /> <h1
<label for="is_internal" class="text-sm text-gray-700">Mark as internal staff note</label> v-else
class="text-xl font-semibold text-gray-900 dark:text-white"
: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> </div>
<div class="flex justify-end"> <!-- 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 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
>
<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 dark:text-gray-300">
<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 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
>
<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 dark:text-gray-400">
<span>📅</span>
<input
v-if="isAgent"
v-model="metaForm.due_date"
type="date"
@change="saveMeta"
class="text-xs border-0 bg-transparent dark:text-gray-400 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 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 text-amber-900 dark:text-amber-100'
: isOwnMessage(msg)
? 'bg-indigo-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
]"
>
<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 dark:border-gray-700 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 dark:bg-gray-700 text-gray-600 dark:text-gray-300 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 dark:bg-gray-700 text-gray-600 dark:text-gray-300 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 dark:bg-amber-900/20 dark:border-amber-700'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
]"
></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 <button
type="submit" type="submit"
:disabled="commentProcessing" :disabled="messageForm.processing"
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50" :class="[
style="background-color: var(--color-sidebar-active-bg)" '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-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60'
]"
> >
{{ commentProcessing ? 'Posting…' : 'Post Comment' }} {{ messageForm.processing ? 'Sending…' : 'Send' }}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</AppLayout> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { Link, useForm } from '@inertiajs/vue3' import { Link, useForm, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({ const props = defineProps({
ticket: Object, ticket: Object,
isAdmin: { type: Boolean, default: false }, isAgent: Boolean,
isManager: Boolean,
agents: Array,
priorities: Array,
}) })
const commentForm = useForm({ 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: '', body: '',
is_internal: false, is_internal: false,
}) })
const commentProcessing = ref(false) function isOwnMessage(msg) {
const commentErrors = ref({}) // If the message user_id matches the current user (we don't have auth here easily,
// but for display purposes: agents on right when it's not submitter's message)
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
}
// Non-admins don't see internal notes function startEditTitle() {
const visibleComments = computed(() => titleEdit.value = props.ticket.title
props.isAdmin editingTitle.value = true
? props.ticket.comments }
: props.ticket.comments.filter(c => !c.is_internal)
)
function submitComment() { function saveTitle() {
commentProcessing.value = true router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
commentForm.post(route('ticketing.comments.store', props.ticket.id), { onSuccess: () => { editingTitle.value = false }
onError: (e) => { commentErrors.value = e },
onSuccess: () => { commentForm.reset() },
onFinish: () => { commentProcessing.value = false },
}) })
} }
function formatDate(d) { function saveMeta() {
return new Date(d).toLocaleDateString('en-CA') metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
} }
function formatStatus(s) { function sendMessage() {
const map = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' } messageForm.is_internal = replyTab.value === 'internal'
return map[s] ?? s messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
onSuccess: () => { messageForm.body = '' }
})
} }
function statusClass(s) { function attachFile(event) {
const map = { const file = event.target.files[0]
open: 'bg-blue-100 text-blue-700', if (!file) return
in_progress: 'bg-yellow-100 text-yellow-700', const data = new FormData()
resolved: 'bg-green-100 text-green-700', data.append('file', file)
closed: 'bg-gray-100 text-gray-600', 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 }))
} }
return map[s] ?? 'bg-gray-100 text-gray-600'
} }
function priorityClass(p) { 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 = { const map = {
low: 'bg-gray-100 text-gray-600', open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
medium: 'bg-blue-100 text-blue-700', in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
high: 'bg-orange-100 text-orange-700', pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
urgent: 'bg-red-100 text-red-700', resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
} }
return map[p] ?? 'bg-gray-100 text-gray-600' 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> </script>

View File

@@ -0,0 +1,63 @@
<?php
namespace Dashboard\Ticketing\Http\Controllers;
use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketAttachment;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class TicketAttachmentController extends Controller
{
private const MAX_SIZE_MB = 20;
private function canAccessTicket(Ticket $ticket): bool
{
$user = Auth::user();
$isAgent = TicketingAgentAccess::where('user_id', $user->id)
->where('group_id', $ticket->group_id)
->exists();
return $isAgent || $ticket->submitter_id === $user->id;
}
public function store(Request $request, Ticket $ticket)
{
if (!$this->canAccessTicket($ticket)) {
abort(403);
}
$request->validate([
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
'message_id' => 'nullable|exists:ticket_messages,id',
]);
$file = $request->file('file');
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
TicketAttachment::create([
'ticket_id' => $ticket->id,
'message_id' => $request->message_id,
'filename' => $file->getClientOriginalName(),
'path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
return back()->with('success', 'Attachment uploaded.');
}
public function show(TicketAttachment $attachment)
{
$ticket = $attachment->ticket;
if (!$this->canAccessTicket($ticket)) {
abort(403);
}
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
return Storage::disk($disk)->download($attachment->path, $attachment->filename);
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace Dashboard\Ticketing\Http\Controllers;
use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketComment;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class TicketCommentController extends Controller
{
public function store(Request $request, Ticket $ticket)
{
$user = auth()->user();
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
$request->validate([
'body' => 'required|string',
'is_internal' => 'boolean',
]);
// Non-admins cannot post internal notes
$isInternal = $isAdmin && $request->boolean('is_internal');
TicketComment::create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'body' => $request->body,
'is_internal' => $isInternal,
]);
return redirect()->route('ticketing.show', $ticket)->with('success', 'Comment added.');
}
}

View File

@@ -2,155 +2,250 @@
namespace Dashboard\Ticketing\Http\Controllers; namespace Dashboard\Ticketing\Http\Controllers;
use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\Ticket; use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup;
use Dashboard\Ticketing\Models\TicketingProject;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response;
class TicketController extends Controller class TicketController extends Controller
{ {
public function index(Request $request) private function agentGroupIds(): array
{ {
$user = auth()->user(); return TicketingAgentAccess::where('user_id', Auth::id())
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); ->pluck('group_id')
->toArray();
$query = Ticket::with(['submitter:id,name', 'assignee:id,name']);
if (! $isAdmin) {
$query->where('user_id', $user->id);
} }
if ($search = $request->get('search')) { private function isAgent(int $groupId = null): bool
$query->where(function ($q) use ($search) { {
$q->where('title', 'like', "%{$search}%") $query = TicketingAgentAccess::where('user_id', Auth::id());
->orWhere('description', 'like', "%{$search}%"); if ($groupId) {
$query->where('group_id', $groupId);
}
return $query->exists();
}
private function isManager(int $groupId): bool
{
return TicketingAgentAccess::where('user_id', Auth::id())
->where('group_id', $groupId)
->where('role', 'manager')
->exists();
}
public function index(Request $request): Response
{
$user = Auth::user();
$agentGroupIds = $this->agentGroupIds();
$isAgent = count($agentGroupIds) > 0;
$query = Ticket::with(['group', 'priority', 'project']);
if ($isAgent) {
$query->whereIn('group_id', $agentGroupIds);
// Filters
if ($request->filled('group_id')) {
$query->where('group_id', $request->group_id);
}
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('priority_id')) {
$query->where('priority_id', $request->priority_id);
}
if ($request->filter === 'mine') {
$query->where('assigned_to', $user->id);
} elseif ($request->filter === 'unassigned') {
$query->whereNull('assigned_to');
} elseif ($request->filter === 'pending') {
$query->where('status', 'pending');
} elseif (!$request->filled('status')) {
$query->whereNotIn('status', ['resolved', 'closed']);
}
} else {
$query->where('submitter_id', $user->id);
}
$tickets = $query->latest()->paginate(30)->withQueryString();
// Enrich with submitter name
$userIds = $tickets->pluck('submitter_id')->merge($tickets->pluck('assigned_to'))->filter()->unique();
$users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
$tickets->getCollection()->transform(function ($ticket) use ($users) {
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
$ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null;
return $ticket;
}); });
}
if ($status = $request->get('status')) { $groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
$query->where('status', $status); $priorities = PriorityLevel::whereNull('group_id')
} ->orWhenIn('group_id', $agentGroupIds ?? [])
->orderBy('sort_order')
if ($priority = $request->get('priority')) { ->get();
$query->where('priority', $priority); $projects = TicketingProject::when($isAgent, fn($q) => $q->whereIn('group_id', $agentGroupIds))
} ->where('status', 'active')
->get();
if ($category = $request->get('category')) {
$query->where('category', $category);
}
$tickets = $query->latest()->paginate(25)->withQueryString();
return Inertia::render('Ticketing/Index', [ return Inertia::render('Ticketing/Index', [
'tickets' => $tickets, 'tickets' => $tickets,
'search' => $request->get('search', ''), 'groups' => $groups,
'statusFilter' => $request->get('status', ''), 'priorities' => $priorities,
'priorityFilter' => $request->get('priority', ''), 'projects' => $projects,
'categoryFilter' => $request->get('category', ''), 'isAgent' => $isAgent,
'isAdmin' => $isAdmin, 'filters' => $request->only(['group_id', 'status', 'priority_id', 'filter']),
]); ]);
} }
public function create() public function create(): Response
{ {
return Inertia::render('Ticketing/Create'); $user = Auth::user();
$agentGroupIds = $this->agentGroupIds();
$isAgent = count($agentGroupIds) > 0;
$groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
$priorities = PriorityLevel::orderBy('sort_order')->get();
return Inertia::render('Ticketing/Create', [
'groups' => $groups,
'priorities' => $priorities,
]);
} }
public function store(Request $request) public function store(Request $request)
{ {
$request->validate([ $validated = $request->validate([
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'description' => 'required|string', 'description' => 'required|string',
'category' => 'required|in:IT,Facilities,HR,Other', 'group_id' => 'required|exists:ticketing_groups,id',
'priority' => 'required|in:low,medium,high,urgent', 'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
]); ]);
Ticket::create([ $group = TicketingGroup::findOrFail($validated['group_id']);
'user_id' => auth()->id(), $number = $group->nextTicketNumber();
'title' => $request->title,
'description' => $request->description, $ticket = Ticket::create([
'category' => $request->category, ...$validated,
'priority' => $request->priority, 'number' => $number,
'submitter_id' => Auth::id(),
'status' => 'open', 'status' => 'open',
]); ]);
return redirect()->route('ticketing.index')->with('success', 'Ticket submitted.'); return redirect()->route('ticketing.show', $ticket);
} }
public function show(Ticket $ticket) public function show(Ticket $ticket): Response
{ {
$user = auth()->user(); $user = Auth::user();
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); $isAgent = $this->isAgent($ticket->group_id);
$isSubmitter = $ticket->submitter_id === $user->id;
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403); if (!$isAgent && !$isSubmitter) {
abort(403);
}
$ticket->load([ $ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
'submitter:id,name',
'assignee:id,name', // Enrich messages with user data
'comments.author:id,name', $userIds = $ticket->messages->pluck('user_id')->filter()->unique();
]); $users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
$ticket->messages->each(function ($msg) use ($users) {
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
});
// Agents for assignment picker
$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();
return Inertia::render('Ticketing/Show', [ return Inertia::render('Ticketing/Show', [
'ticket' => $ticket, 'ticket' => $ticket,
'isAdmin' => $isAdmin, 'isAgent' => $isAgent,
'isManager' => $isAgent && $this->isManager($ticket->group_id),
'agents' => $agents,
'priorities' => $priorities,
]); ]);
} }
public function edit(Ticket $ticket) public function edit(Ticket $ticket): Response
{ {
$user = auth()->user(); if (!$this->isAgent($ticket->group_id)) {
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); abort(403);
}
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403); $ticket->load(['group', 'priority', 'project']);
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
->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();
return Inertia::render('Ticketing/Edit', [ return Inertia::render('Ticketing/Edit', [
'ticket' => $ticket, 'ticket' => $ticket,
'isAdmin' => $isAdmin, 'priorities' => $priorities,
'agents' => $agents,
'projects' => $projects,
]); ]);
} }
public function update(Request $request, Ticket $ticket) public function update(Request $request, Ticket $ticket)
{ {
$user = auth()->user(); if (!$this->isAgent($ticket->group_id)) {
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); abort(403);
}
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
$rules = [ $rules = [
'title' => 'required|string|max:255', 'title' => 'sometimes|required|string|max:255',
'description' => 'required|string', 'description' => 'sometimes|required|string',
'category' => 'required|in:IT,Facilities,HR,Other', 'status' => 'sometimes|in:open,in_progress,pending,resolved,closed',
'priority' => 'required|in:low,medium,high,urgent', 'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
'project_id' => 'nullable|exists:ticketing_projects,id',
]; ];
if ($isAdmin) { // assigned_to only settable by agents
$rules['status'] = 'required|in:open,in_progress,resolved,closed'; if ($this->isAgent($ticket->group_id)) {
$rules['assigned_to'] = 'nullable|exists:users,id'; $rules['assigned_to'] = 'nullable|exists:users,id';
} }
$request->validate($rules); $ticket->update($request->validate($rules));
$data = $request->only(['title', 'description', 'category', 'priority']); return back()->with('success', 'Ticket updated.');
if ($isAdmin) {
$data['status'] = $request->status;
$data['assigned_to'] = $request->assigned_to;
}
$ticket->update($data);
return redirect()->route('ticketing.show', $ticket)->with('success', 'Ticket updated.');
} }
public function destroy(Ticket $ticket) public function destroy(Ticket $ticket)
{ {
$user = auth()->user(); if (!$this->isManager($ticket->group_id)) {
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']); abort(403);
}
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
$ticket->delete(); $ticket->delete();
return redirect()->route('ticketing.index');
}
return redirect()->route('ticketing.index')->with('success', 'Ticket deleted.'); public function myTickets(): Response
{
$tickets = Ticket::where('submitter_id', Auth::id())
->with(['group', 'priority'])
->latest()
->paginate(20);
return Inertia::render('Ticketing/MyTickets', [
'tickets' => $tickets,
]);
} }
} }

View File

@@ -0,0 +1,70 @@
<?php
namespace Dashboard\Ticketing\Http\Controllers;
use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketMessage;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
class TicketMessageController extends Controller
{
private function isAgent(int $groupId): bool
{
return TicketingAgentAccess::where('user_id', Auth::id())
->where('group_id', $groupId)
->exists();
}
public function store(Request $request, Ticket $ticket)
{
$user = Auth::user();
$isAgent = $this->isAgent($ticket->group_id);
$isSubmitter = $ticket->submitter_id === $user->id;
if (!$isAgent && !$isSubmitter) {
abort(403);
}
$validated = $request->validate([
'body' => 'required|string',
'is_internal' => 'boolean',
]);
// Only agents can post internal notes
if ($validated['is_internal'] ?? false) {
if (!$isAgent) {
abort(403, 'Only agents can post internal notes.');
}
}
$message = TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => $user->id,
'body' => $validated['body'],
'is_internal' => $validated['is_internal'] ?? false,
'source' => 'web',
]);
// Submitter reply reopens resolved/closed tickets
if ($isSubmitter && in_array($ticket->status, ['resolved', 'closed'])) {
$ticket->update(['status' => 'open']);
}
// Notify submitter if agent replied (not internal)
if ($isAgent && !($validated['is_internal'] ?? false)) {
$this->notifySubmitter($ticket, $message);
}
return back()->with('success', 'Message sent.');
}
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
{
// Placeholder for email notification
// In production: Mail::to($submitterEmail)->send(new TicketReplyMail($ticket, $message));
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Dashboard\Ticketing\Http\Controllers;
use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class TicketingSettingsController extends Controller
{
private function requireAgentAccess(): void
{
$hasAccess = TicketingAgentAccess::where('user_id', Auth::id())->exists();
if (!$hasAccess) {
abort(403);
}
}
private function requireManagerAccess(int $groupId): void
{
$isManager = TicketingAgentAccess::where('user_id', Auth::id())
->where('group_id', $groupId)
->where('role', 'manager')
->exists();
if (!$isManager) {
abort(403);
}
}
public function index(): Response
{
$this->requireAgentAccess();
$userId = Auth::id();
$myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id');
$groups = TicketingGroup::whereIn('id', $myGroupIds)->get();
$agents = TicketingAgentAccess::whereIn('group_id', $myGroupIds)->get();
$agentUserIds = $agents->pluck('user_id')->unique();
$agentUsers = \DB::table('users')->whereIn('id', $agentUserIds)->get(['id', 'name', 'email'])->keyBy('id');
$agents->each(fn($a) => $a->user = $agentUsers[$a->user_id] ?? null);
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $myGroupIds))
->orderBy('sort_order')->get();
return Inertia::render('Ticketing/Settings', [
'groups' => $groups,
'agents' => $agents,
'priorities' => $priorities,
'myGroupIds' => $myGroupIds,
]);
}
public function storeGroup(Request $request)
{
$this->requireAgentAccess();
$validated = $request->validate([
'name' => 'required|string|max:100',
'email_address' => 'nullable|email',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'prefix' => 'required|string|max:10|alpha_num|unique:ticketing_groups,prefix',
]);
$group = TicketingGroup::create($validated);
// Auto-add creator as manager
TicketingAgentAccess::create([
'user_id' => Auth::id(),
'group_id' => $group->id,
'role' => 'manager',
]);
return back()->with('success', 'Group created.');
}
public function updateGroup(Request $request, TicketingGroup $group)
{
$this->requireManagerAccess($group->id);
$validated = $request->validate([
'name' => 'required|string|max:100',
'email_address' => 'nullable|email',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'prefix' => 'required|string|max:10|alpha_num|unique:ticketing_groups,prefix,' . $group->id,
]);
$group->update($validated);
return back()->with('success', 'Group updated.');
}
public function storeAgent(Request $request)
{
$this->requireAgentAccess();
$validated = $request->validate([
'user_id' => 'required|exists:users,id',
'group_id' => 'required|exists:ticketing_groups,id',
'role' => 'required|in:agent,manager',
]);
$this->requireManagerAccess($validated['group_id']);
TicketingAgentAccess::updateOrCreate(
['user_id' => $validated['user_id'], 'group_id' => $validated['group_id']],
['role' => $validated['role']]
);
return back()->with('success', 'Agent added.');
}
public function destroyAgent(TicketingAgentAccess $access)
{
$this->requireManagerAccess($access->group_id);
$access->delete();
return back()->with('success', 'Agent removed.');
}
public function storePriority(Request $request)
{
$this->requireAgentAccess();
$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',
]);
PriorityLevel::create($validated);
return back()->with('success', 'Priority level created.');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmailConnection extends Model
{
protected $table = 'ticketing_email_connections';
protected $fillable = ['group_id', 'type', 'config', 'last_polled_at', 'active'];
protected $casts = [
'config' => 'array',
'active' => 'boolean',
'last_polled_at' => 'datetime',
];
public function group(): BelongsTo
{
return $this->belongsTo(TicketingGroup::class, 'group_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
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');
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'priority_id');
}
}

View File

@@ -9,27 +9,36 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Ticket extends Model class Ticket extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
'assigned_to', 'title', 'description', 'status', 'priority_id', 'due_date',
'title',
'description',
'category',
'priority',
'status',
]; ];
public function submitter(): BelongsTo protected $casts = [
'due_date' => 'date',
];
public function group(): BelongsTo
{ {
return $this->belongsTo(\App\Models\User::class, 'user_id'); return $this->belongsTo(TicketingGroup::class, 'group_id');
} }
public function assignee(): BelongsTo public function priority(): BelongsTo
{ {
return $this->belongsTo(\App\Models\User::class, 'assigned_to'); return $this->belongsTo(PriorityLevel::class, 'priority_id');
} }
public function comments(): HasMany public function project(): BelongsTo
{ {
return $this->hasMany(TicketComment::class); return $this->belongsTo(TicketingProject::class, 'project_id');
}
public function messages(): HasMany
{
return $this->hasMany(TicketMessage::class, 'ticket_id')->orderBy('created_at');
}
public function attachments(): HasMany
{
return $this->hasMany(TicketAttachment::class, 'ticket_id');
} }
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketAttachment extends Model
{
protected $fillable = ['ticket_id', 'message_id', 'filename', 'path', 'mime_type', 'size'];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function message(): BelongsTo
{
return $this->belongsTo(TicketMessage::class, 'message_id');
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketComment extends Model
{
protected $fillable = [
'ticket_id',
'user_id',
'body',
'is_internal',
];
protected $casts = [
'is_internal' => 'boolean',
];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'user_id');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TicketMessage extends Model
{
protected $fillable = [
'ticket_id', 'user_id', 'author_email', 'body',
'is_internal', 'source', 'email_message_id',
];
protected $casts = [
'is_internal' => 'boolean',
];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
public function attachments(): HasMany
{
return $this->hasMany(TicketAttachment::class, 'message_id');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketingAgentAccess extends Model
{
protected $table = 'ticketing_agent_access';
protected $fillable = ['user_id', 'group_id', 'role'];
public function group(): BelongsTo
{
return $this->belongsTo(TicketingGroup::class, 'group_id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TicketingGroup extends Model
{
protected $fillable = ['name', 'email_address', 'color', 'prefix'];
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'group_id');
}
public function priorityLevels(): HasMany
{
return $this->hasMany(PriorityLevel::class, 'group_id');
}
public function agentAccess(): HasMany
{
return $this->hasMany(TicketingAgentAccess::class, 'group_id');
}
public function projects(): HasMany
{
return $this->hasMany(TicketingProject::class, 'group_id');
}
public function emailConnections(): HasMany
{
return $this->hasMany(EmailConnection::class, 'group_id');
}
public function nextTicketNumber(): string
{
$count = $this->tickets()->count() + 1;
return $this->prefix . '-' . str_pad($count, 4, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TicketingProject extends Model
{
protected $fillable = ['group_id', 'name', 'description', 'status', 'created_by'];
public function group(): BelongsTo
{
return $this->belongsTo(TicketingGroup::class, 'group_id');
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'project_id');
}
}

View File

@@ -6,10 +6,7 @@ use Illuminate\Support\ServiceProvider;
class TicketingServiceProvider extends ServiceProvider class TicketingServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void {}
{
//
}
public function boot(): void public function boot(): void
{ {

View File

@@ -1,16 +1,37 @@
<?php <?php
use Dashboard\Ticketing\Http\Controllers\TicketAttachmentController;
use Dashboard\Ticketing\Http\Controllers\TicketController; use Dashboard\Ticketing\Http\Controllers\TicketController;
use Dashboard\Ticketing\Http\Controllers\TicketCommentController; use Dashboard\Ticketing\Http\Controllers\TicketMessageController;
use Dashboard\Ticketing\Http\Controllers\TicketingSettingsController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['web', 'auth', 'app.access:ticketing'])->prefix('app/ticketing')->name('ticketing.')->group(function () { Route::middleware(['web', 'auth', 'app.access:ticketing'])->prefix('app/ticketing')->name('ticketing.')->group(function () {
// Submitter portal (must come before /{ticket} to avoid conflict)
Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets');
// Settings
Route::get('/settings', [TicketingSettingsController::class, 'index'])->name('settings');
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup'])->name('settings.groups.store');
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup'])->name('settings.groups.update');
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');
// Ticket routes
Route::get('/', [TicketController::class, 'index'])->name('index'); Route::get('/', [TicketController::class, 'index'])->name('index');
Route::get('/create', [TicketController::class, 'create'])->name('create'); Route::get('/create', [TicketController::class, 'create'])->name('create');
Route::post('/', [TicketController::class, 'store'])->name('store'); Route::post('/', [TicketController::class, 'store'])->name('store');
// Attachment show (before /{ticket} wildcard)
Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show');
Route::get('/{ticket}', [TicketController::class, 'show'])->name('show'); Route::get('/{ticket}', [TicketController::class, 'show'])->name('show');
Route::get('/{ticket}/edit', [TicketController::class, 'edit'])->name('edit'); Route::get('/{ticket}/edit', [TicketController::class, 'edit'])->name('edit');
Route::put('/{ticket}', [TicketController::class, 'update'])->name('update'); Route::put('/{ticket}', [TicketController::class, 'update'])->name('update');
Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy'); Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy');
Route::post('/{ticket}/comments', [TicketCommentController::class, 'store'])->name('comments.store');
// Messages & attachments
Route::post('/{ticket}/messages', [TicketMessageController::class, 'store'])->name('messages.store');
Route::post('/{ticket}/attachments', [TicketAttachmentController::class, 'store'])->name('attachments.store');
}); });