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,103 +1,107 @@
<template> <template>
<div class="max-w-2xl mx-auto py-8 px-4"> <AppLayout>
<div class="mb-6"> <div class="max-w-2xl mx-auto">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">Submit a Ticket</h1> <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 mt-2">Submit a Ticket</h1>
</div>
<!-- Bootstrap / No groups state -->
<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 font-semibold text-base mb-2">📦 Ticketing isn't set up yet</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 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>
<form v-else @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<!-- Group -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Group <span class="text-red-500">*</span></label>
<select
v-model="form.group_id"
required
class="w-full border-gray-300 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>
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
<input
v-model="form.title"
type="text"
required
placeholder="Brief summary of the issue"
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>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
<textarea
v-model="form.description"
required
rows="5"
placeholder="Describe the issue in detail..."
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>
</div>
<!-- Priority -->
<div>
<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 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 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 rounded-lg text-sm"
/>
</div>
<!-- Submit -->
<div class="flex justify-end pt-2">
<button
type="submit"
:disabled="form.processing"
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-else>Submit Ticket</span>
</button>
</div>
</form>
</div> </div>
</AppLayout>
<!-- 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">
<p class="text-amber-800 dark:text-amber-200 font-semibold text-base mb-2">📦 Ticketing isnt 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>
<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>
</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">
<!-- 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>
<!-- Title -->
<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>
<input
v-model="form.title"
type="text"
required
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"
/>
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description <span class="text-red-500">*</span></label>
<textarea
v-model="form.description"
required
rows="5"
placeholder="Describe the issue in detail..."
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
></textarea>
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select
v-model="form.priority_id"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
>
<option :value="null">No priority</option>
<option v-for="p in filteredPriorities" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<!-- Due Date -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Due Date <span class="text-gray-400 font-normal">(optional)</span></label>
<input
v-model="form.due_date"
type="date"
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
/>
</div>
<!-- Submit -->
<div class="flex justify-end pt-2">
<button
type="submit"
: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"
>
<span v-if="form.processing">Submitting</span>
<span v-else>Submit Ticket</span>
</button>
</div>
</form>
</div>
</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,90 +1,93 @@
<template> <template>
<div class="max-w-2xl mx-auto py-8 px-4"> <AppLayout>
<div class="mb-6"> <div class="max-w-2xl mx-auto">
<Link :href="route('ticketing.show', { ticket: ticket.id })" class="text-sm text-indigo-600 hover:underline"> Back to ticket</Link> <div class="mb-6">
<h1 class="text-xl font-bold text-gray-900 dark:text-white mt-2"> <Link :href="route('ticketing.show', { ticket: ticket.id })" class="text-sm text-indigo-600 hover:underline"> Back to ticket</Link>
Edit <span class="font-mono text-base">{{ ticket.number }}</span> <h1 class="text-xl font-bold text-gray-900 mt-2">
</h1> Edit <span class="font-mono text-base">{{ ticket.number }}</span>
</h1>
</div>
<form @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<!-- Title -->
<div>
<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 rounded-lg text-sm" />
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<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>
</div>
<!-- Status -->
<div>
<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 rounded-lg text-sm">
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<!-- Priority -->
<div>
<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 rounded-lg text-sm">
<option :value="null">No priority</option>
<option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<!-- Assignee -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
<select v-model="form.assigned_to" class="w-full border-gray-300 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 mb-1">Project</label>
<select v-model="form.project_id" class="w-full border-gray-300 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 mb-1">Due Date</label>
<input v-model="form.due_date" type="date" class="w-full border-gray-300 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 border border-gray-300 rounded-lg hover:bg-gray-50"
>Cancel</Link>
<button
type="submit"
:disabled="form.processing"
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' }}
</button>
</div>
</form>
</div> </div>
</AppLayout>
<form @submit.prevent="submit" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
<input v-model="form.title" type="text" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" />
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<textarea v-model="form.description" rows="6" required class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"></textarea>
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select v-model="form.status" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select v-model="form.priority_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
<option :value="null">No priority</option>
<option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<!-- Assignee -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Assignee</label>
<select v-model="form.assigned_to" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
<option :value="null">Unassigned</option>
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<!-- Project -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<select v-model="form.project_id" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm">
<option :value="null">No project</option>
<option v-for="proj in projects" :key="proj.id" :value="proj.id">{{ proj.name }}</option>
</select>
</div>
<!-- Due Date -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Due Date</label>
<input v-model="form.due_date" type="date" class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm" />
</div>
<div class="flex gap-3 justify-end pt-2">
<Link
:href="route('ticketing.show', { ticket: ticket.id })"
class="px-4 py-2 text-sm text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>Cancel</Link>
<button
type="submit"
:disabled="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"
>
{{ form.processing ? 'Saving…' : 'Save Changes' }}
</button>
</div>
</form>
</div>
</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,46 +1,49 @@
<template> <template>
<div class="max-w-3xl mx-auto py-8 px-4"> <AppLayout>
<div class="mb-6 flex justify-between items-center"> <div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">My Tickets</h1> <div class="mb-6 flex justify-between items-center">
<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"> <h1 class="text-2xl font-bold text-gray-900">My Tickets</h1>
+ Submit Ticket <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">
</Link> + Submit Ticket
</div> </Link>
<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> </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 class="bg-white rounded-xl shadow-sm border border-gray-200 divide-y divide-gray-100">
<div v-if="tickets.last_page > 1" class="mt-4 flex justify-center gap-4 text-sm"> <div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 text-sm">
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-indigo-600 hover:underline"> Previous</Link> You haven't submitted any tickets yet.
<span class="text-gray-500">{{ tickets.current_page }} / {{ tickets.last_page }}</span> </div>
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-indigo-600 hover:underline">Next </Link> <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 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 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> </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,218 +1,218 @@
<template> <template>
<div class="max-w-4xl mx-auto py-8 px-4"> <AppLayout>
<!-- Header --> <div class="max-w-4xl mx-auto">
<div class="mb-6"> <!-- Header -->
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link> <div class="mb-6">
</div> <Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link>
</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"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }" :style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
> >
{{ ticket.number }} {{ ticket.number }}
</span> </span>
<!-- Title (editable inline for agents) --> <!-- Title (editable inline for agents) -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div v-if="editingTitle && isAgent" class="flex items-center gap-2"> <div v-if="editingTitle && isAgent" class="flex items-center gap-2">
<input
v-model="titleEdit"
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent focus:outline-none"
@keyup.enter="saveTitle"
@keyup.esc="editingTitle = false"
/>
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
</div>
<h1
v-else
class="text-xl font-semibold text-gray-900"
: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>
<!-- 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 rounded-full px-3 py-1"
>
<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">
<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 rounded-full px-3 py-1"
>
<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">
<span>📅</span>
<input <input
v-model="titleEdit" v-if="isAgent"
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent dark:text-white focus:outline-none" v-model="metaForm.due_date"
@keyup.enter="saveTitle" type="date"
@keyup.esc="editingTitle = false" @change="saveMeta"
class="text-xs border-0 bg-transparent cursor-pointer p-0"
/> />
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button> <span v-else>{{ ticket.due_date || 'No due date' }}</span>
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button> </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>
<h1
v-else
class="text-xl font-semibold text-gray-900 dark:text-white"
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
@click="isAgent && startEditTitle()"
>
{{ ticket.title }}
<span v-if="isAgent" class="ml-1 text-xs text-gray-400"></span>
</h1>
</div> </div>
</div> </div>
<!-- Meta row --> <!-- Message Thread -->
<div class="flex flex-wrap items-center gap-3 mt-3"> <div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
<!-- Status --> <div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
<div> No messages yet.
<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> </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 <div
:class="[ v-for="msg in ticket.messages"
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm', :key="msg.id"
msg.is_internal :class="['flex', isOwnMessage(msg) ? 'justify-end' : 'justify-start']"
? '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"> <div
<span v-if="msg.is_internal">🔒 Internal Note · </span>
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
<span>· {{ timeAgo(msg.created_at) }}</span>
</div>
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
</div>
</div>
</div>
<!-- Reply Area -->
<div class="border-t border-gray-200 dark:border-gray-700 p-5">
<!-- Tab switcher (agents only) -->
<div v-if="isAgent" class="flex gap-2 mb-3">
<button
@click="replyTab = 'reply'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
]"
>Reply to submitter</button>
<button
@click="replyTab = 'internal'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
]"
>🔒 Internal Note</button>
</div>
<form @submit.prevent="sendMessage">
<textarea
v-model="messageForm.body"
required
rows="3"
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
:class="[
'w-full rounded-lg text-sm border',
replyTab === 'internal'
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
]"
></textarea>
<div class="flex items-center justify-between mt-3">
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
<input type="file" class="hidden" @change="attachFile" />
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
</label>
<button
type="submit"
:disabled="messageForm.processing"
:class="[ :class="[
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition', 'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
replyTab === 'internal' msg.is_internal
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60' ? 'bg-amber-50 border border-amber-200 text-amber-900'
: 'bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60' : isOwnMessage(msg)
? 'bg-indigo-600 text-white'
: 'bg-gray-100 text-gray-800'
]" ]"
> >
{{ messageForm.processing ? 'Sending…' : 'Send' }} <div class="flex items-center gap-2 mb-1 text-xs opacity-70">
</button> <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>
</form> </div>
<!-- Reply Area -->
<div class="border-t border-gray-200 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 text-gray-600 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 text-gray-600 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'
: 'border-gray-300'
]"
></textarea>
<div class="flex items-center justify-between mt-3">
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
<input type="file" class="hidden" @change="attachFile" />
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
</label>
<button
type="submit"
:disabled="messageForm.processing"
:class="[
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
replyTab === 'internal'
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
: 'bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-60'
]"
>
{{ messageForm.processing ? 'Sending…' : 'Send' }}
</button>
</div>
</form>
</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()) {