feat: full dashboard-ticketing scaffold with data model, controllers, Vue pages

This commit is contained in:
Joel Wedemire
2026-04-08 17:10:30 -07:00
parent 81d0d54f50
commit 391699220f
33 changed files with 1947 additions and 719 deletions

View File

@@ -1,112 +1,111 @@
<template>
<AppLayout title="New Ticket">
<div class="p-6 max-w-2xl">
<div class="flex items-center gap-3 mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline"> Tickets</Link>
<h1 class="text-2xl font-bold text-gray-900">Submit a Ticket</h1>
<div class="max-w-2xl mx-auto py-8 px-4">
<div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">Submit a Ticket</h1>
</div>
<form @submit.prevent="submit" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-5">
<!-- Group -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Group <span class="text-red-500">*</span></label>
<select
v-model="form.group_id"
required
class="w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg text-sm"
>
<option value="">Select a group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
<p v-if="form.errors.group_id" class="text-xs text-red-600 mt-1">{{ form.errors.group_id }}</p>
</div>
<form @submit.prevent="submit" class="bg-white rounded-xl shadow p-6 space-y-5">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
<input
v-model="form.title"
type="text"
maxlength="255"
placeholder="Brief summary of the issue"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.title }"
/>
<p v-if="errors.title" class="mt-1 text-xs text-red-500">{{ errors.title }}</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 mb-1">Description <span class="text-red-500">*</span></label>
<textarea
v-model="form.description"
rows="5"
placeholder="Describe the issue in detail…"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.description }"
/>
<p v-if="errors.description" class="mt-1 text-xs text-red-500">{{ errors.description }}</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>
<!-- Category + Priority -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Category <span class="text-red-500">*</span></label>
<select
v-model="form.category"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.category }"
>
<option value="">Select category</option>
<option value="IT">IT</option>
<option value="Facilities">Facilities</option>
<option value="HR">HR</option>
<option value="Other">Other</option>
</select>
<p v-if="errors.category" class="mt-1 text-xs text-red-500">{{ errors.category }}</p>
</div>
<!-- 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>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priority <span class="text-red-500">*</span></label>
<select
v-model="form.priority"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': errors.priority }"
>
<option value="">Select priority</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<p v-if="errors.priority" class="mt-1 text-xs text-red-500">{{ errors.priority }}</p>
</div>
</div>
<!-- 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>
<div class="flex justify-end gap-3 pt-2">
<Link :href="route('ticketing.index')" class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition">
Cancel
</Link>
<button
type="submit"
:disabled="processing"
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color: var(--color-sidebar-active-bg)"
>
{{ processing ? 'Submitting…' : 'Submit Ticket' }}
</button>
</div>
</form>
</div>
</AppLayout>
<!-- 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>
<script setup>
import { ref } from 'vue'
import { computed } from 'vue'
import { Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const form = useForm({
title: '',
description: '',
category: '',
priority: 'medium',
const props = defineProps({
groups: Array,
priorities: Array,
})
const processing = ref(false)
const errors = ref({})
const form = useForm({
group_id: '',
title: '',
description: '',
priority_id: null,
due_date: '',
})
const filteredPriorities = computed(() => {
if (!form.group_id) return props.priorities.filter(p => !p.group_id)
return props.priorities.filter(p => !p.group_id || p.group_id === Number(form.group_id))
})
function submit() {
processing.value = true
form.post(route('ticketing.store'), {
onError: (e) => { errors.value = e },
onFinish: () => { processing.value = false },
})
form.post(route('ticketing.store'))
}
</script>

View File

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

View File

@@ -1,197 +1,255 @@
<template>
<AppLayout title="Ticketing">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Tickets</h1>
<div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
<!-- Left Rail (240px) -->
<aside class="w-60 flex-shrink-0 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-y-auto">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Ticketing</h1>
</div>
<!-- Group Switcher -->
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 uppercase tracking-wide">Group</label>
<select
v-model="activeGroupId"
@change="applyFilter({ group_id: activeGroupId || undefined })"
class="w-full text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
>
<option :value="null">All Groups</option>
<option v-for="g in groups" :key="g.id" :value="g.id">
<span :style="{ color: g.color }"></span> {{ g.name }}
</option>
</select>
</div>
<!-- Filter Views -->
<nav class="p-3 space-y-1 border-b border-gray-200 dark:border-gray-700">
<button
v-for="view in filterViews"
:key="view.key"
@click="applyFilter({ filter: view.key })"
:class="[
'w-full text-left px-3 py-1.5 rounded-md text-sm transition-colors',
activeFilter === view.key
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200 font-medium'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
]"
>
{{ view.label }}
</button>
</nav>
<!-- Priority Filters -->
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Priority</p>
<div class="flex flex-wrap gap-1">
<button
v-for="p in priorities"
:key="p.id"
@click="applyFilter({ priority_id: filters.priority_id === p.id ? undefined : p.id })"
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors',
filters.priority_id === p.id
? 'border-current font-semibold'
: 'border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300'
]"
:style="filters.priority_id === p.id ? { color: p.color, borderColor: p.color } : {}"
>
<span :style="{ color: p.color }"></span> {{ p.name }}
</button>
</div>
</div>
<!-- Projects -->
<div class="p-3">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Projects</p>
<div class="space-y-1">
<p v-if="!projects.length" class="text-xs text-gray-400 italic">No active projects</p>
<button
v-for="proj in projects"
:key="proj.id"
class="w-full text-left px-2 py-1 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md truncate"
>
📁 {{ proj.name }}
</button>
</div>
</div>
</aside>
<!-- Middle Panel: Ticket Queue -->
<main class="flex-1 flex flex-col min-w-0 border-r border-gray-200 dark:border-gray-700" style="max-width: 520px;">
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
{{ currentViewLabel }}
<span class="ml-1.5 text-xs font-normal text-gray-400">({{ tickets.total }})</span>
</h2>
<Link
:href="route('ticketing.create')"
class="inline-flex items-center px-4 py-2 text-white rounded-lg transition"
style="background-color: var(--color-sidebar-active-bg)"
class="inline-flex items-center gap-1 text-xs bg-indigo-600 text-white px-3 py-1.5 rounded-md hover:bg-indigo-700 transition"
>
+ New Ticket
+ New
</Link>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="relative flex-1 min-w-[200px] max-w-md">
<input
v-model="searchInput"
type="text"
placeholder="Search tickets…"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
@keydown.enter="applyFilters"
/>
<div class="overflow-y-auto flex-1">
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500 text-sm">
No tickets found.
</div>
<select v-model="filters.status" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value="">All Statuses</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select v-model="filters.priority" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value="">All Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<select v-model="filters.category" @change="applyFilters" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value="">All Categories</option>
<option value="IT">IT</option>
<option value="Facilities">Facilities</option>
<option value="HR">HR</option>
<option value="Other">Other</option>
</select>
<button
@click="applyFilters"
class="px-4 py-2 text-sm text-white rounded-lg transition"
style="background-color: var(--color-sidebar-active-bg)"
>Search</button>
<button
v-if="hasActiveFilters"
@click="clearFilters"
class="px-4 py-2 text-sm text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition"
> Clear</button>
</div>
<div class="bg-white rounded-xl shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 uppercase text-xs">
<tr>
<th class="px-6 py-3 text-left">#</th>
<th class="px-6 py-3 text-left">Title</th>
<th class="px-6 py-3 text-left">Category</th>
<th class="px-6 py-3 text-left">Priority</th>
<th class="px-6 py-3 text-left">Status</th>
<th v-if="isAdmin" class="px-6 py-3 text-left">Submitter</th>
<th class="px-6 py-3 text-left">Created</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-if="tickets.data.length === 0">
<td :colspan="isAdmin ? 8 : 7" class="px-6 py-10 text-center text-gray-400">
No tickets found.
</td>
</tr>
<tr
v-for="ticket in tickets.data"
:key="ticket.id"
class="hover:bg-gray-50 transition"
>
<td class="px-6 py-4 text-gray-500">{{ ticket.id }}</td>
<td class="px-6 py-4 font-medium text-gray-900 max-w-xs truncate">{{ ticket.title }}</td>
<td class="px-6 py-4 text-gray-600">{{ ticket.category }}</td>
<td class="px-6 py-4">
<span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)">
{{ ticket.priority }}
</span>
</td>
<td class="px-6 py-4">
<span class="px-2 py-1 rounded text-xs font-medium" :class="statusClass(ticket.status)">
{{ formatStatus(ticket.status) }}
</span>
</td>
<td v-if="isAdmin" class="px-6 py-4 text-gray-600">{{ ticket.submitter?.name ?? '' }}</td>
<td class="px-6 py-4 text-gray-500">{{ formatDate(ticket.created_at) }}</td>
<td class="px-6 py-4 text-right space-x-3">
<Link :href="route('ticketing.show', ticket.id)" class="text-xs text-indigo-600 hover:underline">View</Link>
<Link :href="route('ticketing.edit', ticket.id)" class="text-xs text-gray-500 hover:underline">Edit</Link>
</td>
</tr>
</tbody>
</table>
</div>
v-for="ticket in tickets.data"
:key="ticket.id"
@click="selectTicket(ticket)"
:class="[
'w-full text-left px-4 py-3 border-b border-gray-100 dark:border-gray-700 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700',
selectedTicketId === ticket.id ? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-500' : ''
]"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<!-- Ticket number badge -->
<span
class="inline-block text-xs font-mono font-semibold px-1.5 py-0.5 rounded text-white"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>
{{ ticket.number }}
</span>
<!-- Priority dot -->
<span
v-if="ticket.priority"
class="w-2 h-2 rounded-full flex-shrink-0"
:style="{ backgroundColor: ticket.priority.color }"
:title="ticket.priority.name"
></span>
</div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">{{ ticket.title }}</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ ticket.submitter?.name || ticket.submitter?.email || '—' }}
</p>
</div>
<div class="flex-shrink-0 text-right space-y-1">
<span :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-0.5 rounded-full font-medium">
{{ statusLabel(ticket.status) }}
</span>
<p class="text-xs text-gray-400">{{ timeAgo(ticket.created_at) }}</p>
</div>
</div>
</button>
</div>
<!-- Pagination -->
<div v-if="tickets.last_page > 1" class="mt-4 flex items-center justify-between">
<p class="text-sm text-gray-600">
Showing {{ tickets.from }}{{ tickets.to }} of {{ tickets.total }} tickets
</p>
<div v-if="tickets.last_page > 1" class="px-4 py-2 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex justify-between items-center">
<span class="text-xs text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
<div class="flex gap-2">
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="px-3 py-1 text-sm rounded border border-gray-300 hover:bg-gray-50 transition"> Prev</Link>
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="px-3 py-1 text-sm rounded border border-gray-300 hover:bg-gray-50 transition">Next </Link>
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-xs text-indigo-600 hover:underline"> Prev</Link>
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-xs text-indigo-600 hover:underline">Next </Link>
</div>
</div>
</main>
<!-- Right Panel: Ticket Detail -->
<div class="flex-1 min-w-0 flex flex-col bg-white dark:bg-gray-800">
<div v-if="!selectedTicketId" class="flex items-center justify-center h-full text-gray-400 dark:text-gray-500 text-sm">
Select a ticket to view details
</div>
<div v-else class="flex flex-col h-full">
<!-- We use an iframe-like approach: navigate to show page embedded -->
<!-- For now, reload the show page in a contained div via Inertia visit -->
<div class="p-4 text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2 border-b border-gray-200 dark:border-gray-700">
<span class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded text-white"
:style="{ backgroundColor: selectedTicket?.group?.color || '#6366f1' }">
{{ selectedTicket?.number }}
</span>
<span class="font-medium text-gray-800 dark:text-gray-100 truncate">{{ selectedTicket?.title }}</span>
<Link :href="route('ticketing.show', { ticket: selectedTicketId })" class="ml-auto text-xs text-indigo-600 hover:underline">Open </Link>
</div>
<div class="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
<Link
:href="route('ticketing.show', { ticket: selectedTicketId })"
class="inline-flex items-center gap-2 text-sm text-indigo-600 hover:underline"
>
View full conversation
</Link>
</div>
</div>
</div>
</AppLayout>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Link, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({
tickets: Object,
search: { type: String, default: '' },
statusFilter: { type: String, default: '' },
priorityFilter: { type: String, default: '' },
categoryFilter: { type: String, default: '' },
isAdmin: { type: Boolean, default: false },
tickets: Object,
groups: Array,
priorities: Array,
projects: Array,
isAgent: Boolean,
filters: Object,
})
const searchInput = ref(props.search)
const filters = ref({
status: props.statusFilter,
priority: props.priorityFilter,
category: props.categoryFilter,
const selectedTicketId = ref(null)
const activeGroupId = ref(props.filters?.group_id || null)
const activeFilter = ref(props.filters?.filter || 'all')
const filterViews = [
{ key: 'all', label: '📥 All Open' },
{ key: 'mine', label: '👤 Mine' },
{ key: 'unassigned', label: '🔲 Unassigned' },
{ key: 'pending', label: '⏳ Pending' },
]
const currentViewLabel = computed(() => {
return filterViews.find(v => v.key === activeFilter.value)?.label || 'Tickets'
})
const hasActiveFilters = computed(() =>
searchInput.value || filters.value.status || filters.value.priority || filters.value.category
)
const selectedTicket = computed(() => {
return props.tickets.data.find(t => t.id === selectedTicketId.value) || null
})
function applyFilters() {
router.get(route('ticketing.index'), {
search: searchInput.value,
status: filters.value.status,
priority: filters.value.priority,
category: filters.value.category,
}, { preserveState: true, replace: true })
function selectTicket(ticket) {
selectedTicketId.value = ticket.id
}
function clearFilters() {
searchInput.value = ''
filters.value = { status: '', priority: '', category: '' }
router.get(route('ticketing.index'), {}, { preserveState: true, replace: true })
function applyFilter(newFilters) {
const merged = { ...props.filters, ...newFilters }
// Remove undefined/null values
Object.keys(merged).forEach(k => {
if (merged[k] === undefined || merged[k] === null) delete merged[k]
})
if (newFilters.filter !== undefined) activeFilter.value = newFilters.filter || 'all'
router.get(route('ticketing.index'), merged, { preserveState: true, replace: true })
}
function formatDate(d) {
return new Date(d).toLocaleDateString('en-CA')
}
function formatStatus(s) {
const map = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' }
return map[s] ?? s
}
function statusClass(s) {
function statusLabel(status) {
const map = {
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-600',
open: 'Open',
in_progress: 'In Progress',
pending: 'Pending',
resolved: 'Resolved',
closed: 'Closed',
}
return map[s] ?? 'bg-gray-100 text-gray-600'
return map[status] || status
}
function priorityClass(p) {
function statusClass(status) {
const map = {
low: 'bg-gray-100 text-gray-600',
medium: 'bg-blue-100 text-blue-700',
high: 'bg-orange-100 text-orange-700',
urgent: 'bg-red-100 text-red-700',
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
}
return map[p] ?? 'bg-gray-100 text-gray-600'
return map[status] || 'bg-gray-100 text-gray-600'
}
function timeAgo(dateStr) {
const date = new Date(dateStr)
const now = new Date()
const diff = Math.floor((now - date) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="max-w-3xl mx-auto py-8 px-4">
<div class="mb-6 flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">My Tickets</h1>
<Link :href="route('ticketing.create')" class="inline-flex items-center gap-1 bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition">
+ Submit Ticket
</Link>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 text-sm">
You haven't submitted any tickets yet.
</div>
<Link
v-for="ticket in tickets.data"
:key="ticket.id"
:href="route('ticketing.show', { ticket: ticket.id })"
class="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
>
<span
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0 mt-0.5"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>{{ ticket.number }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">{{ ticket.title }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ ticket.group?.name }} · {{ timeAgo(ticket.created_at) }}</p>
</div>
<span :class="statusClass(ticket.status)" class="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0">
{{ statusLabel(ticket.status) }}
</span>
</Link>
</div>
<!-- Pagination -->
<div v-if="tickets.last_page > 1" class="mt-4 flex justify-center gap-4 text-sm">
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-indigo-600 hover:underline"> Previous</Link>
<span class="text-gray-500">{{ tickets.current_page }} / {{ tickets.last_page }}</span>
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-indigo-600 hover:underline">Next </Link>
</div>
</div>
</template>
<script setup>
import { Link } from '@inertiajs/vue3'
defineProps({ tickets: Object })
function statusLabel(status) {
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
return map[status] || status
}
function statusClass(status) {
const map = {
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
in_progress: 'bg-purple-100 text-purple-700',
pending: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
}
return map[status] || 'bg-gray-100 text-gray-600'
}
function timeAgo(dateStr) {
const date = new Date(dateStr)
const now = new Date()
const diff = Math.floor((now - date) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
</script>

View File

@@ -0,0 +1,299 @@
<template>
<div class="max-w-5xl mx-auto py-8 px-4">
<div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">Ticketing Settings</h1>
</div>
<!-- Flash message -->
<div v-if="$page.props.flash?.success" class="mb-4 px-4 py-2 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 text-green-700 dark:text-green-300 rounded-lg text-sm">
{{ $page.props.flash.success }}
</div>
<!-- Tabs -->
<div class="flex gap-1 border-b border-gray-200 dark:border-gray-700 mb-6">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
:class="[
'px-4 py-2.5 text-sm font-medium border-b-2 transition',
activeTab === tab.key
? 'border-indigo-600 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700'
]"
>{{ tab.label }}</button>
</div>
<!-- Groups Tab -->
<div v-if="activeTab === 'groups'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Groups</h2>
<button @click="showAddGroup = !showAddGroup" class="text-sm text-indigo-600 hover:underline">
{{ showAddGroup ? 'Cancel' : '+ Add Group' }}
</button>
</div>
<!-- Add Group Form -->
<div v-if="showAddGroup" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">New Group</h3>
<form @submit.prevent="submitGroup" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="groupForm.name" required type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Prefix (e.g. IT)</label>
<input v-model="groupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg uppercase" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
<input v-model="groupForm.email_address" type="email" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input v-model="groupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
</div>
<div class="col-span-2 flex justify-end">
<button type="submit" :disabled="groupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
Create Group
</button>
</div>
</form>
</div>
<!-- Groups List -->
<div class="space-y-3">
<div v-if="groups.length === 0" class="text-sm text-gray-400 italic">No groups yet.</div>
<div
v-for="group in groups"
:key="group.id"
class="flex items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl"
>
<div class="flex items-center gap-3">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: group.color }"></span>
<div>
<p class="font-medium text-sm text-gray-800 dark:text-gray-100">{{ group.name }}</p>
<p class="text-xs text-gray-400">{{ group.prefix }} · {{ group.email_address || 'No email' }}</p>
</div>
</div>
<button @click="startEditGroup(group)" class="text-xs text-indigo-600 hover:underline">Edit</button>
</div>
</div>
<!-- Edit Group Form -->
<div v-if="editingGroup" class="mt-4 bg-gray-50 dark:bg-gray-700 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Edit: {{ editingGroup.name }}</h3>
<form @submit.prevent="submitEditGroup" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="editGroupForm.name" required type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Prefix</label>
<input v-model="editGroupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg uppercase" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
<input v-model="editGroupForm.email_address" type="email" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input v-model="editGroupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
</div>
<div class="col-span-2 flex justify-end gap-2">
<button type="button" @click="editingGroup = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
<button type="submit" :disabled="editGroupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
</div>
</form>
</div>
</div>
<!-- Agents Tab -->
<div v-if="activeTab === 'agents'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Agents</h2>
<button @click="showAddAgent = !showAddAgent" class="text-sm text-indigo-600 hover:underline">
{{ showAddAgent ? 'Cancel' : '+ Add Agent' }}
</button>
</div>
<!-- Add Agent Form -->
<div v-if="showAddAgent" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitAgent" class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">User ID</label>
<input v-model="agentForm.user_id" required type="number" placeholder="User ID" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Group</label>
<select v-model="agentForm.group_id" required class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg">
<option value="">Select group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Role</label>
<select v-model="agentForm.role" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg">
<option value="agent">Agent</option>
<option value="manager">Manager</option>
</select>
</div>
<div class="col-span-3 flex justify-end">
<button type="submit" :disabled="agentForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
Add Agent
</button>
</div>
</form>
</div>
<!-- Agents List -->
<div class="space-y-2">
<div v-if="agents.length === 0" class="text-sm text-gray-400 italic">No agents configured yet.</div>
<div
v-for="access in agents"
:key="access.id"
class="flex items-center justify-between px-4 py-2.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl"
>
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ access.user?.name || 'User #' + access.user_id }}</p>
<p class="text-xs text-gray-400">{{ access.user?.email }} · {{ access.group?.name || 'Unknown Group' }} · <span class="capitalize">{{ access.role }}</span></p>
</div>
<button
@click="removeAgent(access)"
class="text-xs text-red-500 hover:underline"
>Remove</button>
</div>
</div>
</div>
<!-- Priorities Tab -->
<div v-if="activeTab === 'priorities'">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-100">Priority Levels</h2>
<button @click="showAddPriority = !showAddPriority" class="text-sm text-indigo-600 hover:underline">
{{ showAddPriority ? 'Cancel' : '+ Add Priority' }}
</button>
</div>
<!-- Add Priority Form -->
<div v-if="showAddPriority" class="bg-gray-50 dark:bg-gray-700 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitPriority" class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="priorityForm.name" required type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input v-model="priorityForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
</div>
<div class="col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label>
<input v-model="priorityForm.description" type="text" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Sort Order</label>
<input v-model="priorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Group (blank = global)</label>
<select v-model="priorityForm.group_id" class="w-full text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white rounded-lg">
<option :value="null">Global</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div class="col-span-2 flex justify-end">
<button type="submit" :disabled="priorityForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
Create Priority
</button>
</div>
</form>
</div>
<!-- Priorities List -->
<div class="space-y-2">
<div v-if="priorities.length === 0" class="text-sm text-gray-400 italic">No priorities defined yet.</div>
<div
v-for="p in priorities"
:key="p.id"
class="flex items-center gap-3 px-4 py-2.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl"
>
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: p.color }"></span>
<div class="flex-1">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">{{ p.name }}</p>
<p v-if="p.description" class="text-xs text-gray-400">{{ p.description }}</p>
</div>
<span class="text-xs text-gray-400">{{ p.group_id ? 'Group-specific' : 'Global' }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Link, useForm, router } from '@inertiajs/vue3'
const props = defineProps({
groups: Array,
agents: Array,
priorities: Array,
myGroupIds: Array,
})
const activeTab = ref('groups')
const tabs = [
{ key: 'groups', label: 'Groups' },
{ key: 'agents', label: 'Agents' },
{ key: 'priorities', label: 'Priorities' },
]
const showAddGroup = ref(false)
const showAddAgent = ref(false)
const showAddPriority = ref(false)
const editingGroup = ref(null)
// Forms
const groupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
const editGroupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
const agentForm = useForm({ user_id: '', group_id: '', role: 'agent' })
const priorityForm = useForm({ name: '', color: '#6b7280', description: '', sort_order: 0, group_id: null })
function submitGroup() {
groupForm.post(route('ticketing.settings.groups.store'), {
onSuccess: () => { showAddGroup.value = false; groupForm.reset() }
})
}
function startEditGroup(group) {
editingGroup.value = group
editGroupForm.name = group.name
editGroupForm.email_address = group.email_address || ''
editGroupForm.color = group.color
editGroupForm.prefix = group.prefix
}
function submitEditGroup() {
editGroupForm.put(route('ticketing.settings.groups.update', { group: editingGroup.value.id }), {
onSuccess: () => { editingGroup.value = null }
})
}
function submitAgent() {
agentForm.post(route('ticketing.settings.agents.store'), {
onSuccess: () => { showAddAgent.value = false; agentForm.reset() }
})
}
function removeAgent(access) {
if (confirm('Remove this agent?')) {
router.delete(route('ticketing.settings.agents.destroy', { access: access.id }))
}
}
function submitPriority() {
priorityForm.post(route('ticketing.settings.priorities.store'), {
onSuccess: () => { showAddPriority.value = false; priorityForm.reset() }
})
}
</script>

View File

@@ -1,156 +1,316 @@
<template>
<AppLayout :title="`Ticket #${ticket.id}`">
<div class="p-6 max-w-3xl">
<div class="flex items-center gap-3 mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-gray-500 hover:underline"> Tickets</Link>
<h1 class="text-2xl font-bold text-gray-900">Ticket #{{ ticket.id }}</h1>
<span class="px-2 py-1 rounded text-xs font-medium" :class="statusClass(ticket.status)">
{{ formatStatus(ticket.status) }}
</span>
<span class="px-2 py-1 rounded text-xs font-medium" :class="priorityClass(ticket.priority)">
{{ ticket.priority }}
</span>
</div>
<div class="max-w-4xl mx-auto py-8 px-4">
<!-- Header -->
<div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline"> Back to tickets</Link>
</div>
<!-- Ticket Details -->
<div class="bg-white rounded-xl shadow p-6 mb-6">
<div class="flex items-start justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">{{ ticket.title }}</h2>
<Link :href="route('ticketing.edit', ticket.id)" class="text-sm text-indigo-600 hover:underline">Edit</Link>
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap mb-5">{{ ticket.description }}</p>
<div class="grid grid-cols-2 gap-4 text-sm text-gray-600 border-t pt-4">
<div><span class="font-medium">Category:</span> {{ ticket.category }}</div>
<div><span class="font-medium">Priority:</span> {{ ticket.priority }}</div>
<div><span class="font-medium">Submitted by:</span> {{ ticket.submitter?.name ?? '—' }}</div>
<div><span class="font-medium">Assigned to:</span> {{ ticket.assignee?.name ?? 'Unassigned' }}</div>
<div><span class="font-medium">Created:</span> {{ formatDate(ticket.created_at) }}</div>
<div><span class="font-medium">Updated:</span> {{ formatDate(ticket.updated_at) }}</div>
</div>
</div>
<!-- Comments -->
<div class="bg-white rounded-xl shadow p-6 mb-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">
Comments ({{ visibleComments.length }})
</h3>
<div v-if="visibleComments.length === 0" class="text-sm text-gray-400 py-4 text-center">
No comments yet.
</div>
<div class="space-y-4">
<div
v-for="comment in visibleComments"
:key="comment.id"
:class="['rounded-lg p-4 text-sm', comment.is_internal ? 'bg-yellow-50 border border-yellow-200' : 'bg-gray-50']"
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<!-- Ticket Header -->
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start gap-3 flex-wrap">
<span
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-gray-800">{{ comment.author?.name ?? 'Unknown' }}</span>
<span class="text-xs text-gray-400">{{ formatDate(comment.created_at) }}</span>
{{ ticket.number }}
</span>
<!-- Title (editable inline for agents) -->
<div class="flex-1 min-w-0">
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
<input
v-model="titleEdit"
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent dark:text-white focus:outline-none"
@keyup.enter="saveTitle"
@keyup.esc="editingTitle = false"
/>
<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>
<p class="text-gray-700 whitespace-pre-wrap">{{ comment.body }}</p>
<span v-if="comment.is_internal" class="mt-1 inline-block text-xs text-yellow-700 font-medium">Staff note</span>
<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>
<!-- Meta row -->
<div class="flex flex-wrap items-center gap-3 mt-3">
<!-- Status -->
<div>
<select
v-if="isAgent"
v-model="metaForm.status"
@change="saveMeta"
class="text-xs border-0 rounded-full px-3 py-1 font-medium cursor-pointer"
:class="statusClass(metaForm.status)"
>
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
<span v-else :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-1 rounded-full font-medium">
{{ statusLabel(ticket.status) }}
</span>
</div>
<!-- Priority -->
<div>
<select
v-if="isAgent"
v-model="metaForm.priority_id"
@change="saveMeta"
class="text-xs border border-gray-200 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
>
<option :value="null">No priority</option>
<option v-for="p in priorities" :key="p.id" :value="p.id">
{{ p.name }}
</option>
</select>
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
{{ ticket.priority.name }}
</span>
</div>
<!-- Assignee -->
<div v-if="isAgent">
<select
v-model="metaForm.assigned_to"
@change="saveMeta"
class="text-xs border border-gray-200 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
>
<option :value="null">Unassigned</option>
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<!-- Due date -->
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<span>📅</span>
<input
v-if="isAgent"
v-model="metaForm.due_date"
type="date"
@change="saveMeta"
class="text-xs border-0 bg-transparent dark:text-gray-400 cursor-pointer p-0"
/>
<span v-else>{{ ticket.due_date || 'No due date' }}</span>
</div>
<!-- Edit / Delete actions -->
<div class="ml-auto flex gap-2">
<Link
v-if="isAgent"
:href="route('ticketing.edit', { ticket: ticket.id })"
class="text-xs text-indigo-600 hover:underline"
>Edit</Link>
<button
v-if="isManager"
@click="confirmDelete"
class="text-xs text-red-500 hover:underline"
>Delete</button>
</div>
</div>
</div>
<!-- Add Comment Form -->
<div class="bg-white rounded-xl shadow p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">Add Comment</h3>
<form @submit.prevent="submitComment" class="space-y-4">
<textarea
v-model="commentForm.body"
rows="4"
placeholder="Write your comment…"
class="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
:class="{ 'border-red-400': commentErrors.body }"
/>
<p v-if="commentErrors.body" class="text-xs text-red-500">{{ commentErrors.body }}</p>
<!-- 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-if="isAdmin" class="flex items-center gap-2">
<input id="is_internal" v-model="commentForm.is_internal" type="checkbox" class="rounded border-gray-300" />
<label for="is_internal" class="text-sm text-gray-700">Mark as internal staff note</label>
<div
v-for="msg in ticket.messages"
:key="msg.id"
:class="[
'flex',
isOwnMessage(msg) ? 'justify-end' : 'justify-start'
]"
>
<div
:class="[
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
msg.is_internal
? 'bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 text-amber-900 dark:text-amber-100'
: isOwnMessage(msg)
? 'bg-indigo-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
]"
>
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
<span v-if="msg.is_internal">🔒 Internal Note · </span>
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
<span>· {{ timeAgo(msg.created_at) }}</span>
</div>
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
</div>
</div>
</div>
<div class="flex justify-end">
<!-- Reply Area -->
<div class="border-t border-gray-200 dark:border-gray-700 p-5">
<!-- Tab switcher (agents only) -->
<div v-if="isAgent" class="flex gap-2 mb-3">
<button
@click="replyTab = 'reply'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
]"
>Reply to submitter</button>
<button
@click="replyTab = 'internal'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
]"
>🔒 Internal Note</button>
</div>
<form @submit.prevent="sendMessage">
<textarea
v-model="messageForm.body"
required
rows="3"
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
:class="[
'w-full rounded-lg text-sm border',
replyTab === 'internal'
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
]"
></textarea>
<div class="flex items-center justify-between mt-3">
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
<input type="file" class="hidden" @change="attachFile" />
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
</label>
<button
type="submit"
:disabled="commentProcessing"
class="px-5 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color: var(--color-sidebar-active-bg)"
:disabled="messageForm.processing"
:class="[
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
replyTab === 'internal'
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
: 'bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60'
]"
>
{{ commentProcessing ? 'Posting…' : 'Post Comment' }}
{{ messageForm.processing ? 'Sending…' : 'Send' }}
</button>
</div>
</form>
</div>
</div>
</AppLayout>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
import { Link, useForm, router } from '@inertiajs/vue3'
const props = defineProps({
ticket: Object,
isAdmin: { type: Boolean, default: false },
ticket: Object,
isAgent: Boolean,
isManager: Boolean,
agents: Array,
priorities: Array,
})
const commentForm = useForm({
body: '',
const replyTab = ref('reply')
const editingTitle = ref(false)
const titleEdit = ref(props.ticket.title)
const statuses = [
{ value: 'open', label: 'Open' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'pending', label: 'Pending' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]
const metaForm = useForm({
status: props.ticket.status,
priority_id: props.ticket.priority_id,
assigned_to: props.ticket.assigned_to,
due_date: props.ticket.due_date,
})
const messageForm = useForm({
body: '',
is_internal: false,
})
const commentProcessing = ref(false)
const commentErrors = ref({})
function isOwnMessage(msg) {
// If the message user_id matches the current user (we don't have auth here easily,
// but for display purposes: agents on right when it's not submitter's message)
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
}
// Non-admins don't see internal notes
const visibleComments = computed(() =>
props.isAdmin
? props.ticket.comments
: props.ticket.comments.filter(c => !c.is_internal)
)
function startEditTitle() {
titleEdit.value = props.ticket.title
editingTitle.value = true
}
function submitComment() {
commentProcessing.value = true
commentForm.post(route('ticketing.comments.store', props.ticket.id), {
onError: (e) => { commentErrors.value = e },
onSuccess: () => { commentForm.reset() },
onFinish: () => { commentProcessing.value = false },
function saveTitle() {
router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
onSuccess: () => { editingTitle.value = false }
})
}
function formatDate(d) {
return new Date(d).toLocaleDateString('en-CA')
function saveMeta() {
metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
}
function formatStatus(s) {
const map = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' }
return map[s] ?? s
function sendMessage() {
messageForm.is_internal = replyTab.value === 'internal'
messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
onSuccess: () => { messageForm.body = '' }
})
}
function statusClass(s) {
const map = {
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-600',
function attachFile(event) {
const file = event.target.files[0]
if (!file) return
const data = new FormData()
data.append('file', file)
router.post(route('ticketing.attachments.store', { ticket: props.ticket.id }), data)
}
function confirmDelete() {
if (confirm(`Delete ticket ${props.ticket.number}? This cannot be undone.`)) {
router.delete(route('ticketing.destroy', { ticket: props.ticket.id }))
}
return map[s] ?? 'bg-gray-100 text-gray-600'
}
function priorityClass(p) {
function statusLabel(status) {
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
return map[status] || status
}
function statusClass(status) {
const map = {
low: 'bg-gray-100 text-gray-600',
medium: 'bg-blue-100 text-blue-700',
high: 'bg-orange-100 text-orange-700',
urgent: 'bg-red-100 text-red-700',
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
}
return map[p] ?? 'bg-gray-100 text-gray-600'
return map[status] || 'bg-gray-100 text-gray-600'
}
function timeAgo(dateStr) {
const date = new Date(dateStr)
const now = new Date()
const diff = Math.floor((now - date) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
</script>