feat: full dashboard-ticketing scaffold with data model, controllers, Vue pages
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
73
resources/js/Pages/Ticketing/MyTickets.vue
Normal file
73
resources/js/Pages/Ticketing/MyTickets.vue
Normal 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>
|
||||
299
resources/js/Pages/Ticketing/Settings.vue
Normal file
299
resources/js/Pages/Ticketing/Settings.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
63
src/Http/Controllers/TicketAttachmentController.php
Normal file
63
src/Http/Controllers/TicketAttachmentController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
70
src/Http/Controllers/TicketMessageController.php
Normal file
70
src/Http/Controllers/TicketMessageController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
144
src/Http/Controllers/TicketingSettingsController.php
Normal file
144
src/Http/Controllers/TicketingSettingsController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
24
src/Models/EmailConnection.php
Normal file
24
src/Models/EmailConnection.php
Normal 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');
|
||||
}
|
||||
}
|
||||
24
src/Models/PriorityLevel.php
Normal file
24
src/Models/PriorityLevel.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
21
src/Models/TicketAttachment.php
Normal file
21
src/Models/TicketAttachment.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
29
src/Models/TicketMessage.php
Normal file
29
src/Models/TicketMessage.php
Normal 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');
|
||||
}
|
||||
}
|
||||
18
src/Models/TicketingAgentAccess.php
Normal file
18
src/Models/TicketingAgentAccess.php
Normal 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');
|
||||
}
|
||||
}
|
||||
42
src/Models/TicketingGroup.php
Normal file
42
src/Models/TicketingGroup.php
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/Models/TicketingProject.php
Normal file
22
src/Models/TicketingProject.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,7 @@ use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class TicketingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
public function register(): void {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user