Files
dashboard-ticketing/resources/js/Pages/Ticketing/Index.vue
2026-04-09 10:40:23 -07:00

799 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="flex h-screen overflow-hidden bg-gray-100 text-gray-900">
<!-- Bootstrap / first-run state -->
<div v-if="isBootstrap" class="flex flex-col items-center justify-center w-full h-full text-center px-6">
<div class="max-w-md">
<div class="text-5xl mb-4">🎫</div>
<h2 class="text-2xl font-bold text-gray-800 mb-2">Ticketing isnt set up yet</h2>
<p class="text-gray-500 mb-6">Create your first group in Settings to get started. Default priorities will be seeded automatically.</p>
<Link :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-700 transition">Go to Settings</Link>
</div>
</div>
<template v-else>
<aside
class="w-full shrink-0 border-r border-gray-200 bg-white/90 backdrop-blur lg:flex lg:w-72 lg:flex-col"
:class="showSidebar ? 'flex flex-col' : 'hidden'"
>
<div class="border-b border-gray-200 p-5">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-gray-400">Shared inbox</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Ticketing</h1>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
@click="showSidebar = false"
>
Done
</button>
<Link
:href="route('ticketing.create')"
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700:bg-indigo-400"
>
New
</Link>
</div>
</div>
</div>
<div class="flex-1 space-y-6 overflow-y-auto p-5">
<section class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Group switcher</label>
<select
v-model="activeGroupId"
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm shadow-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
@change="applyFilters({ group_id: normalizeNullable(activeGroupId) })"
>
<option :value="null">All groups</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</section>
<section class="space-y-2">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Views</h2>
<span class="text-xs text-gray-400">{{ filteredTickets.length }}</span>
</div>
<button
v-for="view in visibleViews"
:key="view.key"
type="button"
class="flex w-full items-center justify-between rounded-2xl px-3 py-3 text-sm transition"
:class="currentFilter === view.key
? 'bg-gray-900 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
@click="applyFilters({ filter: view.key === 'all' ? 'all' : view.key })"
>
<span>{{ view.label }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="currentFilter === view.key ? 'bg-white/15 text-white' : 'bg-gray-200 text-gray-600'"
>
{{ viewCount(view.key) }}
</span>
</button>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Priority</h2>
<button
v-if="filters.priority_id"
type="button"
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
@click="applyFilters({ priority_id: undefined })"
>
Clear
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="priority in priorities"
:key="priority.id"
type="button"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition"
:class="Number(filters.priority_id) === priority.id ? 'shadow-sm' : 'border-gray-200 text-gray-600 hover:bg-gray-100:bg-gray-800'"
:style="priorityChipStyle(priority, Number(filters.priority_id) === priority.id)"
@click="togglePriority(priority.id)"
>
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: priority.color || '#94a3b8' }"></span>
{{ priority.name }}
</button>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Projects</h2>
<button
v-if="filters.project_id"
type="button"
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
@click="applyFilters({ project_id: undefined })"
>
Clear
</button>
</div>
<div class="space-y-1">
<button
type="button"
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
:class="!filters.project_id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
@click="applyFilters({ project_id: undefined })"
>
<span>All projects</span>
<span class="text-xs text-gray-400">{{ totalProjectCount }}</span>
</button>
<button
v-for="project in projects"
:key="project.id"
type="button"
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
:class="Number(filters.project_id) === project.id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
@click="applyFilters({ project_id: Number(filters.project_id) === project.id ? undefined : project.id })"
>
<span class="truncate">{{ project.name }}</span>
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">{{ project.ticket_count ?? 0 }}</span>
</button>
</div>
</section>
</div>
</aside>
<main
class="min-w-0 flex-1 border-r border-gray-200 bg-white xl:max-w-[34rem]"
:class="showList ? 'flex' : 'hidden lg:flex'"
>
<div class="flex min-w-0 flex-1 flex-col">
<div class="border-b border-gray-200 p-5">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Queue</p>
<h2 class="mt-1 text-xl font-semibold tracking-tight">{{ currentViewTitle }}</h2>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
@click="showSidebar = true"
>
Filters
</button>
<Link
:href="route('ticketing.create')"
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700 lg:hidden"
>
New
</Link>
<div class="hidden rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-500 sm:block">
{{ tickets.total }} total
</div>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 sm:hidden">
{{ tickets.total }} total
</div>
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
<div class="relative flex-1">
<input
v-model="search"
type="text"
placeholder="Search tickets, people, or numbers"
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
/>
</div>
<select
v-model="sortBy"
class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="priority">Priority</option>
<option value="status">Status</option>
</select>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<div v-if="filteredTickets.length === 0" class="flex h-full flex-col items-center justify-center px-6 text-center">
<div class="rounded-3xl border border-dashed border-gray-300 px-8 py-10">
<p class="text-sm font-medium text-gray-500">Nothing matches the current filters.</p>
<p class="mt-2 text-sm text-gray-400">Try a different view, search, or priority chip.</p>
</div>
</div>
<button
v-for="ticket in filteredTickets"
:key="ticket.id"
type="button"
class="flex w-full gap-4 border-b border-gray-100 px-4 py-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/60 sm:px-5"
:class="selectedTicketId === ticket.id ? 'bg-indigo-50/80' : ''"
@click="selectTicket(ticket.id)"
>
<div class="w-1 self-stretch rounded-full" :style="{ backgroundColor: ticket.priority?.color || ticket.group?.color || '#cbd5e1' }"></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-gray-400">{{ ticket.number }}</span>
<span :class="badgeClass(ticket.status)" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold">{{ statusLabel(ticket.status) }}</span>
<span v-if="hasUnread(ticket)" class="h-2.5 w-2.5 rounded-full bg-sky-500"></span>
</div>
<p class="mt-2 truncate text-sm font-semibold text-gray-900">{{ ticket.title }}</p>
<div class="mt-3 flex items-center gap-3 text-xs text-gray-500">
<div class="flex items-center gap-2">
<span class="avatar-ring bg-gray-200 text-gray-700">{{ initials(ticket.submitter?.name || ticket.submitter?.email || '?') }}</span>
<span class="truncate">{{ ticket.submitter?.name || ticket.submitter?.email || 'Unknown' }}</span>
</div>
<span></span>
<span>{{ ageLabel(ticket.created_at) }}</span>
</div>
</div>
<div class="flex shrink-0 flex-col items-end justify-between gap-3">
<div class="text-xs font-medium text-gray-500">{{ ticket.priority?.name || 'No priority' }}</div>
<div class="flex items-center gap-2">
<span class="text-[11px] uppercase tracking-[0.16em] text-gray-400">Assignee</span>
<span
class="avatar-ring"
:class="ticket.assignee ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-200 text-gray-500'"
>
{{ ticket.assignee ? initials(ticket.assignee.name || ticket.assignee.email) : '—' }}
</span>
</div>
</div>
</button>
</div>
<div v-if="tickets.last_page > 1" class="flex items-center justify-between border-t border-gray-200 px-5 py-3 text-sm">
<span class="text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
<div class="flex items-center gap-2">
<Link
v-if="tickets.prev_page_url"
:href="tickets.prev_page_url"
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
>Prev</Link>
<Link
v-if="tickets.next_page_url"
:href="tickets.next_page_url"
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
>Next</Link>
</div>
</div>
</div>
</main>
<section
class="min-w-0 flex-1 flex-col bg-gray-50"
:class="showDetail ? 'flex' : 'hidden lg:flex'"
>
<div v-if="!ticketDetail" class="flex h-full items-center justify-center p-8">
<div class="max-w-md rounded-3xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">Conversation</p>
<h3 class="mt-3 text-2xl font-semibold tracking-tight">Pick a ticket</h3>
<p class="mt-3 text-sm leading-6 text-gray-500">
The right panel becomes the full thread view: metadata, internal notes, and reply composer included. Miracles do happen.
</p>
</div>
</div>
<template v-else>
<div class="border-b border-gray-200 bg-white px-4 py-5 sm:px-6">
<div class="mb-4 lg:hidden">
<button
type="button"
class="inline-flex items-center rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100"
@click="backToList"
>
Back to list
</button>
</div>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold text-white" :style="{ backgroundColor: ticketDetail.group?.color || '#6366f1' }">{{ ticketDetail.number }}</span>
<span :class="badgeClass(ticketDetail.status)" class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold">{{ statusLabel(ticketDetail.status) }}</span>
<span v-if="ticketDetail.priority" class="inline-flex items-center gap-2 rounded-full border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-600">
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: ticketDetail.priority.color || '#94a3b8' }"></span>
{{ ticketDetail.priority.name }}
</span>
</div>
<div class="mt-3 flex items-start gap-3">
<h3 class="min-w-0 flex-1 text-2xl font-semibold tracking-tight text-gray-900">{{ ticketDetail.title }}</h3>
<Link :href="route('ticketing.show', { ticket: ticketDetail.id })" class="text-sm font-medium text-indigo-600 transition hover:text-indigo-500">Open full page</Link>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-5">
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Assignee</div>
<div class="mt-2 flex items-center gap-2 text-sm font-medium">
<span class="avatar-ring bg-indigo-100 text-indigo-700">{{ ticketDetail.assignee ? initials(ticketDetail.assignee.name || ticketDetail.assignee.email) : '—' }}</span>
<span class="truncate">{{ ticketDetail.assignee?.name || ticketDetail.assignee?.email || 'Unassigned' }}</span>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Due date</div>
<div class="mt-2 text-sm font-medium">{{ formatDate(ticketDetail.due_date, 'No due date') }}</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Project</div>
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.project?.name || 'No project' }}</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Submitter</div>
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.submitter?.name || ticketDetail.submitter?.email || 'Unknown' }}</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Opened</div>
<div class="mt-2 text-sm font-medium">{{ ageLabel(ticketDetail.created_at) }}</div>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 text-sm text-gray-500">
<span>Group: <strong class="text-gray-700">{{ ticketDetail.group?.name || 'Unknown' }}</strong></span>
<span></span>
<span>{{ ticketDetail.messages?.length || 0 }} messages</span>
<span></span>
<span>{{ ticketDetail.attachments?.length || 0 }} attachments</span>
</div>
<div v-if="isAgent" class="mt-5 grid gap-3 lg:grid-cols-4">
<select v-model="metaForm.status" class="meta-select" @change="saveMeta">
<option v-for="status in statuses" :key="status.value" :value="status.value">{{ status.label }}</option>
</select>
<select v-model="metaForm.priority_id" class="meta-select" @change="saveMeta">
<option :value="null">No priority</option>
<option v-for="priority in priorities" :key="priority.id" :value="priority.id">{{ priority.name }}</option>
</select>
<select v-model="metaForm.assigned_to" class="meta-select" @change="saveMeta">
<option :value="null">Unassigned</option>
<option v-for="agent in detailAgents" :key="agent.id" :value="agent.id">{{ agent.name || agent.email }}</option>
</select>
<input v-model="metaForm.due_date" type="date" class="meta-select" @change="saveMeta" />
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
<div v-if="ticketDetail.description" class="mb-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Description</div>
<p class="whitespace-pre-wrap text-sm leading-6 text-gray-700">{{ ticketDetail.description }}</p>
</div>
<div v-if="!ticketDetail.messages?.length" class="rounded-3xl border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500">
No messages yet.
</div>
<div v-else class="space-y-4">
<div
v-for="message in ticketDetail.messages"
:key="message.id"
class="flex"
:class="messageAlignment(message)"
>
<div class="max-w-3xl rounded-3xl px-5 py-4 shadow-sm" :class="messageCardClass(message)">
<div class="flex flex-wrap items-center gap-2 text-xs font-medium opacity-80">
<span class="inline-flex items-center gap-2">
<span class="avatar-ring bg-white/70 text-gray-700">{{ initials(messageAuthor(message)) }}</span>
{{ messageRoleLabel(message) }}
</span>
<span></span>
<span>{{ message.author?.name || message.author_email || 'Unknown' }}</span>
<span></span>
<span>{{ preciseDate(message.created_at) }}</span>
</div>
<p class="mt-3 whitespace-pre-wrap text-sm leading-6">{{ message.body }}</p>
<div v-if="message.attachments?.length" class="mt-4 flex flex-wrap gap-2">
<a
v-for="attachment in message.attachments"
:key="attachment.id"
:href="route('ticketing.attachments.show', { attachment: attachment.id })"
class="inline-flex items-center gap-2 rounded-full border border-current/20 px-3 py-1.5 text-xs font-medium transition hover:bg-white/30:bg-gray-900/30"
target="_blank"
>
📎 {{ attachment.filename }}
</a>
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 bg-white px-4 py-5 sm:px-6">
<div v-if="isAgent" class="mb-4 inline-flex rounded-2xl bg-gray-100 p-1">
<button
type="button"
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
:class="replyMode === 'reply' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
@click="replyMode = 'reply'"
>Reply</button>
<button
type="button"
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
:class="replyMode === 'internal' ? 'bg-amber-500 text-white shadow-sm' : 'text-gray-500'"
@click="replyMode = 'internal'"
>Internal note</button>
</div>
<form @submit.prevent="sendMessage" class="space-y-3">
<textarea
v-model="messageForm.body"
rows="4"
required
class="w-full rounded-3xl border px-4 py-3 text-sm outline-none transition focus:ring-2"
:class="replyMode === 'internal'
? 'border-amber-300 bg-amber-50 text-amber-950 focus:border-amber-400 focus:ring-amber-100:ring-amber-500/20'
: 'border-gray-200 bg-gray-50 text-gray-900 focus:border-indigo-400 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20'"
:placeholder="replyMode === 'internal' ? 'Internal note for agents only…' : 'Reply to the thread…'"
></textarea>
<div class="flex items-center justify-between gap-3">
<div class="text-xs text-gray-400">
{{ replyMode === 'internal' ? 'Only agents can see internal notes.' : 'Replies are visible in the ticket thread.' }}
</div>
<button
type="submit"
:disabled="messageForm.processing"
class="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-medium text-white transition disabled:cursor-not-allowed disabled:opacity-60"
:class="replyMode === 'internal' ? 'bg-amber-500 hover:bg-amber-600' : 'bg-gray-900 hover:bg-gray-700:bg-indigo-400'"
>
{{ messageForm.processing ? 'Sending…' : replyMode === 'internal' ? 'Save note' : 'Send reply' }}
</button>
</div>
</form>
</div>
</template>
</section>
</template>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { Link, router, useForm } from '@inertiajs/vue3'
const props = defineProps({
tickets: Object,
groups: Array,
priorities: Array,
projects: Array,
isAgent: Boolean,
filters: Object,
ticketDetail: Object,
detailAgents: Array,
viewCounts: Object,
isBootstrap: Boolean,
})
const search = ref('')
const sortBy = ref('newest')
const activeGroupId = ref(props.filters?.group_id ? Number(props.filters.group_id) : null)
const replyMode = ref('reply')
const showSidebar = ref(!props.ticketDetail)
const visibleViews = computed(() => {
const base = [
{ key: 'all', label: 'All Open' },
{ key: 'mine', label: 'Mine' },
{ key: 'unassigned', label: 'Unassigned' },
{ key: 'pending', label: 'Pending' },
{ key: 'resolved', label: 'Resolved' },
]
return props.isAgent ? base : base.filter((view) => ['all', 'pending', 'resolved'].includes(view.key))
})
const currentFilter = computed(() => props.filters?.filter || 'all')
const selectedTicketId = computed(() => props.ticketDetail?.id ?? (props.filters?.detail ? Number(props.filters.detail) : null))
const currentViewTitle = computed(() => visibleViews.value.find((view) => view.key === currentFilter.value)?.label || 'Tickets')
const totalProjectCount = computed(() => props.projects.reduce((sum, project) => sum + Number(project.ticket_count || 0), 0))
const showList = computed(() => !props.ticketDetail)
const showDetail = computed(() => !!props.ticketDetail)
const filteredTickets = computed(() => {
const term = search.value.trim().toLowerCase()
const priorityWeight = Object.fromEntries(
[...props.priorities].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map((priority, index, arr) => [priority.id, arr.length - index])
)
let items = [...(props.tickets?.data || [])]
if (term) {
items = items.filter((ticket) => {
const haystack = [
ticket.number,
ticket.title,
ticket.status,
ticket.submitter?.name,
ticket.submitter?.email,
ticket.assignee?.name,
ticket.assignee?.email,
ticket.priority?.name,
].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(term)
})
}
items.sort((a, b) => {
if (sortBy.value === 'oldest') {
return new Date(a.created_at) - new Date(b.created_at)
}
if (sortBy.value === 'priority') {
return (priorityWeight[b.priority_id] || 0) - (priorityWeight[a.priority_id] || 0) || new Date(b.created_at) - new Date(a.created_at)
}
if (sortBy.value === 'status') {
return statusSortValue(a.status) - statusSortValue(b.status) || new Date(b.created_at) - new Date(a.created_at)
}
return new Date(b.created_at) - new Date(a.created_at)
})
return items
})
const metaForm = useForm({
status: props.ticketDetail?.status ?? 'open',
priority_id: props.ticketDetail?.priority_id ?? null,
assigned_to: props.ticketDetail?.assigned_to ?? null,
due_date: props.ticketDetail?.due_date ?? null,
})
const messageForm = useForm({
body: '',
is_internal: false,
})
watch(
() => props.ticketDetail,
(detail) => {
replyMode.value = 'reply'
showSidebar.value = !detail
metaForm.defaults({
status: detail?.status ?? 'open',
priority_id: detail?.priority_id ?? null,
assigned_to: detail?.assigned_to ?? null,
due_date: detail?.due_date ?? null,
})
metaForm.reset()
},
{ immediate: true }
)
const statuses = [
{ value: 'open', label: 'Open' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'pending', label: 'Pending' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]
function normalizeNullable(value) {
return value === '' || value === null || value === undefined ? undefined : Number(value)
}
function buildParams(overrides = {}) {
const params = {
...props.filters,
...overrides,
}
Object.keys(params).forEach((key) => {
if (params[key] === undefined || params[key] === null || params[key] === '') {
delete params[key]
}
})
return params
}
function applyFilters(overrides = {}) {
const params = buildParams({ ...overrides, detail: overrides.detail === undefined ? props.filters?.detail : overrides.detail })
router.get(route('ticketing.index'), params, {
preserveState: true,
preserveScroll: true,
replace: true,
})
}
function togglePriority(priorityId) {
applyFilters({ priority_id: Number(props.filters.priority_id) === priorityId ? undefined : priorityId })
}
function selectTicket(ticketId) {
router.get(route('ticketing.index'), buildParams({ detail: ticketId }), {
preserveState: true,
preserveScroll: true,
replace: true,
only: ['ticketDetail', 'detailAgents', 'filters'],
})
}
function backToList() {
router.get(route('ticketing.index'), buildParams({ detail: undefined }), {
preserveState: true,
preserveScroll: true,
replace: true,
only: ['ticketDetail', 'detailAgents', 'filters'],
})
}
function saveMeta() {
if (!props.ticketDetail) return
metaForm.transform((data) => ({
...data,
priority_id: normalizeNullable(data.priority_id),
assigned_to: normalizeNullable(data.assigned_to),
due_date: data.due_date || null,
})).put(route('ticketing.update', { ticket: props.ticketDetail.id }), {
preserveScroll: true,
onSuccess: () => {
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
},
})
}
function sendMessage() {
if (!props.ticketDetail) return
messageForm.transform((data) => ({
...data,
is_internal: props.isAgent && replyMode.value === 'internal',
})).post(route('ticketing.messages.store', { ticket: props.ticketDetail.id }), {
preserveScroll: true,
onSuccess: () => {
messageForm.reset()
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
},
})
}
function viewCount(key) {
return props.viewCounts?.[key] ?? 0
}
function initials(value) {
return String(value || '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}
function badgeClass(status) {
return {
open: 'bg-sky-100 text-sky-700',
in_progress: 'bg-violet-100 text-violet-700',
pending: 'bg-amber-100 text-amber-700',
resolved: 'bg-emerald-100 text-emerald-700',
closed: 'bg-gray-200 text-gray-700',
}[status] || 'bg-gray-200 text-gray-700'
}
function priorityChipStyle(priority, active) {
const color = priority.color || '#94a3b8'
return active
? { borderColor: color, backgroundColor: `${color}18`, color }
: {}
}
function statusLabel(status) {
return {
open: 'Open',
in_progress: 'In Progress',
pending: 'Pending',
resolved: 'Resolved',
closed: 'Closed',
}[status] || status
}
function statusSortValue(status) {
return {
open: 1,
in_progress: 2,
pending: 3,
resolved: 4,
closed: 5,
}[status] || 99
}
function ageLabel(value) {
return relativeTime(value)
}
function preciseDate(value) {
if (!value) return 'Unknown time'
return new Date(value).toLocaleString()
}
function formatDate(value, fallback = '—') {
if (!value) return fallback
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function relativeTime(value) {
if (!value) return '—'
const date = new Date(value)
const diff = Math.max(1, Math.floor((Date.now() - date.getTime()) / 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`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
return `${Math.floor(diff / 604800)}w ago`
}
function hasUnread(ticket) {
if (!ticket || !props.ticketDetail) return false
return ticket.id !== props.ticketDetail.id && ['open', 'pending'].includes(ticket.status)
}
function messageAlignment(message) {
if (message.is_internal) return 'justify-center'
return isSubmitterMessage(message) ? 'justify-start' : 'justify-end'
}
function messageCardClass(message) {
if (message.is_internal) {
return 'border border-amber-200 bg-amber-50 text-amber-950'
}
if (isSubmitterMessage(message)) {
return 'border border-gray-200 bg-white text-gray-800'
}
return 'bg-gray-900 text-white'
}
function isSubmitterMessage(message) {
return Number(message.user_id) === Number(props.ticketDetail?.submitter_id)
}
function messageRoleLabel(message) {
if (message.is_internal) return 'Internal note'
return isSubmitterMessage(message) ? 'Submitter' : 'Agent reply'
}
function messageAuthor(message) {
return message.author?.name || message.author?.email || message.author_email || 'Unknown'
}
</script>
<style scoped>
.avatar-ring {
@apply inline-flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-semibold;
}
.meta-select {
@apply w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 focus:ring-indigo-500/20;
}
</style>