Files
dashboard-ticketing/resources/js/Pages/Ticketing/Show.vue

317 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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>
<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' }"
>
{{ 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>
<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>
<!-- Message Thread -->
<div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
<div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
No messages yet.
</div>
<div
v-for="msg in ticket.messages"
:key="msg.id"
:class="[
'flex',
isOwnMessage(msg) ? 'justify-end' : 'justify-start'
]"
>
<div
:class="[
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
msg.is_internal
? 'bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 text-amber-900 dark:text-amber-100'
: isOwnMessage(msg)
? 'bg-indigo-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
]"
>
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
<span v-if="msg.is_internal">🔒 Internal Note · </span>
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
<span>· {{ timeAgo(msg.created_at) }}</span>
</div>
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
</div>
</div>
</div>
<!-- Reply Area -->
<div class="border-t border-gray-200 dark:border-gray-700 p-5">
<!-- Tab switcher (agents only) -->
<div v-if="isAgent" class="flex gap-2 mb-3">
<button
@click="replyTab = 'reply'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
]"
>Reply to submitter</button>
<button
@click="replyTab = 'internal'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
]"
>🔒 Internal Note</button>
</div>
<form @submit.prevent="sendMessage">
<textarea
v-model="messageForm.body"
required
rows="3"
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
:class="[
'w-full rounded-lg text-sm border',
replyTab === 'internal'
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700'
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
]"
></textarea>
<div class="flex items-center justify-between mt-3">
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
<input type="file" class="hidden" @change="attachFile" />
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
</label>
<button
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-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60'
]"
>
{{ messageForm.processing ? 'Sending…' : 'Send' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Link, useForm, router } from '@inertiajs/vue3'
const props = defineProps({
ticket: Object,
isAgent: Boolean,
isManager: Boolean,
agents: Array,
priorities: Array,
})
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,
})
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
}
function startEditTitle() {
titleEdit.value = props.ticket.title
editingTitle.value = true
}
function saveTitle() {
router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
onSuccess: () => { editingTitle.value = false }
})
}
function saveMeta() {
metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
}
function sendMessage() {
messageForm.is_internal = replyTab.value === 'internal'
messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
onSuccess: () => { messageForm.body = '' }
})
}
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 }))
}
}
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 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[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>