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,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>