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",
"description": "Ticketing / help-desk snap-in for the Dashboard platform",
"description": "Ticketing snap-in for dashboard-shell",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0",
"inertiajs/inertia-laravel": "^2.0"
},
"autoload": {
"psr-4": {
"Dashboard\\Ticketing\\": "src/"
@@ -10,14 +14,7 @@
},
"extra": {
"laravel": {
"providers": [
"Dashboard\\Ticketing\\TicketingServiceProvider"
]
"providers": ["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>
<AppLayout title="New Ticket">
<div class="p-6 max-w-2xl">
<div class="flex items-center gap-3 mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline"> Tickets</Link>
<h1 class="text-2xl font-bold text-gray-900">Submit a Ticket</h1>
<div class="max-w-2xl 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">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>
<form @submit.prevent="submit" class="bg-white rounded-xl shadow p-6 space-y-5">
<!-- Title -->
<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
v-model="form.title"
type="text"
maxlength="255"
required
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="{ 'border-red-400': errors.title }"
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.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>
<!-- Description -->
<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
v-model="form.description"
required
rows="5"
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="{ 'border-red-400': errors.description }"
placeholder="Describe the issue in detail..."
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
></textarea>
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 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>
<!-- Category + Priority -->
<div class="grid grid-cols-2 gap-4">
<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>
<!-- Submit -->
<div class="flex justify-end pt-2">
<button
type="submit"
:disabled="processing"
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color: var(--color-sidebar-active-bg)"
:disabled="form.processing"
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"
>
{{ processing ? 'Submitting…' : 'Submit Ticket' }}
<span v-if="form.processing">Submitting</span>
<span v-else>Submit Ticket</span>
</button>
</div>
</form>
</div>
</AppLayout>
</template>
<script setup>
import { ref } from 'vue'
import { computed } from 'vue'
import { Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const form = useForm({
title: '',
description: '',
category: '',
priority: 'medium',
const props = defineProps({
groups: Array,
priorities: Array,
})
const processing = ref(false)
const errors = ref({})
const form = useForm({
group_id: '',
title: '',
description: '',
priority_id: null,
due_date: '',
})
const filteredPriorities = computed(() => {
if (!form.group_id) return props.priorities.filter(p => !p.group_id)
return props.priorities.filter(p => !p.group_id || p.group_id === Number(form.group_id))
})
function submit() {
processing.value = true
form.post(route('ticketing.store'), {
onError: (e) => { errors.value = e },
onFinish: () => { processing.value = false },
})
form.post(route('ticketing.store'))
}
</script>

View File

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

View File

@@ -1,197 +1,255 @@
<template>
<AppLayout title="Ticketing">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Tickets</h1>
<div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
<!-- Left Rail (240px) -->
<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">
<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
:href="route('ticketing.create')"
class="inline-flex items-center px-4 py-2 text-white rounded-lg transition"
style="background-color: var(--color-sidebar-active-bg)"
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"
>
+ New Ticket
+ New
</Link>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<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">
<div class="overflow-y-auto flex-1">
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500 text-sm">
No tickets found.
</td>
</tr>
<tr
</div>
<button
v-for="ticket in tickets.data"
: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>
<td class="px-6 py-4 font-medium text-gray-900 max-w-xs truncate">{{ ticket.title }}</td>
<td class="px-6 py-4 text-gray-600">{{ ticket.category }}</td>
<td class="px-6 py-4">
<span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)">
{{ ticket.priority }}
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<!-- Ticket number badge -->
<span
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>
</td>
<td class="px-6 py-4">
<span class="px-2 py-1 rounded text-xs font-medium" :class="statusClass(ticket.status)">
{{ formatStatus(ticket.status) }}
</span>
</td>
<td v-if="isAdmin" class="px-6 py-4 text-gray-600">{{ ticket.submitter?.name ?? '' }}</td>
<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>
<!-- Priority dot -->
<span
v-if="ticket.priority"
class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: ticket.priority.color }"
:title="ticket.priority.name"
></span>
</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>
<!-- Pagination -->
<div v-if="tickets.last_page > 1" class="mt-4 flex items-center justify-between">
<p class="text-sm text-gray-600">
Showing {{ tickets.from }}{{ tickets.to }} of {{ tickets.total }} tickets
</p>
<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">
<span class="text-xs text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
<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.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.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="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>
</AppLayout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Link, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({
tickets: Object,
search: { type: String, default: '' },
statusFilter: { type: String, default: '' },
priorityFilter: { type: String, default: '' },
categoryFilter: { type: String, default: '' },
isAdmin: { type: Boolean, default: false },
groups: Array,
priorities: Array,
projects: Array,
isAgent: Boolean,
filters: Object,
})
const searchInput = ref(props.search)
const filters = ref({
status: props.statusFilter,
priority: props.priorityFilter,
category: props.categoryFilter,
const selectedTicketId = ref(null)
const activeGroupId = ref(props.filters?.group_id || null)
const activeFilter = ref(props.filters?.filter || 'all')
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(() =>
searchInput.value || filters.value.status || filters.value.priority || filters.value.category
)
const selectedTicket = computed(() => {
return props.tickets.data.find(t => t.id === selectedTicketId.value) || null
})
function applyFilters() {
router.get(route('ticketing.index'), {
search: searchInput.value,
status: filters.value.status,
priority: filters.value.priority,
category: filters.value.category,
}, { preserveState: true, replace: true })
function selectTicket(ticket) {
selectedTicketId.value = ticket.id
}
function clearFilters() {
searchInput.value = ''
filters.value = { status: '', priority: '', category: '' }
router.get(route('ticketing.index'), {}, { preserveState: true, replace: true })
function applyFilter(newFilters) {
const merged = { ...props.filters, ...newFilters }
// Remove undefined/null values
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) {
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) {
function statusLabel(status) {
const map = {
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-600',
open: 'Open',
in_progress: 'In Progress',
pending: 'Pending',
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 = {
low: 'bg-gray-100 text-gray-600',
medium: 'bg-blue-100 text-blue-700',
high: 'bg-orange-100 text-orange-700',
urgent: 'bg-red-100 text-red-700',
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
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>

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>
<AppLayout :title="`Ticket #${ticket.id}`">
<div class="p-6 max-w-3xl">
<div class="flex items-center gap-3 mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline"> 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 class="max-w-4xl mx-auto py-8 px-4">
<!-- Header -->
<div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link>
</div>
<!-- Ticket Details -->
<div class="bg-white rounded-xl shadow p-6 mb-6">
<div class="flex items-start justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">{{ ticket.title }}</h2>
<Link :href="route('ticketing.edit', ticket.id)" class="text-sm text-indigo-600 hover:underline">Edit</Link>
</div>
<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="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<!-- Ticket Header -->
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-3 flex-wrap">
<span
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-gray-800">{{ comment.author?.name ?? 'Unknown' }}</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>
{{ ticket.number }}
</span>
<!-- Add Comment Form -->
<div class="bg-white rounded-xl shadow p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">Add Comment</h3>
<form @submit.prevent="submitComment" class="space-y-4">
<textarea
v-model="commentForm.body"
rows="4"
placeholder="Write your comment…"
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 }"
<!-- Title (editable inline for agents) -->
<div class="flex-1 min-w-0">
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
<input
v-model="titleEdit"
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent dark:text-white focus:outline-none"
@keyup.enter="saveTitle"
@keyup.esc="editingTitle = false"
/>
<p v-if="commentErrors.body" class="text-xs text-red-500">{{ commentErrors.body }}</p>
<div v-if="isAdmin" class="flex items-center gap-2">
<input id="is_internal" v-model="commentForm.is_internal" type="checkbox" class="rounded border-gray-300" />
<label for="is_internal" class="text-sm text-gray-700">Mark as internal staff note</label>
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
</div>
<h1
v-else
class="text-xl font-semibold text-gray-900 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 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
type="submit"
:disabled="commentProcessing"
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color: var(--color-sidebar-active-bg)"
:disabled="messageForm.processing"
:class="[
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
replyTab === 'internal'
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
: 'bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60'
]"
>
{{ commentProcessing ? 'Posting…' : 'Post Comment' }}
{{ messageForm.processing ? 'Sending…' : 'Send' }}
</button>
</div>
</form>
</div>
</div>
</AppLayout>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
import { Link, useForm, router } from '@inertiajs/vue3'
const props = defineProps({
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: '',
is_internal: false,
})
const commentProcessing = ref(false)
const commentErrors = ref({})
function isOwnMessage(msg) {
// 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
const visibleComments = computed(() =>
props.isAdmin
? props.ticket.comments
: props.ticket.comments.filter(c => !c.is_internal)
)
function startEditTitle() {
titleEdit.value = props.ticket.title
editingTitle.value = true
}
function submitComment() {
commentProcessing.value = true
commentForm.post(route('ticketing.comments.store', props.ticket.id), {
onError: (e) => { commentErrors.value = e },
onSuccess: () => { commentForm.reset() },
onFinish: () => { commentProcessing.value = false },
function saveTitle() {
router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
onSuccess: () => { editingTitle.value = false }
})
}
function formatDate(d) {
return new Date(d).toLocaleDateString('en-CA')
function saveMeta() {
metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
}
function formatStatus(s) {
const map = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' }
return map[s] ?? s
function sendMessage() {
messageForm.is_internal = replyTab.value === 'internal'
messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
onSuccess: () => { messageForm.body = '' }
})
}
function statusClass(s) {
const map = {
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-600',
function attachFile(event) {
const file = event.target.files[0]
if (!file) return
const data = new FormData()
data.append('file', file)
router.post(route('ticketing.attachments.store', { ticket: props.ticket.id }), data)
}
function confirmDelete() {
if (confirm(`Delete ticket ${props.ticket.number}? This cannot be undone.`)) {
router.delete(route('ticketing.destroy', { ticket: props.ticket.id }))
}
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 = {
low: 'bg-gray-100 text-gray-600',
medium: 'bg-blue-100 text-blue-700',
high: 'bg-orange-100 text-orange-700',
urgent: 'bg-red-100 text-red-700',
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
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>

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;
use Dashboard\Ticketing\Models\PriorityLevel;
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\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class TicketController extends Controller
{
public function index(Request $request)
private function agentGroupIds(): array
{
$user = auth()->user();
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
$query = Ticket::with(['submitter:id,name', 'assignee:id,name']);
if (! $isAdmin) {
$query->where('user_id', $user->id);
return TicketingAgentAccess::where('user_id', Auth::id())
->pluck('group_id')
->toArray();
}
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
private function isAgent(int $groupId = null): bool
{
$query = TicketingAgentAccess::where('user_id', Auth::id());
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')) {
$query->where('status', $status);
}
if ($priority = $request->get('priority')) {
$query->where('priority', $priority);
}
if ($category = $request->get('category')) {
$query->where('category', $category);
}
$tickets = $query->latest()->paginate(25)->withQueryString();
$groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
$priorities = PriorityLevel::whereNull('group_id')
->orWhenIn('group_id', $agentGroupIds ?? [])
->orderBy('sort_order')
->get();
$projects = TicketingProject::when($isAgent, fn($q) => $q->whereIn('group_id', $agentGroupIds))
->where('status', 'active')
->get();
return Inertia::render('Ticketing/Index', [
'tickets' => $tickets,
'search' => $request->get('search', ''),
'statusFilter' => $request->get('status', ''),
'priorityFilter' => $request->get('priority', ''),
'categoryFilter' => $request->get('category', ''),
'isAdmin' => $isAdmin,
'groups' => $groups,
'priorities' => $priorities,
'projects' => $projects,
'isAgent' => $isAgent,
'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)
{
$request->validate([
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string',
'category' => 'required|in:IT,Facilities,HR,Other',
'priority' => 'required|in:low,medium,high,urgent',
'group_id' => 'required|exists:ticketing_groups,id',
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
]);
Ticket::create([
'user_id' => auth()->id(),
'title' => $request->title,
'description' => $request->description,
'category' => $request->category,
'priority' => $request->priority,
$group = TicketingGroup::findOrFail($validated['group_id']);
$number = $group->nextTicketNumber();
$ticket = Ticket::create([
...$validated,
'number' => $number,
'submitter_id' => Auth::id(),
'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();
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
$user = Auth::user();
$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([
'submitter:id,name',
'assignee:id,name',
'comments.author:id,name',
]);
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
// Enrich messages with user data
$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', [
'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();
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
if (!$this->isAgent($ticket->group_id)) {
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', [
'ticket' => $ticket,
'isAdmin' => $isAdmin,
'priorities' => $priorities,
'agents' => $agents,
'projects' => $projects,
]);
}
public function update(Request $request, Ticket $ticket)
{
$user = auth()->user();
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
if (!$this->isAgent($ticket->group_id)) {
abort(403);
}
$rules = [
'title' => 'required|string|max:255',
'description' => 'required|string',
'category' => 'required|in:IT,Facilities,HR,Other',
'priority' => 'required|in:low,medium,high,urgent',
'title' => 'sometimes|required|string|max:255',
'description' => 'sometimes|required|string',
'status' => 'sometimes|in:open,in_progress,pending,resolved,closed',
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
'project_id' => 'nullable|exists:ticketing_projects,id',
];
if ($isAdmin) {
$rules['status'] = 'required|in:open,in_progress,resolved,closed';
// assigned_to only settable by agents
if ($this->isAgent($ticket->group_id)) {
$rules['assigned_to'] = 'nullable|exists:users,id';
}
$request->validate($rules);
$ticket->update($request->validate($rules));
$data = $request->only(['title', 'description', 'category', 'priority']);
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.');
return back()->with('success', 'Ticket updated.');
}
public function destroy(Ticket $ticket)
{
$user = auth()->user();
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
if (!$this->isManager($ticket->group_id)) {
abort(403);
}
$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
{
protected $fillable = [
'user_id',
'assigned_to',
'title',
'description',
'category',
'priority',
'status',
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
'title', 'description', 'status', 'priority_id', 'due_date',
];
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
{
public function register(): void
{
//
}
public function register(): void {}
public function boot(): void
{

View File

@@ -1,16 +1,37 @@
<?php
use Dashboard\Ticketing\Http\Controllers\TicketAttachmentController;
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;
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('/create', [TicketController::class, 'create'])->name('create');
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}/edit', [TicketController::class, 'edit'])->name('edit');
Route::put('/{ticket}', [TicketController::class, 'update'])->name('update');
Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy');
Route::post('/{ticket}/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');
});