fix(ticketing): restrict settings link to admins and protect global priorities

- Show 'Go to Settings' bootstrap link only for admin/super_admin users
- Pass isSiteAdmin prop to Create.vue to control settings CTA visibility
- Require site admin for updatePriority/destroyPriority when priority is global (group_id = null)
- Closes: non-admin users seeing forbidden settings link; agents mutating global priorities
This commit is contained in:
Joel Wedemire
2026-04-09 14:32:19 -07:00
parent 45b019dad2
commit bce98c0d4b
7 changed files with 493 additions and 473 deletions

View File

@@ -1,25 +1,26 @@
<template> <template>
<div class="max-w-2xl mx-auto py-8 px-4"> <AppLayout>
<div class="max-w-2xl mx-auto">
<div class="mb-6"> <div class="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-indigo-600 hover:underline"> Back to tickets</Link>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">Submit a Ticket</h1> <h1 class="text-2xl font-bold text-gray-900 mt-2">Submit a Ticket</h1>
</div> </div>
<!-- Bootstrap / No groups state --> <!-- Bootstrap / No groups state -->
<div v-if="isBootstrap" class="bg-amber-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-600 rounded-xl px-5 py-6 text-center"> <div v-if="isBootstrap" class="bg-amber-50 border border-amber-300 rounded-xl px-5 py-6 text-center">
<p class="text-amber-800 dark:text-amber-200 font-semibold text-base mb-2">📦 Ticketing isnt set up yet</p> <p class="text-amber-800 font-semibold text-base mb-2">📦 Ticketing isn't set up yet</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mb-4">An admin needs to create at least one group before tickets can be submitted.</p> <p class="text-sm text-amber-700 mb-4">An admin needs to create at least one group before tickets can be submitted.</p>
<Link :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700">Go to Settings</Link> <Link v-if="isSiteAdmin" :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700">Go to Settings</Link>
</div> </div>
<form v-else @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"> <form v-else @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<!-- Group --> <!-- Group -->
<div> <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> <label class="block text-sm font-medium text-gray-700 mb-1">Group <span class="text-red-500">*</span></label>
<select <select
v-model="form.group_id" v-model="form.group_id"
required required
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" class="w-full border-gray-300 rounded-lg text-sm"
> >
<option value="">Select a group</option> <option value="">Select a group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option> <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
@@ -29,36 +30,36 @@
<!-- Title --> <!-- Title -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
<input <input
v-model="form.title" v-model="form.title"
type="text" type="text"
required required
placeholder="Brief summary of the issue" placeholder="Brief summary of the issue"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" class="w-full border-gray-300 rounded-lg text-sm"
/> />
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p> <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 dark:text-gray-300 mb-1">Description <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
<textarea <textarea
v-model="form.description" v-model="form.description"
required required
rows="5" rows="5"
placeholder="Describe the issue in detail..." placeholder="Describe the issue in detail..."
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" class="w-full border-gray-300 rounded-lg text-sm"
></textarea> ></textarea>
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p> <p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div> </div>
<!-- Priority --> <!-- Priority -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label> <label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select <select
v-model="form.priority_id" 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="w-full border-gray-300 rounded-lg text-sm"
> >
<option :value="null">No priority</option> <option :value="null">No priority</option>
<option v-for="p in filteredPriorities" :key="p.id" :value="p.id">{{ p.name }}</option> <option v-for="p in filteredPriorities" :key="p.id" :value="p.id">{{ p.name }}</option>
@@ -67,11 +68,11 @@
<!-- Due Date --> <!-- Due Date -->
<div> <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> <label class="block text-sm font-medium text-gray-700 mb-1">Due Date <span class="text-gray-400 font-normal">(optional)</span></label>
<input <input
v-model="form.due_date" v-model="form.due_date"
type="date" type="date"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" class="w-full border-gray-300 rounded-lg text-sm"
/> />
</div> </div>
@@ -80,7 +81,7 @@
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="inline-flex items-center gap-2 bg-indigo-600 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-60 transition" class="inline-flex items-center gap-2 bg-gray-900 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-60 transition"
> >
<span v-if="form.processing">Submitting…</span> <span v-if="form.processing">Submitting…</span>
<span v-else>Submit Ticket</span> <span v-else>Submit Ticket</span>
@@ -88,16 +89,19 @@
</div> </div>
</form> </form>
</div> </div>
</AppLayout>
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
import { Link, useForm } from '@inertiajs/vue3' import { Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({ const props = defineProps({
groups: Array, groups: Array,
priorities: Array, priorities: Array,
isBootstrap: Boolean, isBootstrap: Boolean,
isSiteAdmin: Boolean,
}) })
const form = useForm({ const form = useForm({

View File

@@ -1,31 +1,32 @@
<template> <template>
<div class="max-w-2xl mx-auto py-8 px-4"> <AppLayout>
<div class="max-w-2xl mx-auto">
<div class="mb-6"> <div class="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: ticket.id })" class="text-sm text-indigo-600 hover:underline"> Back to ticket</Link>
<h1 class="text-xl font-bold text-gray-900 dark:text-white mt-2"> <h1 class="text-xl font-bold text-gray-900 mt-2">
Edit <span class="font-mono text-base">{{ ticket.number }}</span> Edit <span class="font-mono text-base">{{ ticket.number }}</span>
</h1> </h1>
</div> </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"> <form @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<!-- Title --> <!-- Title -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label> <label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input v-model="form.title" type="text" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" /> <input v-model="form.title" type="text" required class="w-full border-gray-300 rounded-lg text-sm" />
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p> <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 dark:text-gray-300 mb-1">Description</label> <label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea v-model="form.description" rows="6" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"></textarea> <textarea v-model="form.description" rows="6" required class="w-full border-gray-300 rounded-lg text-sm"></textarea>
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p> <p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div> </div>
<!-- Status --> <!-- Status -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label> <label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select v-model="form.status" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"> <select v-model="form.status" class="w-full border-gray-300 rounded-lg text-sm">
<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="pending">Pending</option>
@@ -36,8 +37,8 @@
<!-- Priority --> <!-- Priority -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label> <label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select v-model="form.priority_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"> <select v-model="form.priority_id" class="w-full border-gray-300 rounded-lg text-sm">
<option :value="null">No priority</option> <option :value="null">No priority</option>
<option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option> <option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
</select> </select>
@@ -45,8 +46,8 @@
<!-- Assignee --> <!-- Assignee -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Assignee</label> <label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
<select v-model="form.assigned_to" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"> <select v-model="form.assigned_to" class="w-full border-gray-300 rounded-lg text-sm">
<option :value="null">Unassigned</option> <option :value="null">Unassigned</option>
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option> <option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
</select> </select>
@@ -54,8 +55,8 @@
<!-- Project --> <!-- Project -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label> <label class="block text-sm font-medium text-gray-700 mb-1">Project</label>
<select v-model="form.project_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"> <select v-model="form.project_id" class="w-full border-gray-300 rounded-lg text-sm">
<option :value="null">No project</option> <option :value="null">No project</option>
<option v-for="proj in projects" :key="proj.id" :value="proj.id">{{ proj.name }}</option> <option v-for="proj in projects" :key="proj.id" :value="proj.id">{{ proj.name }}</option>
</select> </select>
@@ -63,28 +64,30 @@
<!-- Due Date --> <!-- Due Date -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Due Date</label> <label class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
<input v-model="form.due_date" type="date" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" /> <input v-model="form.due_date" type="date" class="w-full border-gray-300 rounded-lg text-sm" />
</div> </div>
<div class="flex gap-3 justify-end pt-2"> <div class="flex gap-3 justify-end pt-2">
<Link <Link
:href="route('ticketing.show', { ticket: ticket.id })" :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" class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>Cancel</Link> >Cancel</Link>
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="px-5 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-60 transition" class="px-5 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-60 transition"
> >
{{ form.processing ? 'Saving…' : 'Save Changes' }} {{ form.processing ? 'Saving…' : 'Save Changes' }}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</AppLayout>
</template> </template>
<script setup> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { Link, useForm } from '@inertiajs/vue3' import { Link, useForm } from '@inertiajs/vue3'
const props = defineProps({ const props = defineProps({

View File

@@ -1,13 +1,14 @@
<template> <template>
<div class="max-w-3xl mx-auto py-8 px-4"> <AppLayout>
<div class="max-w-3xl mx-auto">
<div class="mb-6 flex justify-between items-center"> <div class="mb-6 flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">My Tickets</h1> <h1 class="text-2xl font-bold text-gray-900">My Tickets</h1>
<Link :href="route('ticketing.create')" class="inline-flex items-center gap-1 bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition"> <Link :href="route('ticketing.create')" class="inline-flex items-center gap-1 bg-gray-900 text-white text-sm px-4 py-2 rounded-lg hover:bg-gray-700 transition">
+ Submit Ticket + Submit Ticket
</Link> </Link>
</div> </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 class="bg-white rounded-xl shadow-sm border border-gray-200 divide-y divide-gray-100">
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 text-sm"> <div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 text-sm">
You haven't submitted any tickets yet. You haven't submitted any tickets yet.
</div> </div>
@@ -15,14 +16,14 @@
v-for="ticket in tickets.data" v-for="ticket in tickets.data"
:key="ticket.id" :key="ticket.id"
:href="route('ticketing.show', { ticket: 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" class="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition"
> >
<span <span
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0 mt-0.5" 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' }" :style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>{{ ticket.number }}</span> >{{ ticket.number }}</span>
<div class="flex-1 min-w-0"> <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-sm font-medium text-gray-800 truncate">{{ ticket.title }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ ticket.group?.name }} · {{ timeAgo(ticket.created_at) }}</p> <p class="text-xs text-gray-400 mt-0.5">{{ ticket.group?.name }} · {{ timeAgo(ticket.created_at) }}</p>
</div> </div>
<span :class="statusClass(ticket.status)" class="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0"> <span :class="statusClass(ticket.status)" class="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0">
@@ -38,9 +39,11 @@
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-indigo-600 hover:underline">Next </Link> <Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-indigo-600 hover:underline">Next </Link>
</div> </div>
</div> </div>
</AppLayout>
</template> </template>
<script setup> <script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { Link } from '@inertiajs/vue3' import { Link } from '@inertiajs/vue3'
defineProps({ tickets: Object }) defineProps({ tickets: Object })
@@ -52,7 +55,7 @@ function statusLabel(status) {
function statusClass(status) { function statusClass(status) {
const map = { const map = {
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200', open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-purple-100 text-purple-700', in_progress: 'bg-purple-100 text-purple-700',
pending: 'bg-yellow-100 text-yellow-700', pending: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700', resolved: 'bg-green-100 text-green-700',

View File

@@ -2,30 +2,30 @@
<div class="max-w-5xl mx-auto py-8 px-4"> <div class="max-w-5xl mx-auto py-8 px-4">
<div class="mb-6"> <div class="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-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> <h1 class="text-2xl font-bold text-gray-900 mt-2">Ticketing Settings</h1>
</div> </div>
<div v-if="isBootstrap" class="mb-6 px-5 py-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-600 rounded-xl"> <div v-if="isBootstrap" class="mb-6 px-5 py-4 bg-amber-50 border border-amber-300 rounded-xl">
<h2 class="text-base font-semibold text-amber-800 dark:text-amber-200 mb-1">🚀 First-Run Setup</h2> <h2 class="text-base font-semibold text-amber-800 mb-1">🚀 First-Run Setup</h2>
<p class="text-sm text-amber-700 dark:text-amber-300"> <p class="text-sm text-amber-700">
No groups exist yet. Create your first group below to get started. No groups exist yet. Create your first group below to get started.
Default priorities (Low, Medium, High, Urgent) will be seeded automatically. Default priorities (Low, Medium, High, Urgent) will be seeded automatically.
</p> </p>
</div> </div>
<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"> <div v-if="$page.props.flash?.success" class="mb-4 px-4 py-2 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ $page.props.flash.success }} {{ $page.props.flash.success }}
</div> </div>
<div v-if="$page.props.errors?.priority" class="mb-4 px-4 py-2 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-300 rounded-lg text-sm"> <div v-if="$page.props.errors?.priority" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ $page.props.errors.priority }} {{ $page.props.errors.priority }}
</div> </div>
<div v-if="$page.props.errors?.project" class="mb-4 px-4 py-2 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-700 dark:text-red-300 rounded-lg text-sm"> <div v-if="$page.props.errors?.project" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ $page.props.errors.project }} {{ $page.props.errors.project }}
</div> </div>
<div class="flex gap-1 border-b border-gray-200 dark:border-gray-700 mb-6"> <div class="flex gap-1 border-b border-gray-200 mb-6">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.key" :key="tab.key"
@@ -33,34 +33,34 @@
:class="[ :class="[
'px-4 py-2.5 text-sm font-medium border-b-2 transition', 'px-4 py-2.5 text-sm font-medium border-b-2 transition',
activeTab === tab.key activeTab === tab.key
? 'border-indigo-600 text-indigo-600 dark:text-indigo-400' ? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700' : 'border-transparent text-gray-500 hover:text-gray-700'
]" ]"
>{{ tab.label }}</button> >{{ tab.label }}</button>
</div> </div>
<div v-if="activeTab === 'groups'"> <div v-if="activeTab === 'groups'">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Groups</h2> <h2 class="text-lg font-semibold text-gray-800">Groups</h2>
<button @click="showAddGroup = !showAddGroup" class="text-sm text-indigo-600 hover:underline"> <button @click="showAddGroup = !showAddGroup" class="text-sm text-indigo-600 hover:underline">
{{ showAddGroup ? 'Cancel' : '+ Add Group' }} {{ showAddGroup ? 'Cancel' : '+ Add Group' }}
</button> </button>
</div> </div>
<div v-if="showAddGroup" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3"> <div v-if="showAddGroup" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">New Group</h3> <h3 class="text-sm font-semibold text-gray-700">New Group</h3>
<form @submit.prevent="submitGroup" class="grid grid-cols-2 gap-3"> <form @submit.prevent="submitGroup" class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Name</label> <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" /> <input v-model="groupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Prefix (e.g. IT)</label> <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" /> <input v-model="groupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Email Address</label> <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" /> <input v-model="groupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Color</label> <label class="block text-xs text-gray-500 mb-1">Color</label>
@@ -79,12 +79,12 @@
<div <div
v-for="group in groups" v-for="group in groups"
:key="group.id" :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" class="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-xl"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: group.color }"></span> <span class="w-3 h-3 rounded-full" :style="{ backgroundColor: group.color }"></span>
<div> <div>
<p class="font-medium text-sm text-gray-800 dark:text-gray-100">{{ group.name }}</p> <p class="font-medium text-sm text-gray-800">{{ group.name }}</p>
<p class="text-xs text-gray-400">{{ group.prefix }} · {{ group.email_address || 'No email' }}</p> <p class="text-xs text-gray-400">{{ group.prefix }} · {{ group.email_address || 'No email' }}</p>
</div> </div>
</div> </div>
@@ -92,20 +92,20 @@
</div> </div>
</div> </div>
<div v-if="editingGroup" class="mt-4 bg-gray-50 dark:bg-gray-700 rounded-xl p-4 space-y-3"> <div v-if="editingGroup" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Edit: {{ editingGroup.name }}</h3> <h3 class="text-sm font-semibold text-gray-700">Edit: {{ editingGroup.name }}</h3>
<form @submit.prevent="submitEditGroup" class="grid grid-cols-2 gap-3"> <form @submit.prevent="submitEditGroup" class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Name</label> <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" /> <input v-model="editGroupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Prefix</label> <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" /> <input v-model="editGroupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Email Address</label> <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" /> <input v-model="editGroupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Color</label> <label class="block text-xs text-gray-500 mb-1">Color</label>
@@ -121,28 +121,28 @@
<div v-if="activeTab === 'agents'"> <div v-if="activeTab === 'agents'">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Agents</h2> <h2 class="text-lg font-semibold text-gray-800">Agents</h2>
<button @click="showAddAgent = !showAddAgent" class="text-sm text-indigo-600 hover:underline"> <button @click="showAddAgent = !showAddAgent" class="text-sm text-indigo-600 hover:underline">
{{ showAddAgent ? 'Cancel' : '+ Add Agent' }} {{ showAddAgent ? 'Cancel' : '+ Add Agent' }}
</button> </button>
</div> </div>
<div v-if="showAddAgent" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3"> <div v-if="showAddAgent" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitAgent" class="grid grid-cols-3 gap-3"> <form @submit.prevent="submitAgent" class="grid grid-cols-3 gap-3">
<div> <div>
<label class="block text-xs text-gray-500 mb-1">User ID</label> <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" /> <input v-model="agentForm.user_id" required type="number" placeholder="User ID" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Group</label> <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"> <select v-model="agentForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
<option value="">Select group</option> <option value="">Select group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option> <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Role</label> <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"> <select v-model="agentForm.role" class="w-full text-sm border-gray-300 rounded-lg">
<option value="agent">Agent</option> <option value="agent">Agent</option>
<option value="manager">Manager</option> <option value="manager">Manager</option>
</select> </select>
@@ -160,10 +160,10 @@
<div <div
v-for="access in agents" v-for="access in agents"
:key="access.id" :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" class="flex items-center justify-between px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
> >
<div> <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-sm font-medium text-gray-800">{{ access.user?.name || 'User #' + access.user_id }}</p>
<p class="text-xs text-gray-400">{{ access.user?.email }} · {{ access.group?.name || 'Unknown Group' }} · <span class="capitalize">{{ access.role }}</span></p> <p class="text-xs text-gray-400">{{ access.user?.email }} · {{ access.group?.name || 'Unknown Group' }} · <span class="capitalize">{{ access.role }}</span></p>
</div> </div>
<button @click="openRemoveAgentModal(access)" class="text-xs text-red-500 hover:underline">Remove</button> <button @click="openRemoveAgentModal(access)" class="text-xs text-red-500 hover:underline">Remove</button>
@@ -173,17 +173,17 @@
<div v-if="activeTab === 'priorities'"> <div v-if="activeTab === 'priorities'">
<div class="flex justify-between items-center mb-4"> <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> <h2 class="text-lg font-semibold text-gray-800">Priority Levels</h2>
<button @click="showAddPriority = !showAddPriority" class="text-sm text-indigo-600 hover:underline"> <button @click="showAddPriority = !showAddPriority" class="text-sm text-indigo-600 hover:underline">
{{ showAddPriority ? 'Cancel' : '+ Add Priority' }} {{ showAddPriority ? 'Cancel' : '+ Add Priority' }}
</button> </button>
</div> </div>
<div v-if="showAddPriority" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3"> <div v-if="showAddPriority" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitPriority" class="grid grid-cols-2 gap-3"> <form @submit.prevent="submitPriority" class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Name</label> <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" /> <input v-model="priorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Color</label> <label class="block text-xs text-gray-500 mb-1">Color</label>
@@ -191,15 +191,15 @@
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label> <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" /> <input v-model="priorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Sort Order</label> <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" /> <input v-model="priorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Group (blank = global)</label> <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"> <select v-model="priorityForm.group_id" class="w-full text-sm border-gray-300 rounded-lg">
<option :value="null">Global</option> <option :value="null">Global</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option> <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select> </select>
@@ -217,11 +217,11 @@
<div <div
v-for="p in priorities" v-for="p in priorities"
:key="p.id" :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" class="flex items-center gap-3 px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
> >
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: p.color }"></span> <span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: p.color }"></span>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ p.name }}</p> <p class="text-sm font-medium text-gray-800">{{ p.name }}</p>
<p v-if="p.description" class="text-xs text-gray-400">{{ p.description }}</p> <p v-if="p.description" class="text-xs text-gray-400">{{ p.description }}</p>
</div> </div>
<span class="text-xs text-gray-400">{{ p.group_id ? 'Group-specific' : 'Global' }}</span> <span class="text-xs text-gray-400">{{ p.group_id ? 'Group-specific' : 'Global' }}</span>
@@ -232,12 +232,12 @@
</div> </div>
</div> </div>
<div v-if="editingPriority" class="mt-4 bg-gray-50 dark:bg-gray-700 rounded-xl p-4 space-y-3"> <div v-if="editingPriority" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Edit Priority: {{ editingPriority.name }}</h3> <h3 class="text-sm font-semibold text-gray-700">Edit Priority: {{ editingPriority.name }}</h3>
<form @submit.prevent="submitEditPriority" class="grid grid-cols-2 gap-3"> <form @submit.prevent="submitEditPriority" class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Name</label> <label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="editPriorityForm.name" required type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" /> <input v-model="editPriorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Color</label> <label class="block text-xs text-gray-500 mb-1">Color</label>
@@ -245,15 +245,15 @@
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label> <label class="block text-xs text-gray-500 mb-1">Description</label>
<input v-model="editPriorityForm.description" type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" /> <input v-model="editPriorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Sort Order</label> <label class="block text-xs text-gray-500 mb-1">Sort Order</label>
<input v-model="editPriorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" /> <input v-model="editPriorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Scope</label> <label class="block text-xs text-gray-500 mb-1">Scope</label>
<input :value="editingPriority.group_id ? 'Group-specific' : 'Global'" disabled type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800/60 dark:text-white rounded-lg opacity-70" /> <input :value="editingPriority.group_id ? 'Group-specific' : 'Global'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
</div> </div>
<div class="col-span-2 flex justify-end gap-2"> <div class="col-span-2 flex justify-end gap-2">
<button type="button" @click="editingPriority = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button> <button type="button" @click="editingPriority = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
@@ -265,35 +265,35 @@
<div v-if="activeTab === 'projects'"> <div v-if="activeTab === 'projects'">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Projects</h2> <h2 class="text-lg font-semibold text-gray-800">Projects</h2>
<button @click="showAddProject = !showAddProject" class="text-sm text-indigo-600 hover:underline"> <button @click="showAddProject = !showAddProject" class="text-sm text-indigo-600 hover:underline">
{{ showAddProject ? 'Cancel' : '+ Add Project' }} {{ showAddProject ? 'Cancel' : '+ Add Project' }}
</button> </button>
</div> </div>
<div v-if="showAddProject" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3"> <div v-if="showAddProject" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitProject" class="grid grid-cols-2 gap-3"> <form @submit.prevent="submitProject" class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Group</label> <label class="block text-xs text-gray-500 mb-1">Group</label>
<select v-model="projectForm.group_id" required class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg"> <select v-model="projectForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
<option value="">Select group</option> <option value="">Select group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option> <option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Status</label> <label class="block text-xs text-gray-500 mb-1">Status</label>
<select v-model="projectForm.status" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg"> <select v-model="projectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
<option value="active">Active</option> <option value="active">Active</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1">Name</label> <label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="projectForm.name" required type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" /> <input v-model="projectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label> <label class="block text-xs text-gray-500 mb-1">Description</label>
<textarea v-model="projectForm.description" rows="3" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg"></textarea> <textarea v-model="projectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
</div> </div>
<div class="col-span-2 flex justify-end"> <div class="col-span-2 flex justify-end">
<button type="submit" :disabled="projectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60"> <button type="submit" :disabled="projectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
@@ -308,10 +308,10 @@
<div <div
v-for="project in projects" v-for="project in projects"
:key="project.id" :key="project.id"
class="flex items-center gap-3 px-4 py-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl" class="flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-xl"
> >
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ project.name }}</p> <p class="text-sm font-medium text-gray-800">{{ project.name }}</p>
<p class="text-xs text-gray-400">{{ groups.find(g => g.id === project.group_id)?.name || 'Unknown Group' }} · {{ project.status }}</p> <p class="text-xs text-gray-400">{{ groups.find(g => g.id === project.group_id)?.name || 'Unknown Group' }} · {{ project.status }}</p>
<p v-if="project.description" class="text-xs text-gray-500 mt-1">{{ project.description }}</p> <p v-if="project.description" class="text-xs text-gray-500 mt-1">{{ project.description }}</p>
</div> </div>
@@ -322,27 +322,27 @@
</div> </div>
</div> </div>
<div v-if="editingProject" class="mt-4 bg-gray-50 dark:bg-gray-700 rounded-xl p-4 space-y-3"> <div v-if="editingProject" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Edit Project: {{ editingProject.name }}</h3> <h3 class="text-sm font-semibold text-gray-700">Edit Project: {{ editingProject.name }}</h3>
<form @submit.prevent="submitEditProject" class="grid grid-cols-2 gap-3"> <form @submit.prevent="submitEditProject" class="grid grid-cols-2 gap-3">
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1">Name</label> <label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="editProjectForm.name" required type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" /> <input v-model="editProjectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Status</label> <label class="block text-xs text-gray-500 mb-1">Status</label>
<select v-model="editProjectForm.status" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg"> <select v-model="editProjectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
<option value="active">Active</option> <option value="active">Active</option>
<option value="archived">Archived</option> <option value="archived">Archived</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Group</label> <label class="block text-xs text-gray-500 mb-1">Group</label>
<input :value="groups.find(g => g.id === editingProject.group_id)?.name || 'Unknown Group'" disabled type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800/60 dark:text-white rounded-lg opacity-70" /> <input :value="groups.find(g => g.id === editingProject.group_id)?.name || 'Unknown Group'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label> <label class="block text-xs text-gray-500 mb-1">Description</label>
<textarea v-model="editProjectForm.description" rows="3" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg"></textarea> <textarea v-model="editProjectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
</div> </div>
<div class="col-span-2 flex justify-end gap-2"> <div class="col-span-2 flex justify-end gap-2">
<button type="button" @click="editingProject = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button> <button type="button" @click="editingProject = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
@@ -354,9 +354,9 @@
</div> </div>
<div v-if="removeAgentModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <div v-if="removeAgentModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 p-6 shadow-xl"> <div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove agent?</h3> <h3 class="text-lg font-semibold text-gray-900">Remove agent?</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300"> <p class="mt-2 text-sm text-gray-600">
Remove <strong>{{ pendingAgentRemoval?.user?.name || ('User #' + pendingAgentRemoval?.user_id) }}</strong> Remove <strong>{{ pendingAgentRemoval?.user?.name || ('User #' + pendingAgentRemoval?.user_id) }}</strong>
from <strong>{{ pendingAgentRemoval?.group?.name }}</strong>? from <strong>{{ pendingAgentRemoval?.group?.name }}</strong>?
</p> </p>
@@ -368,9 +368,9 @@
</div> </div>
<div v-if="deletePriorityModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <div v-if="deletePriorityModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 p-6 shadow-xl"> <div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Delete priority?</h3> <h3 class="text-lg font-semibold text-gray-900">Delete priority?</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300"> <p class="mt-2 text-sm text-gray-600">
Delete <strong>{{ pendingPriorityDelete?.name }}</strong>? This cannot be undone. Delete <strong>{{ pendingPriorityDelete?.name }}</strong>? This cannot be undone.
</p> </p>
<div class="mt-6 flex justify-end gap-2"> <div class="mt-6 flex justify-end gap-2">
@@ -381,9 +381,9 @@
</div> </div>
<div v-if="deleteProjectModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <div v-if="deleteProjectModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="w-full max-w-md rounded-2xl bg-white dark:bg-gray-800 p-6 shadow-xl"> <div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Delete project?</h3> <h3 class="text-lg font-semibold text-gray-900">Delete project?</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300"> <p class="mt-2 text-sm text-gray-600">
Delete <strong>{{ pendingProjectDelete?.name }}</strong>? This cannot be undone. Delete <strong>{{ pendingProjectDelete?.name }}</strong>? This cannot be undone.
</p> </p>
<div class="mt-6 flex justify-end gap-2"> <div class="mt-6 flex justify-end gap-2">

View File

@@ -1,13 +1,14 @@
<template> <template>
<div class="max-w-4xl mx-auto py-8 px-4"> <AppLayout>
<div class="max-w-4xl mx-auto">
<!-- Header --> <!-- Header -->
<div class="mb-6"> <div class="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-indigo-600 hover:underline"> Back to tickets</Link>
</div> </div>
<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-sm border border-gray-200">
<!-- Ticket Header --> <!-- Ticket Header -->
<div class="p-5 border-b border-gray-200 dark:border-gray-700"> <div class="p-5 border-b border-gray-200">
<div class="flex items-start gap-3 flex-wrap"> <div class="flex items-start gap-3 flex-wrap">
<span <span
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0" class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
@@ -21,7 +22,7 @@
<div v-if="editingTitle && isAgent" class="flex items-center gap-2"> <div v-if="editingTitle && isAgent" class="flex items-center gap-2">
<input <input
v-model="titleEdit" v-model="titleEdit"
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent dark:text-white focus:outline-none" class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent focus:outline-none"
@keyup.enter="saveTitle" @keyup.enter="saveTitle"
@keyup.esc="editingTitle = false" @keyup.esc="editingTitle = false"
/> />
@@ -30,7 +31,7 @@
</div> </div>
<h1 <h1
v-else v-else
class="text-xl font-semibold text-gray-900 dark:text-white" class="text-xl font-semibold text-gray-900"
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }" :class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
@click="isAgent && startEditTitle()" @click="isAgent && startEditTitle()"
> >
@@ -64,14 +65,14 @@
v-if="isAgent" v-if="isAgent"
v-model="metaForm.priority_id" v-model="metaForm.priority_id"
@change="saveMeta" @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" class="text-xs border border-gray-200 rounded-full px-3 py-1"
> >
<option :value="null">No priority</option> <option :value="null">No priority</option>
<option v-for="p in priorities" :key="p.id" :value="p.id"> <option v-for="p in priorities" :key="p.id" :value="p.id">
{{ p.name }} {{ p.name }}
</option> </option>
</select> </select>
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300"> <span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span> <span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
{{ ticket.priority.name }} {{ ticket.priority.name }}
</span> </span>
@@ -82,7 +83,7 @@
<select <select
v-model="metaForm.assigned_to" v-model="metaForm.assigned_to"
@change="saveMeta" @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" class="text-xs border border-gray-200 rounded-full px-3 py-1"
> >
<option :value="null">Unassigned</option> <option :value="null">Unassigned</option>
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option> <option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
@@ -90,14 +91,14 @@
</div> </div>
<!-- Due date --> <!-- Due date -->
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"> <div class="flex items-center gap-1 text-xs text-gray-500">
<span>📅</span> <span>📅</span>
<input <input
v-if="isAgent" v-if="isAgent"
v-model="metaForm.due_date" v-model="metaForm.due_date"
type="date" type="date"
@change="saveMeta" @change="saveMeta"
class="text-xs border-0 bg-transparent dark:text-gray-400 cursor-pointer p-0" class="text-xs border-0 bg-transparent cursor-pointer p-0"
/> />
<span v-else>{{ ticket.due_date || 'No due date' }}</span> <span v-else>{{ ticket.due_date || 'No due date' }}</span>
</div> </div>
@@ -127,19 +128,16 @@
<div <div
v-for="msg in ticket.messages" v-for="msg in ticket.messages"
:key="msg.id" :key="msg.id"
:class="[ :class="['flex', isOwnMessage(msg) ? 'justify-end' : 'justify-start']"
'flex',
isOwnMessage(msg) ? 'justify-end' : 'justify-start'
]"
> >
<div <div
:class="[ :class="[
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm', 'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
msg.is_internal 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' ? 'bg-amber-50 border border-amber-200 text-amber-900'
: isOwnMessage(msg) : isOwnMessage(msg)
? 'bg-indigo-600 text-white' ? 'bg-indigo-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100' : 'bg-gray-100 text-gray-800'
]" ]"
> >
<div class="flex items-center gap-2 mb-1 text-xs opacity-70"> <div class="flex items-center gap-2 mb-1 text-xs opacity-70">
@@ -153,21 +151,21 @@
</div> </div>
<!-- Reply Area --> <!-- Reply Area -->
<div class="border-t border-gray-200 dark:border-gray-700 p-5"> <div class="border-t border-gray-200 p-5">
<!-- Tab switcher (agents only) --> <!-- Tab switcher (agents only) -->
<div v-if="isAgent" class="flex gap-2 mb-3"> <div v-if="isAgent" class="flex gap-2 mb-3">
<button <button
@click="replyTab = 'reply'" @click="replyTab = 'reply'"
:class="[ :class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition', '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' replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]" ]"
>Reply to submitter</button> >Reply to submitter</button>
<button <button
@click="replyTab = 'internal'" @click="replyTab = 'internal'"
:class="[ :class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition', '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' replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]" ]"
>🔒 Internal Note</button> >🔒 Internal Note</button>
</div> </div>
@@ -181,8 +179,8 @@
:class="[ :class="[
'w-full rounded-lg text-sm border', 'w-full rounded-lg text-sm border',
replyTab === 'internal' replyTab === 'internal'
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700' ? 'border-amber-300 bg-amber-50'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white' : 'border-gray-300'
]" ]"
></textarea> ></textarea>
@@ -198,7 +196,7 @@
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition', 'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
replyTab === 'internal' replyTab === 'internal'
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60' ? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
: 'bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60' : 'bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-60'
]" ]"
> >
{{ messageForm.processing ? 'Sending…' : 'Send' }} {{ messageForm.processing ? 'Sending…' : 'Send' }}
@@ -208,11 +206,13 @@
</div> </div>
</div> </div>
</div> </div>
</AppLayout>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref } from 'vue'
import { Link, useForm, router } 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,
@@ -247,8 +247,6 @@ const messageForm = useForm({
}) })
function isOwnMessage(msg) { function isOwnMessage(msg) {
// If the message user_id matches the current user (we don't have auth here easily,
// but for display purposes: agents on right when it's not submitter's message)
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
} }
@@ -295,11 +293,11 @@ function statusLabel(status) {
function statusClass(status) { function statusClass(status) {
const map = { const map = {
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200', open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200', in_progress: 'bg-purple-100 text-purple-700',
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200', pending: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200', resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400', closed: 'bg-gray-100 text-gray-500',
} }
return map[status] || 'bg-gray-100 text-gray-600' return map[status] || 'bg-gray-100 text-gray-600'
} }

View File

@@ -242,6 +242,7 @@ class TicketController extends Controller
'groups' => [], 'groups' => [],
'priorities' => [], 'priorities' => [],
'isBootstrap' => true, 'isBootstrap' => true,
'isSiteAdmin' => in_array(Auth::user()->role ?? '', ['admin', 'super_admin']),
]); ]);
} }
@@ -255,6 +256,7 @@ class TicketController extends Controller
'groups' => $groups, 'groups' => $groups,
'priorities' => $priorities, 'priorities' => $priorities,
'isBootstrap' => false, 'isBootstrap' => false,
'isSiteAdmin' => in_array(Auth::user()->role ?? '', ['admin', 'super_admin']),
]); ]);
} }

View File

@@ -235,6 +235,11 @@ class TicketingSettingsController extends Controller
if ($priority->group_id) { if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id); $this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
} }
$validated = $request->validate([ $validated = $request->validate([
@@ -264,6 +269,11 @@ class TicketingSettingsController extends Controller
if ($priority->group_id) { if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id); $this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
} }
if ($priority->tickets()->exists()) { if ($priority->tickets()->exists()) {