feat: full dashboard-ticketing scaffold with data model, controllers, Vue pages
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard/ticketing",
|
"name": "dashboard/ticketing",
|
||||||
"description": "Ticketing / help-desk snap-in for the Dashboard platform",
|
"description": "Ticketing snap-in for dashboard-shell",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"illuminate/support": "^11.0|^12.0",
|
||||||
|
"inertiajs/inertia-laravel": "^2.0"
|
||||||
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Dashboard\\Ticketing\\": "src/"
|
"Dashboard\\Ticketing\\": "src/"
|
||||||
@@ -10,14 +14,7 @@
|
|||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"providers": [
|
"providers": ["Dashboard\\Ticketing\\TicketingServiceProvider"]
|
||||||
"Dashboard\\Ticketing\\TicketingServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^8.2",
|
|
||||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
|
||||||
"inertiajs/inertia-laravel": "^1.0|^2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<AppLayout title="New Ticket">
|
<div class="max-w-2xl mx-auto py-8 px-4">
|
||||||
<div class="p-6 max-w-2xl">
|
<div class="mb-6">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
||||||
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline">← Tickets</Link>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">Submit a Ticket</h1>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Submit a Ticket</h1>
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||||
|
<!-- Group -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Group <span class="text-red-500">*</span></label>
|
||||||
|
<select
|
||||||
|
v-model="form.group_id"
|
||||||
|
required
|
||||||
|
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select a group</option>
|
||||||
|
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="form.errors.group_id" class="text-xs text-red-600 mt-1">{{ form.errors.group_id }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submit" class="bg-white rounded-xl shadow p-6 space-y-5">
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title <span class="text-red-500">*</span></label>
|
||||||
<input
|
<input
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
type="text"
|
type="text"
|
||||||
maxlength="255"
|
required
|
||||||
placeholder="Brief summary of the issue"
|
placeholder="Brief summary of the issue"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
|
||||||
:class="{ 'border-red-400': errors.title }"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p>
|
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description <span class="text-red-500">*</span></label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
|
required
|
||||||
rows="5"
|
rows="5"
|
||||||
placeholder="Describe the issue in detail…"
|
placeholder="Describe the issue in detail..."
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
|
||||||
:class="{ 'border-red-400': errors.description }"
|
></textarea>
|
||||||
|
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
|
||||||
|
<select
|
||||||
|
v-model="form.priority_id"
|
||||||
|
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option :value="null">No priority</option>
|
||||||
|
<option v-for="p in filteredPriorities" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Due Date -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Due Date <span class="text-gray-400 font-normal">(optional)</span></label>
|
||||||
|
<input
|
||||||
|
v-model="form.due_date"
|
||||||
|
type="date"
|
||||||
|
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.description" class="mt-1 text-xs text-red-500">{{ errors.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category + Priority -->
|
<!-- Submit -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="flex justify-end pt-2">
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label>
|
|
||||||
<select
|
|
||||||
v-model="form.category"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
:class="{ 'border-red-400': errors.category }"
|
|
||||||
>
|
|
||||||
<option value="">Select category</option>
|
|
||||||
<option value="IT">IT</option>
|
|
||||||
<option value="Facilities">Facilities</option>
|
|
||||||
<option value="HR">HR</option>
|
|
||||||
<option value="Other">Other</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="errors.category" class="mt-1 text-xs text-red-500">{{ errors.category }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority <span class="text-red-500">*</span></label>
|
|
||||||
<select
|
|
||||||
v-model="form.priority"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
:class="{ 'border-red-400': errors.priority }"
|
|
||||||
>
|
|
||||||
<option value="">Select priority</option>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="urgent">Urgent</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="errors.priority" class="mt-1 text-xs text-red-500">{{ errors.priority }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<Link :href="route('ticketing.index')" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
|
||||||
Cancel
|
|
||||||
</Link>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="processing"
|
:disabled="form.processing"
|
||||||
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
|
class="inline-flex items-center gap-2 bg-indigo-600 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-60 transition"
|
||||||
style="background-color: var(--color-sidebar-active-bg)"
|
|
||||||
>
|
>
|
||||||
{{ processing ? 'Submitting…' : 'Submit Ticket' }}
|
<span v-if="form.processing">Submitting…</span>
|
||||||
|
<span v-else>Submit Ticket</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Link, useForm } from '@inertiajs/vue3'
|
import { Link, useForm } from '@inertiajs/vue3'
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
|
|
||||||
const form = useForm({
|
const props = defineProps({
|
||||||
title: '',
|
groups: Array,
|
||||||
description: '',
|
priorities: Array,
|
||||||
category: '',
|
|
||||||
priority: 'medium',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const processing = ref(false)
|
const form = useForm({
|
||||||
const errors = ref({})
|
group_id: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
priority_id: null,
|
||||||
|
due_date: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredPriorities = computed(() => {
|
||||||
|
if (!form.group_id) return props.priorities.filter(p => !p.group_id)
|
||||||
|
return props.priorities.filter(p => !p.group_id || p.group_id === Number(form.group_id))
|
||||||
|
})
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
processing.value = true
|
form.post(route('ticketing.store'))
|
||||||
form.post(route('ticketing.store'), {
|
|
||||||
onError: (e) => { errors.value = e },
|
|
||||||
onFinish: () => { processing.value = false },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,147 +1,114 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout :title="`Edit Ticket #${ticket.id}`">
|
<div class="max-w-2xl mx-auto py-8 px-4">
|
||||||
<div class="p-6 max-w-2xl">
|
<div class="mb-6">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<Link :href="route('ticketing.show', { ticket: ticket.id })" class="text-sm text-indigo-600 hover:underline">← Back to ticket</Link>
|
||||||
<Link :href="route('ticketing.show', ticket.id)" class="text-sm text-gray-500 hover:underline">← Ticket #{{ ticket.id }}</Link>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white mt-2">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Edit Ticket</h1>
|
Edit <span class="font-mono text-base">{{ ticket.number }}</span>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submit" class="bg-white rounded-xl shadow p-6 space-y-5">
|
<form @submit.prevent="submit" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-5">
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
|
||||||
<input
|
<input v-model="form.title" type="text" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" />
|
||||||
v-model="form.title"
|
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
|
||||||
type="text"
|
|
||||||
maxlength="255"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
:class="{ 'border-red-400': errors.title }"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
|
||||||
<textarea
|
<textarea v-model="form.description" rows="6" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"></textarea>
|
||||||
v-model="form.description"
|
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
|
||||||
rows="5"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
:class="{ 'border-red-400': errors.description }"
|
|
||||||
/>
|
|
||||||
<p v-if="errors.description" class="mt-1 text-xs text-red-500">{{ errors.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category + Priority -->
|
<!-- Status -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
||||||
<select
|
<select v-model="form.status" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
|
||||||
v-model="form.category"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
:class="{ 'border-red-400': errors.category }"
|
|
||||||
>
|
|
||||||
<option value="IT">IT</option>
|
|
||||||
<option value="Facilities">Facilities</option>
|
|
||||||
<option value="HR">HR</option>
|
|
||||||
<option value="Other">Other</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="errors.category" class="mt-1 text-xs text-red-500">{{ errors.category }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Priority <span class="text-red-500">*</span></label>
|
|
||||||
<select
|
|
||||||
v-model="form.priority"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
:class="{ 'border-red-400': errors.priority }"
|
|
||||||
>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="urgent">Urgent</option>
|
|
||||||
</select>
|
|
||||||
<p v-if="errors.priority" class="mt-1 text-xs text-red-500">{{ errors.priority }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Admin-only: Status + Assigned To -->
|
|
||||||
<template v-if="isAdmin">
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
v-model="form.status"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
>
|
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
<option value="in_progress">In Progress</option>
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
<option value="resolved">Resolved</option>
|
<option value="resolved">Resolved</option>
|
||||||
<option value="closed">Closed</option>
|
<option value="closed">Closed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center pt-2">
|
<!-- Priority -->
|
||||||
<button
|
<div>
|
||||||
type="button"
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
|
||||||
@click="destroy"
|
<select v-model="form.priority_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
|
||||||
class="px-4 py-2 text-sm text-red-600 hover:text-red-800 transition"
|
<option :value="null">No priority</option>
|
||||||
>
|
<option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||||
Delete Ticket
|
</select>
|
||||||
</button>
|
</div>
|
||||||
<div class="flex gap-3">
|
|
||||||
<Link :href="route('ticketing.show', ticket.id)" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
|
<!-- Assignee -->
|
||||||
Cancel
|
<div>
|
||||||
</Link>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Assignee</label>
|
||||||
|
<select v-model="form.assigned_to" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
|
||||||
|
<option :value="null">Unassigned</option>
|
||||||
|
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
|
||||||
|
<select v-model="form.project_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
|
||||||
|
<option :value="null">No project</option>
|
||||||
|
<option v-for="proj in projects" :key="proj.id" :value="proj.id">{{ proj.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Due Date -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Due Date</label>
|
||||||
|
<input v-model="form.due_date" type="date" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-end pt-2">
|
||||||
|
<Link
|
||||||
|
:href="route('ticketing.show', { ticket: ticket.id })"
|
||||||
|
class="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>Cancel</Link>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="processing"
|
:disabled="form.processing"
|
||||||
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
|
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-60 transition"
|
||||||
style="background-color: var(--color-sidebar-active-bg)"
|
|
||||||
>
|
>
|
||||||
{{ processing ? 'Saving…' : 'Save Changes' }}
|
{{ form.processing ? 'Saving…' : 'Save Changes' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { Link, useForm } from '@inertiajs/vue3'
|
||||||
import { Link, useForm, router } from '@inertiajs/vue3'
|
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
ticket: Object,
|
ticket: Object,
|
||||||
isAdmin: { type: Boolean, default: false },
|
priorities: Array,
|
||||||
|
agents: Array,
|
||||||
|
projects: Array,
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
title: props.ticket.title,
|
title: props.ticket.title,
|
||||||
description: props.ticket.description,
|
description: props.ticket.description,
|
||||||
category: props.ticket.category,
|
|
||||||
priority: props.ticket.priority,
|
|
||||||
status: props.ticket.status,
|
status: props.ticket.status,
|
||||||
assigned_to: props.ticket.assigned_to ?? '',
|
priority_id: props.ticket.priority_id,
|
||||||
|
assigned_to: props.ticket.assigned_to,
|
||||||
|
project_id: props.ticket.project_id,
|
||||||
|
due_date: props.ticket.due_date,
|
||||||
})
|
})
|
||||||
|
|
||||||
const processing = ref(false)
|
|
||||||
const errors = ref({})
|
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
processing.value = true
|
form.put(route('ticketing.update', { ticket: props.ticket.id }), {
|
||||||
form.put(route('ticketing.update', props.ticket.id), {
|
onSuccess: () => {
|
||||||
onError: (e) => { errors.value = e },
|
// redirect happens server side
|
||||||
onFinish: () => { processing.value = false },
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
|
||||||
if (!confirm('Delete this ticket? This cannot be undone.')) return
|
|
||||||
router.delete(route('ticketing.destroy', props.ticket.id))
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,197 +1,255 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout title="Ticketing">
|
<div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||||
<div class="p-6">
|
<!-- Left Rail (240px) -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<aside class="w-60 flex-shrink-0 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-y-auto">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Tickets</h1>
|
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Ticketing</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Switcher -->
|
||||||
|
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 uppercase tracking-wide">Group</label>
|
||||||
|
<select
|
||||||
|
v-model="activeGroupId"
|
||||||
|
@change="applyFilter({ group_id: activeGroupId || undefined })"
|
||||||
|
class="w-full text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option :value="null">All Groups</option>
|
||||||
|
<option v-for="g in groups" :key="g.id" :value="g.id">
|
||||||
|
<span :style="{ color: g.color }">●</span> {{ g.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Views -->
|
||||||
|
<nav class="p-3 space-y-1 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
v-for="view in filterViews"
|
||||||
|
:key="view.key"
|
||||||
|
@click="applyFilter({ filter: view.key })"
|
||||||
|
:class="[
|
||||||
|
'w-full text-left px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||||
|
activeFilter === view.key
|
||||||
|
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200 font-medium'
|
||||||
|
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ view.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Priority Filters -->
|
||||||
|
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Priority</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="p in priorities"
|
||||||
|
:key="p.id"
|
||||||
|
@click="applyFilter({ priority_id: filters.priority_id === p.id ? undefined : p.id })"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors',
|
||||||
|
filters.priority_id === p.id
|
||||||
|
? 'border-current font-semibold'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300'
|
||||||
|
]"
|
||||||
|
:style="filters.priority_id === p.id ? { color: p.color, borderColor: p.color } : {}"
|
||||||
|
>
|
||||||
|
<span :style="{ color: p.color }">●</span> {{ p.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects -->
|
||||||
|
<div class="p-3">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Projects</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p v-if="!projects.length" class="text-xs text-gray-400 italic">No active projects</p>
|
||||||
|
<button
|
||||||
|
v-for="proj in projects"
|
||||||
|
:key="proj.id"
|
||||||
|
class="w-full text-left px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md truncate"
|
||||||
|
>
|
||||||
|
📁 {{ proj.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Middle Panel: Ticket Queue -->
|
||||||
|
<main class="flex-1 flex flex-col min-w-0 border-r border-gray-200 dark:border-gray-700" style="max-width: 520px;">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{{ currentViewLabel }}
|
||||||
|
<span class="ml-1.5 text-xs font-normal text-gray-400">({{ tickets.total }})</span>
|
||||||
|
</h2>
|
||||||
<Link
|
<Link
|
||||||
:href="route('ticketing.create')"
|
:href="route('ticketing.create')"
|
||||||
class="inline-flex items-center px-4 py-2 text-white rounded-lg transition"
|
class="inline-flex items-center gap-1 text-xs bg-indigo-600 text-white px-3 py-1.5 rounded-md hover:bg-indigo-700 transition"
|
||||||
style="background-color: var(--color-sidebar-active-bg)"
|
|
||||||
>
|
>
|
||||||
+ New Ticket
|
+ New
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<div class="overflow-y-auto flex-1">
|
||||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500 text-sm">
|
||||||
<div class="relative flex-1 min-w-[200px] max-w-md">
|
|
||||||
<input
|
|
||||||
v-model="searchInput"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search tickets…"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
@keydown.enter="applyFilters"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select v-model="filters.status" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
|
||||||
<option value="">All Statuses</option>
|
|
||||||
<option value="open">Open</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<option value="resolved">Resolved</option>
|
|
||||||
<option value="closed">Closed</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select v-model="filters.priority" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
|
||||||
<option value="">All Priorities</option>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="urgent">Urgent</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select v-model="filters.category" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
<option value="IT">IT</option>
|
|
||||||
<option value="Facilities">Facilities</option>
|
|
||||||
<option value="HR">HR</option>
|
|
||||||
<option value="Other">Other</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="applyFilters"
|
|
||||||
class="px-4 py-2 text-sm text-white rounded-lg transition"
|
|
||||||
style="background-color: var(--color-sidebar-active-bg)"
|
|
||||||
>Search</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="hasActiveFilters"
|
|
||||||
@click="clearFilters"
|
|
||||||
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition"
|
|
||||||
>✕ Clear</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="bg-gray-50 text-gray-600 uppercase text-xs">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 text-left">#</th>
|
|
||||||
<th class="px-6 py-3 text-left">Title</th>
|
|
||||||
<th class="px-6 py-3 text-left">Category</th>
|
|
||||||
<th class="px-6 py-3 text-left">Priority</th>
|
|
||||||
<th class="px-6 py-3 text-left">Status</th>
|
|
||||||
<th v-if="isAdmin" class="px-6 py-3 text-left">Submitter</th>
|
|
||||||
<th class="px-6 py-3 text-left">Created</th>
|
|
||||||
<th class="px-6 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-100">
|
|
||||||
<tr v-if="tickets.data.length === 0">
|
|
||||||
<td :colspan="isAdmin ? 8 : 7" class="px-6 py-10 text-center text-gray-400">
|
|
||||||
No tickets found.
|
No tickets found.
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<button
|
||||||
<tr
|
|
||||||
v-for="ticket in tickets.data"
|
v-for="ticket in tickets.data"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
class="hover:bg-gray-50 transition"
|
@click="selectTicket(ticket)"
|
||||||
|
:class="[
|
||||||
|
'w-full text-left px-4 py-3 border-b border-gray-100 dark:border-gray-700 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700',
|
||||||
|
selectedTicketId === ticket.id ? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-500' : ''
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<td class="px-6 py-4 text-gray-500">{{ ticket.id }}</td>
|
<div class="flex items-start justify-between gap-2">
|
||||||
<td class="px-6 py-4 font-medium text-gray-900 max-w-xs truncate">{{ ticket.title }}</td>
|
<div class="min-w-0 flex-1">
|
||||||
<td class="px-6 py-4 text-gray-600">{{ ticket.category }}</td>
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
<td class="px-6 py-4">
|
<!-- Ticket number badge -->
|
||||||
<span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)">
|
<span
|
||||||
{{ ticket.priority }}
|
class="inline-block text-xs font-mono font-semibold px-1.5 py-0.5 rounded text-white"
|
||||||
|
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
||||||
|
>
|
||||||
|
{{ ticket.number }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<!-- Priority dot -->
|
||||||
<td class="px-6 py-4">
|
<span
|
||||||
<span class="px-2 py-1 rounded text-xs font-medium" :class="statusClass(ticket.status)">
|
v-if="ticket.priority"
|
||||||
{{ formatStatus(ticket.status) }}
|
class="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
</span>
|
:style="{ backgroundColor: ticket.priority.color }"
|
||||||
</td>
|
:title="ticket.priority.name"
|
||||||
<td v-if="isAdmin" class="px-6 py-4 text-gray-600">{{ ticket.submitter?.name ?? '—' }}</td>
|
></span>
|
||||||
<td class="px-6 py-4 text-gray-500">{{ formatDate(ticket.created_at) }}</td>
|
|
||||||
<td class="px-6 py-4 text-right space-x-3">
|
|
||||||
<Link :href="route('ticketing.show', ticket.id)" class="text-xs text-indigo-600 hover:underline">View</Link>
|
|
||||||
<Link :href="route('ticketing.edit', ticket.id)" class="text-xs text-gray-500 hover:underline">Edit</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">{{ ticket.title }}</p>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">
|
||||||
|
{{ ticket.submitter?.name || ticket.submitter?.email || '—' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 text-right space-y-1">
|
||||||
|
<span :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-0.5 rounded-full font-medium">
|
||||||
|
{{ statusLabel(ticket.status) }}
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-400">{{ timeAgo(ticket.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div v-if="tickets.last_page > 1" class="mt-4 flex items-center justify-between">
|
<div v-if="tickets.last_page > 1" class="px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex justify-between items-center">
|
||||||
<p class="text-sm text-gray-600">
|
<span class="text-xs text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
|
||||||
Showing {{ tickets.from }}–{{ tickets.to }} of {{ tickets.total }} tickets
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="px-3 py-1 text-sm rounded border border-gray-300 hover:bg-gray-50 transition">← Prev</Link>
|
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-xs text-indigo-600 hover:underline">← Prev</Link>
|
||||||
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="px-3 py-1 text-sm rounded border border-gray-300 hover:bg-gray-50 transition">Next →</Link>
|
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-xs text-indigo-600 hover:underline">Next →</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Right Panel: Ticket Detail -->
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col bg-white dark:bg-gray-800">
|
||||||
|
<div v-if="!selectedTicketId" class="flex items-center justify-center h-full text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
Select a ticket to view details
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col h-full">
|
||||||
|
<!-- We use an iframe-like approach: navigate to show page embedded -->
|
||||||
|
<!-- For now, reload the show page in a contained div via Inertia visit -->
|
||||||
|
<div class="p-4 text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded text-white"
|
||||||
|
:style="{ backgroundColor: selectedTicket?.group?.color || '#6366f1' }">
|
||||||
|
{{ selectedTicket?.number }}
|
||||||
|
</span>
|
||||||
|
<span class="font-medium text-gray-800 dark:text-gray-100 truncate">{{ selectedTicket?.title }}</span>
|
||||||
|
<Link :href="route('ticketing.show', { ticket: selectedTicketId })" class="ml-auto text-xs text-indigo-600 hover:underline">Open →</Link>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
|
||||||
|
<Link
|
||||||
|
:href="route('ticketing.show', { ticket: selectedTicketId })"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-indigo-600 hover:underline"
|
||||||
|
>
|
||||||
|
View full conversation →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Link, router } from '@inertiajs/vue3'
|
import { Link, router } from '@inertiajs/vue3'
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tickets: Object,
|
tickets: Object,
|
||||||
search: { type: String, default: '' },
|
groups: Array,
|
||||||
statusFilter: { type: String, default: '' },
|
priorities: Array,
|
||||||
priorityFilter: { type: String, default: '' },
|
projects: Array,
|
||||||
categoryFilter: { type: String, default: '' },
|
isAgent: Boolean,
|
||||||
isAdmin: { type: Boolean, default: false },
|
filters: Object,
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchInput = ref(props.search)
|
const selectedTicketId = ref(null)
|
||||||
const filters = ref({
|
const activeGroupId = ref(props.filters?.group_id || null)
|
||||||
status: props.statusFilter,
|
const activeFilter = ref(props.filters?.filter || 'all')
|
||||||
priority: props.priorityFilter,
|
|
||||||
category: props.categoryFilter,
|
const filterViews = [
|
||||||
|
{ key: 'all', label: '📥 All Open' },
|
||||||
|
{ key: 'mine', label: '👤 Mine' },
|
||||||
|
{ key: 'unassigned', label: '🔲 Unassigned' },
|
||||||
|
{ key: 'pending', label: '⏳ Pending' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentViewLabel = computed(() => {
|
||||||
|
return filterViews.find(v => v.key === activeFilter.value)?.label || 'Tickets'
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveFilters = computed(() =>
|
const selectedTicket = computed(() => {
|
||||||
searchInput.value || filters.value.status || filters.value.priority || filters.value.category
|
return props.tickets.data.find(t => t.id === selectedTicketId.value) || null
|
||||||
)
|
})
|
||||||
|
|
||||||
function applyFilters() {
|
function selectTicket(ticket) {
|
||||||
router.get(route('ticketing.index'), {
|
selectedTicketId.value = ticket.id
|
||||||
search: searchInput.value,
|
|
||||||
status: filters.value.status,
|
|
||||||
priority: filters.value.priority,
|
|
||||||
category: filters.value.category,
|
|
||||||
}, { preserveState: true, replace: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function applyFilter(newFilters) {
|
||||||
searchInput.value = ''
|
const merged = { ...props.filters, ...newFilters }
|
||||||
filters.value = { status: '', priority: '', category: '' }
|
// Remove undefined/null values
|
||||||
router.get(route('ticketing.index'), {}, { preserveState: true, replace: true })
|
Object.keys(merged).forEach(k => {
|
||||||
|
if (merged[k] === undefined || merged[k] === null) delete merged[k]
|
||||||
|
})
|
||||||
|
if (newFilters.filter !== undefined) activeFilter.value = newFilters.filter || 'all'
|
||||||
|
router.get(route('ticketing.index'), merged, { preserveState: true, replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d) {
|
function statusLabel(status) {
|
||||||
return new Date(d).toLocaleDateString('en-CA')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStatus(s) {
|
|
||||||
const map = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' }
|
|
||||||
return map[s] ?? s
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(s) {
|
|
||||||
const map = {
|
const map = {
|
||||||
open: 'bg-blue-100 text-blue-700',
|
open: 'Open',
|
||||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
in_progress: 'In Progress',
|
||||||
resolved: 'bg-green-100 text-green-700',
|
pending: 'Pending',
|
||||||
closed: 'bg-gray-100 text-gray-600',
|
resolved: 'Resolved',
|
||||||
|
closed: 'Closed',
|
||||||
}
|
}
|
||||||
return map[s] ?? 'bg-gray-100 text-gray-600'
|
return map[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
function priorityClass(p) {
|
function statusClass(status) {
|
||||||
const map = {
|
const map = {
|
||||||
low: 'bg-gray-100 text-gray-600',
|
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
|
||||||
medium: 'bg-blue-100 text-blue-700',
|
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
|
||||||
high: 'bg-orange-100 text-orange-700',
|
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
urgent: 'bg-red-100 text-red-700',
|
resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
|
||||||
|
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
||||||
}
|
}
|
||||||
return map[p] ?? 'bg-gray-100 text-gray-600'
|
return map[status] || 'bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = Math.floor((now - date) / 1000)
|
||||||
|
if (diff < 60) return `${diff}s ago`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<AppLayout :title="`Ticket #${ticket.id}`">
|
<div class="max-w-4xl mx-auto py-8 px-4">
|
||||||
<div class="p-6 max-w-3xl">
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="mb-6">
|
||||||
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline">← Tickets</Link>
|
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Ticket #{{ ticket.id }}</h1>
|
|
||||||
<span class="px-2 py-1 rounded text-xs font-medium" :class="statusClass(ticket.status)">
|
|
||||||
{{ formatStatus(ticket.status) }}
|
|
||||||
</span>
|
|
||||||
<span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)">
|
|
||||||
{{ ticket.priority }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ticket Details -->
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="bg-white rounded-xl shadow p-6 mb-6">
|
<!-- Ticket Header -->
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">{{ ticket.title }}</h2>
|
<div class="flex items-start gap-3 flex-wrap">
|
||||||
<Link :href="route('ticketing.edit', ticket.id)" class="text-sm text-indigo-600 hover:underline">Edit</Link>
|
<span
|
||||||
</div>
|
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
|
||||||
|
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
||||||
<p class="text-sm text-gray-700 whitespace-pre-wrap mb-5">{{ ticket.description }}</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm text-gray-600 border-t pt-4">
|
|
||||||
<div><span class="font-medium">Category:</span> {{ ticket.category }}</div>
|
|
||||||
<div><span class="font-medium">Priority:</span> {{ ticket.priority }}</div>
|
|
||||||
<div><span class="font-medium">Submitted by:</span> {{ ticket.submitter?.name ?? '—' }}</div>
|
|
||||||
<div><span class="font-medium">Assigned to:</span> {{ ticket.assignee?.name ?? 'Unassigned' }}</div>
|
|
||||||
<div><span class="font-medium">Created:</span> {{ formatDate(ticket.created_at) }}</div>
|
|
||||||
<div><span class="font-medium">Updated:</span> {{ formatDate(ticket.updated_at) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<div class="bg-white rounded-xl shadow p-6 mb-6">
|
|
||||||
<h3 class="text-base font-semibold text-gray-800 mb-4">
|
|
||||||
Comments ({{ visibleComments.length }})
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div v-if="visibleComments.length === 0" class="text-sm text-gray-400 py-4 text-center">
|
|
||||||
No comments yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="comment in visibleComments"
|
|
||||||
:key="comment.id"
|
|
||||||
:class="['rounded-lg p-4 text-sm', comment.is_internal ? 'bg-yellow-50 border border-yellow-200' : 'bg-gray-50']"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-2">
|
{{ ticket.number }}
|
||||||
<span class="font-medium text-gray-800">{{ comment.author?.name ?? 'Unknown' }}</span>
|
</span>
|
||||||
<span class="text-xs text-gray-400">{{ formatDate(comment.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-700 whitespace-pre-wrap">{{ comment.body }}</p>
|
|
||||||
<span v-if="comment.is_internal" class="mt-1 inline-block text-xs text-yellow-700 font-medium">Staff note</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Comment Form -->
|
<!-- Title (editable inline for agents) -->
|
||||||
<div class="bg-white rounded-xl shadow p-6">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-base font-semibold text-gray-800 mb-4">Add Comment</h3>
|
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
|
||||||
<form @submit.prevent="submitComment" class="space-y-4">
|
<input
|
||||||
<textarea
|
v-model="titleEdit"
|
||||||
v-model="commentForm.body"
|
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent dark:text-white focus:outline-none"
|
||||||
rows="4"
|
@keyup.enter="saveTitle"
|
||||||
placeholder="Write your comment…"
|
@keyup.esc="editingTitle = false"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
|
||||||
:class="{ 'border-red-400': commentErrors.body }"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="commentErrors.body" class="text-xs text-red-500">{{ commentErrors.body }}</p>
|
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
|
||||||
|
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
|
||||||
<div v-if="isAdmin" class="flex items-center gap-2">
|
</div>
|
||||||
<input id="is_internal" v-model="commentForm.is_internal" type="checkbox" class="rounded border-gray-300" />
|
<h1
|
||||||
<label for="is_internal" class="text-sm text-gray-700">Mark as internal staff note</label>
|
v-else
|
||||||
|
class="text-xl font-semibold text-gray-900 dark:text-white"
|
||||||
|
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
|
||||||
|
@click="isAgent && startEditTitle()"
|
||||||
|
>
|
||||||
|
{{ ticket.title }}
|
||||||
|
<span v-if="isAgent" class="ml-1 text-xs text-gray-400">✏️</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<!-- Meta row -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mt-3">
|
||||||
|
<!-- Status -->
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
v-if="isAgent"
|
||||||
|
v-model="metaForm.status"
|
||||||
|
@change="saveMeta"
|
||||||
|
class="text-xs border-0 rounded-full px-3 py-1 font-medium cursor-pointer"
|
||||||
|
:class="statusClass(metaForm.status)"
|
||||||
|
>
|
||||||
|
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span v-else :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-1 rounded-full font-medium">
|
||||||
|
{{ statusLabel(ticket.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Priority -->
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
v-if="isAgent"
|
||||||
|
v-model="metaForm.priority_id"
|
||||||
|
@change="saveMeta"
|
||||||
|
class="text-xs border border-gray-200 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option :value="null">No priority</option>
|
||||||
|
<option v-for="p in priorities" :key="p.id" :value="p.id">
|
||||||
|
{{ p.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
|
||||||
|
{{ ticket.priority.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignee -->
|
||||||
|
<div v-if="isAgent">
|
||||||
|
<select
|
||||||
|
v-model="metaForm.assigned_to"
|
||||||
|
@change="saveMeta"
|
||||||
|
class="text-xs border border-gray-200 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
|
||||||
|
>
|
||||||
|
<option :value="null">Unassigned</option>
|
||||||
|
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Due date -->
|
||||||
|
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>📅</span>
|
||||||
|
<input
|
||||||
|
v-if="isAgent"
|
||||||
|
v-model="metaForm.due_date"
|
||||||
|
type="date"
|
||||||
|
@change="saveMeta"
|
||||||
|
class="text-xs border-0 bg-transparent dark:text-gray-400 cursor-pointer p-0"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ ticket.due_date || 'No due date' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit / Delete actions -->
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<Link
|
||||||
|
v-if="isAgent"
|
||||||
|
:href="route('ticketing.edit', { ticket: ticket.id })"
|
||||||
|
class="text-xs text-indigo-600 hover:underline"
|
||||||
|
>Edit</Link>
|
||||||
|
<button
|
||||||
|
v-if="isManager"
|
||||||
|
@click="confirmDelete"
|
||||||
|
class="text-xs text-red-500 hover:underline"
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Thread -->
|
||||||
|
<div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
<div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
|
||||||
|
No messages yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="msg in ticket.messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:class="[
|
||||||
|
'flex',
|
||||||
|
isOwnMessage(msg) ? 'justify-end' : 'justify-start'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
|
||||||
|
msg.is_internal
|
||||||
|
? 'bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 text-amber-900 dark:text-amber-100'
|
||||||
|
: isOwnMessage(msg)
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
|
||||||
|
<span v-if="msg.is_internal">🔒 Internal Note · </span>
|
||||||
|
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
|
||||||
|
<span>· {{ timeAgo(msg.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply Area -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<!-- Tab switcher (agents only) -->
|
||||||
|
<div v-if="isAgent" class="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
@click="replyTab = 'reply'"
|
||||||
|
:class="[
|
||||||
|
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||||
|
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
>Reply to submitter</button>
|
||||||
|
<button
|
||||||
|
@click="replyTab = 'internal'"
|
||||||
|
:class="[
|
||||||
|
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||||
|
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
>🔒 Internal Note</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="sendMessage">
|
||||||
|
<textarea
|
||||||
|
v-model="messageForm.body"
|
||||||
|
required
|
||||||
|
rows="3"
|
||||||
|
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
|
||||||
|
:class="[
|
||||||
|
'w-full rounded-lg text-sm border',
|
||||||
|
replyTab === 'internal'
|
||||||
|
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||||
|
]"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-3">
|
||||||
|
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
|
||||||
|
<input type="file" class="hidden" @change="attachFile" />
|
||||||
|
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="commentProcessing"
|
:disabled="messageForm.processing"
|
||||||
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
|
:class="[
|
||||||
style="background-color: var(--color-sidebar-active-bg)"
|
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
|
||||||
|
replyTab === 'internal'
|
||||||
|
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
|
||||||
|
: 'bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60'
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
{{ commentProcessing ? 'Posting…' : 'Post Comment' }}
|
{{ messageForm.processing ? 'Sending…' : 'Send' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Link, useForm } from '@inertiajs/vue3'
|
import { Link, useForm, router } from '@inertiajs/vue3'
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
ticket: Object,
|
ticket: Object,
|
||||||
isAdmin: { type: Boolean, default: false },
|
isAgent: Boolean,
|
||||||
|
isManager: Boolean,
|
||||||
|
agents: Array,
|
||||||
|
priorities: Array,
|
||||||
})
|
})
|
||||||
|
|
||||||
const commentForm = useForm({
|
const replyTab = ref('reply')
|
||||||
|
const editingTitle = ref(false)
|
||||||
|
const titleEdit = ref(props.ticket.title)
|
||||||
|
|
||||||
|
const statuses = [
|
||||||
|
{ value: 'open', label: 'Open' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'resolved', label: 'Resolved' },
|
||||||
|
{ value: 'closed', label: 'Closed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const metaForm = useForm({
|
||||||
|
status: props.ticket.status,
|
||||||
|
priority_id: props.ticket.priority_id,
|
||||||
|
assigned_to: props.ticket.assigned_to,
|
||||||
|
due_date: props.ticket.due_date,
|
||||||
|
})
|
||||||
|
|
||||||
|
const messageForm = useForm({
|
||||||
body: '',
|
body: '',
|
||||||
is_internal: false,
|
is_internal: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const commentProcessing = ref(false)
|
function isOwnMessage(msg) {
|
||||||
const commentErrors = ref({})
|
// If the message user_id matches the current user (we don't have auth here easily,
|
||||||
|
// but for display purposes: agents on right when it's not submitter's message)
|
||||||
|
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
|
||||||
|
}
|
||||||
|
|
||||||
// Non-admins don't see internal notes
|
function startEditTitle() {
|
||||||
const visibleComments = computed(() =>
|
titleEdit.value = props.ticket.title
|
||||||
props.isAdmin
|
editingTitle.value = true
|
||||||
? props.ticket.comments
|
}
|
||||||
: props.ticket.comments.filter(c => !c.is_internal)
|
|
||||||
)
|
|
||||||
|
|
||||||
function submitComment() {
|
function saveTitle() {
|
||||||
commentProcessing.value = true
|
router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
|
||||||
commentForm.post(route('ticketing.comments.store', props.ticket.id), {
|
onSuccess: () => { editingTitle.value = false }
|
||||||
onError: (e) => { commentErrors.value = e },
|
|
||||||
onSuccess: () => { commentForm.reset() },
|
|
||||||
onFinish: () => { commentProcessing.value = false },
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d) {
|
function saveMeta() {
|
||||||
return new Date(d).toLocaleDateString('en-CA')
|
metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStatus(s) {
|
function sendMessage() {
|
||||||
const map = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' }
|
messageForm.is_internal = replyTab.value === 'internal'
|
||||||
return map[s] ?? s
|
messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
|
||||||
|
onSuccess: () => { messageForm.body = '' }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(s) {
|
function attachFile(event) {
|
||||||
const map = {
|
const file = event.target.files[0]
|
||||||
open: 'bg-blue-100 text-blue-700',
|
if (!file) return
|
||||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
const data = new FormData()
|
||||||
resolved: 'bg-green-100 text-green-700',
|
data.append('file', file)
|
||||||
closed: 'bg-gray-100 text-gray-600',
|
router.post(route('ticketing.attachments.store', { ticket: props.ticket.id }), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (confirm(`Delete ticket ${props.ticket.number}? This cannot be undone.`)) {
|
||||||
|
router.delete(route('ticketing.destroy', { ticket: props.ticket.id }))
|
||||||
}
|
}
|
||||||
return map[s] ?? 'bg-gray-100 text-gray-600'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function priorityClass(p) {
|
function statusLabel(status) {
|
||||||
|
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
const map = {
|
const map = {
|
||||||
low: 'bg-gray-100 text-gray-600',
|
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
|
||||||
medium: 'bg-blue-100 text-blue-700',
|
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
|
||||||
high: 'bg-orange-100 text-orange-700',
|
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
urgent: 'bg-red-100 text-red-700',
|
resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
|
||||||
|
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
||||||
}
|
}
|
||||||
return map[p] ?? 'bg-gray-100 text-gray-600'
|
return map[status] || 'bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr) {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = Math.floor((now - date) / 1000)
|
||||||
|
if (diff < 60) return `${diff}s ago`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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;
|
namespace Dashboard\Ticketing\Http\Controllers;
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Models\PriorityLevel;
|
||||||
use Dashboard\Ticketing\Models\Ticket;
|
use Dashboard\Ticketing\Models\Ticket;
|
||||||
|
use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
||||||
|
use Dashboard\Ticketing\Models\TicketingGroup;
|
||||||
|
use Dashboard\Ticketing\Models\TicketingProject;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
class TicketController extends Controller
|
class TicketController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
private function agentGroupIds(): array
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
return TicketingAgentAccess::where('user_id', Auth::id())
|
||||||
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
|
->pluck('group_id')
|
||||||
|
->toArray();
|
||||||
$query = Ticket::with(['submitter:id,name', 'assignee:id,name']);
|
|
||||||
|
|
||||||
if (! $isAdmin) {
|
|
||||||
$query->where('user_id', $user->id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($search = $request->get('search')) {
|
private function isAgent(int $groupId = null): bool
|
||||||
$query->where(function ($q) use ($search) {
|
{
|
||||||
$q->where('title', 'like', "%{$search}%")
|
$query = TicketingAgentAccess::where('user_id', Auth::id());
|
||||||
->orWhere('description', 'like', "%{$search}%");
|
if ($groupId) {
|
||||||
|
$query->where('group_id', $groupId);
|
||||||
|
}
|
||||||
|
return $query->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isManager(int $groupId): bool
|
||||||
|
{
|
||||||
|
return TicketingAgentAccess::where('user_id', Auth::id())
|
||||||
|
->where('group_id', $groupId)
|
||||||
|
->where('role', 'manager')
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$agentGroupIds = $this->agentGroupIds();
|
||||||
|
$isAgent = count($agentGroupIds) > 0;
|
||||||
|
|
||||||
|
$query = Ticket::with(['group', 'priority', 'project']);
|
||||||
|
|
||||||
|
if ($isAgent) {
|
||||||
|
$query->whereIn('group_id', $agentGroupIds);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
if ($request->filled('group_id')) {
|
||||||
|
$query->where('group_id', $request->group_id);
|
||||||
|
}
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
if ($request->filled('priority_id')) {
|
||||||
|
$query->where('priority_id', $request->priority_id);
|
||||||
|
}
|
||||||
|
if ($request->filter === 'mine') {
|
||||||
|
$query->where('assigned_to', $user->id);
|
||||||
|
} elseif ($request->filter === 'unassigned') {
|
||||||
|
$query->whereNull('assigned_to');
|
||||||
|
} elseif ($request->filter === 'pending') {
|
||||||
|
$query->where('status', 'pending');
|
||||||
|
} elseif (!$request->filled('status')) {
|
||||||
|
$query->whereNotIn('status', ['resolved', 'closed']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$query->where('submitter_id', $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tickets = $query->latest()->paginate(30)->withQueryString();
|
||||||
|
|
||||||
|
// Enrich with submitter name
|
||||||
|
$userIds = $tickets->pluck('submitter_id')->merge($tickets->pluck('assigned_to'))->filter()->unique();
|
||||||
|
$users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
||||||
|
|
||||||
|
$tickets->getCollection()->transform(function ($ticket) use ($users) {
|
||||||
|
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
|
||||||
|
$ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null;
|
||||||
|
return $ticket;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if ($status = $request->get('status')) {
|
$groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
|
||||||
$query->where('status', $status);
|
$priorities = PriorityLevel::whereNull('group_id')
|
||||||
}
|
->orWhenIn('group_id', $agentGroupIds ?? [])
|
||||||
|
->orderBy('sort_order')
|
||||||
if ($priority = $request->get('priority')) {
|
->get();
|
||||||
$query->where('priority', $priority);
|
$projects = TicketingProject::when($isAgent, fn($q) => $q->whereIn('group_id', $agentGroupIds))
|
||||||
}
|
->where('status', 'active')
|
||||||
|
->get();
|
||||||
if ($category = $request->get('category')) {
|
|
||||||
$query->where('category', $category);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tickets = $query->latest()->paginate(25)->withQueryString();
|
|
||||||
|
|
||||||
return Inertia::render('Ticketing/Index', [
|
return Inertia::render('Ticketing/Index', [
|
||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'search' => $request->get('search', ''),
|
'groups' => $groups,
|
||||||
'statusFilter' => $request->get('status', ''),
|
'priorities' => $priorities,
|
||||||
'priorityFilter' => $request->get('priority', ''),
|
'projects' => $projects,
|
||||||
'categoryFilter' => $request->get('category', ''),
|
'isAgent' => $isAgent,
|
||||||
'isAdmin' => $isAdmin,
|
'filters' => $request->only(['group_id', 'status', 'priority_id', 'filter']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create()
|
public function create(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('Ticketing/Create');
|
$user = Auth::user();
|
||||||
|
$agentGroupIds = $this->agentGroupIds();
|
||||||
|
$isAgent = count($agentGroupIds) > 0;
|
||||||
|
|
||||||
|
$groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
|
||||||
|
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
||||||
|
|
||||||
|
return Inertia::render('Ticketing/Create', [
|
||||||
|
'groups' => $groups,
|
||||||
|
'priorities' => $priorities,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$validated = $request->validate([
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
'description' => 'required|string',
|
'description' => 'required|string',
|
||||||
'category' => 'required|in:IT,Facilities,HR,Other',
|
'group_id' => 'required|exists:ticketing_groups,id',
|
||||||
'priority' => 'required|in:low,medium,high,urgent',
|
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
|
||||||
|
'due_date' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Ticket::create([
|
$group = TicketingGroup::findOrFail($validated['group_id']);
|
||||||
'user_id' => auth()->id(),
|
$number = $group->nextTicketNumber();
|
||||||
'title' => $request->title,
|
|
||||||
'description' => $request->description,
|
$ticket = Ticket::create([
|
||||||
'category' => $request->category,
|
...$validated,
|
||||||
'priority' => $request->priority,
|
'number' => $number,
|
||||||
|
'submitter_id' => Auth::id(),
|
||||||
'status' => 'open',
|
'status' => 'open',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('ticketing.index')->with('success', 'Ticket submitted.');
|
return redirect()->route('ticketing.show', $ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Ticket $ticket)
|
public function show(Ticket $ticket): Response
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = Auth::user();
|
||||||
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
|
$isAgent = $this->isAgent($ticket->group_id);
|
||||||
|
$isSubmitter = $ticket->submitter_id === $user->id;
|
||||||
|
|
||||||
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
|
if (!$isAgent && !$isSubmitter) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$ticket->load([
|
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
|
||||||
'submitter:id,name',
|
|
||||||
'assignee:id,name',
|
// Enrich messages with user data
|
||||||
'comments.author:id,name',
|
$userIds = $ticket->messages->pluck('user_id')->filter()->unique();
|
||||||
]);
|
$users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
||||||
|
|
||||||
|
$ticket->messages->each(function ($msg) use ($users) {
|
||||||
|
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agents for assignment picker
|
||||||
|
$agents = [];
|
||||||
|
if ($isAgent) {
|
||||||
|
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||||
|
$agents = \DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
|
||||||
|
->orderBy('sort_order')->get();
|
||||||
|
|
||||||
return Inertia::render('Ticketing/Show', [
|
return Inertia::render('Ticketing/Show', [
|
||||||
'ticket' => $ticket,
|
'ticket' => $ticket,
|
||||||
'isAdmin' => $isAdmin,
|
'isAgent' => $isAgent,
|
||||||
|
'isManager' => $isAgent && $this->isManager($ticket->group_id),
|
||||||
|
'agents' => $agents,
|
||||||
|
'priorities' => $priorities,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Ticket $ticket)
|
public function edit(Ticket $ticket): Response
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
if (!$this->isAgent($ticket->group_id)) {
|
||||||
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
|
$ticket->load(['group', 'priority', 'project']);
|
||||||
|
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
|
||||||
|
->orderBy('sort_order')->get();
|
||||||
|
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||||
|
$agents = \DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
||||||
|
$projects = TicketingProject::where('group_id', $ticket->group_id)->get();
|
||||||
|
|
||||||
return Inertia::render('Ticketing/Edit', [
|
return Inertia::render('Ticketing/Edit', [
|
||||||
'ticket' => $ticket,
|
'ticket' => $ticket,
|
||||||
'isAdmin' => $isAdmin,
|
'priorities' => $priorities,
|
||||||
|
'agents' => $agents,
|
||||||
|
'projects' => $projects,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Ticket $ticket)
|
public function update(Request $request, Ticket $ticket)
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
if (!$this->isAgent($ticket->group_id)) {
|
||||||
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
|
abort(403);
|
||||||
|
}
|
||||||
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
|
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'sometimes|required|string|max:255',
|
||||||
'description' => 'required|string',
|
'description' => 'sometimes|required|string',
|
||||||
'category' => 'required|in:IT,Facilities,HR,Other',
|
'status' => 'sometimes|in:open,in_progress,pending,resolved,closed',
|
||||||
'priority' => 'required|in:low,medium,high,urgent',
|
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
|
||||||
|
'due_date' => 'nullable|date',
|
||||||
|
'project_id' => 'nullable|exists:ticketing_projects,id',
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($isAdmin) {
|
// assigned_to only settable by agents
|
||||||
$rules['status'] = 'required|in:open,in_progress,resolved,closed';
|
if ($this->isAgent($ticket->group_id)) {
|
||||||
$rules['assigned_to'] = 'nullable|exists:users,id';
|
$rules['assigned_to'] = 'nullable|exists:users,id';
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->validate($rules);
|
$ticket->update($request->validate($rules));
|
||||||
|
|
||||||
$data = $request->only(['title', 'description', 'category', 'priority']);
|
return back()->with('success', 'Ticket updated.');
|
||||||
|
|
||||||
if ($isAdmin) {
|
|
||||||
$data['status'] = $request->status;
|
|
||||||
$data['assigned_to'] = $request->assigned_to;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ticket->update($data);
|
|
||||||
|
|
||||||
return redirect()->route('ticketing.show', $ticket)->with('success', 'Ticket updated.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Ticket $ticket)
|
public function destroy(Ticket $ticket)
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
if (!$this->isManager($ticket->group_id)) {
|
||||||
$isAdmin = in_array($user->role ?? '', ['admin', 'super_admin']);
|
abort(403);
|
||||||
|
}
|
||||||
abort_if(! $isAdmin && $ticket->user_id !== $user->id, 403);
|
|
||||||
|
|
||||||
$ticket->delete();
|
$ticket->delete();
|
||||||
|
return redirect()->route('ticketing.index');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('ticketing.index')->with('success', 'Ticket deleted.');
|
public function myTickets(): Response
|
||||||
|
{
|
||||||
|
$tickets = Ticket::where('submitter_id', Auth::id())
|
||||||
|
->with(['group', 'priority'])
|
||||||
|
->latest()
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
return Inertia::render('Ticketing/MyTickets', [
|
||||||
|
'tickets' => $tickets,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
class Ticket extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
|
||||||
'assigned_to',
|
'title', 'description', 'status', 'priority_id', 'due_date',
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'category',
|
|
||||||
'priority',
|
|
||||||
'status',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function submitter(): BelongsTo
|
protected $casts = [
|
||||||
|
'due_date' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function group(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\User::class, 'user_id');
|
return $this->belongsTo(TicketingGroup::class, 'group_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function assignee(): BelongsTo
|
public function priority(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\User::class, 'assigned_to');
|
return $this->belongsTo(PriorityLevel::class, 'priority_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function comments(): HasMany
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->hasMany(TicketComment::class);
|
return $this->belongsTo(TicketingProject::class, 'project_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketMessage::class, 'ticket_id')->orderBy('created_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TicketAttachment::class, 'ticket_id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
class TicketingServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void {}
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Dashboard\Ticketing\Http\Controllers\TicketAttachmentController;
|
||||||
use Dashboard\Ticketing\Http\Controllers\TicketController;
|
use Dashboard\Ticketing\Http\Controllers\TicketController;
|
||||||
use Dashboard\Ticketing\Http\Controllers\TicketCommentController;
|
use Dashboard\Ticketing\Http\Controllers\TicketMessageController;
|
||||||
|
use Dashboard\Ticketing\Http\Controllers\TicketingSettingsController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'app.access:ticketing'])->prefix('app/ticketing')->name('ticketing.')->group(function () {
|
Route::middleware(['web', 'auth', 'app.access:ticketing'])->prefix('app/ticketing')->name('ticketing.')->group(function () {
|
||||||
|
// Submitter portal (must come before /{ticket} to avoid conflict)
|
||||||
|
Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets');
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
Route::get('/settings', [TicketingSettingsController::class, 'index'])->name('settings');
|
||||||
|
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup'])->name('settings.groups.store');
|
||||||
|
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup'])->name('settings.groups.update');
|
||||||
|
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent'])->name('settings.agents.store');
|
||||||
|
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent'])->name('settings.agents.destroy');
|
||||||
|
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority'])->name('settings.priorities.store');
|
||||||
|
|
||||||
|
// Ticket routes
|
||||||
Route::get('/', [TicketController::class, 'index'])->name('index');
|
Route::get('/', [TicketController::class, 'index'])->name('index');
|
||||||
Route::get('/create', [TicketController::class, 'create'])->name('create');
|
Route::get('/create', [TicketController::class, 'create'])->name('create');
|
||||||
Route::post('/', [TicketController::class, 'store'])->name('store');
|
Route::post('/', [TicketController::class, 'store'])->name('store');
|
||||||
|
|
||||||
|
// Attachment show (before /{ticket} wildcard)
|
||||||
|
Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show');
|
||||||
|
|
||||||
Route::get('/{ticket}', [TicketController::class, 'show'])->name('show');
|
Route::get('/{ticket}', [TicketController::class, 'show'])->name('show');
|
||||||
Route::get('/{ticket}/edit', [TicketController::class, 'edit'])->name('edit');
|
Route::get('/{ticket}/edit', [TicketController::class, 'edit'])->name('edit');
|
||||||
Route::put('/{ticket}', [TicketController::class, 'update'])->name('update');
|
Route::put('/{ticket}', [TicketController::class, 'update'])->name('update');
|
||||||
Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy');
|
Route::delete('/{ticket}', [TicketController::class, 'destroy'])->name('destroy');
|
||||||
Route::post('/{ticket}/comments', [TicketCommentController::class, 'store'])->name('comments.store');
|
|
||||||
|
// Messages & attachments
|
||||||
|
Route::post('/{ticket}/messages', [TicketMessageController::class, 'store'])->name('messages.store');
|
||||||
|
Route::post('/{ticket}/attachments', [TicketAttachmentController::class, 'store'])->name('attachments.store');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user