Build hybrid ticketing inbox
This commit is contained in:
@@ -1,183 +1,419 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
|
<div class="flex h-screen overflow-hidden bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||||
<!-- Left Rail (240px) -->
|
<aside class="hidden w-72 shrink-0 border-r border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90 lg:flex lg:flex-col">
|
||||||
<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="border-b border-slate-200 p-5 dark:border-slate-800">
|
||||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">Ticketing</h1>
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">Shared inbox</p>
|
||||||
|
<h1 class="mt-1 text-2xl font-semibold tracking-tight">Ticketing</h1>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
:href="route('ticketing.create')"
|
||||||
|
class="inline-flex h-10 items-center justify-center rounded-xl bg-slate-900 px-4 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group Switcher -->
|
<div class="flex-1 space-y-6 overflow-y-auto p-5">
|
||||||
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
|
<section class="space-y-2">
|
||||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 uppercase tracking-wide">Group</label>
|
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Group switcher</label>
|
||||||
<select
|
<select
|
||||||
v-model="activeGroupId"
|
v-model="activeGroupId"
|
||||||
@change="applyFilter({ group_id: activeGroupId || undefined })"
|
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm shadow-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-800 dark:focus:border-indigo-500 dark:focus:ring-indigo-500/20"
|
||||||
class="w-full text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white"
|
@change="applyFilters({ group_id: normalizeNullable(activeGroupId) })"
|
||||||
>
|
>
|
||||||
<option :value="null">All Groups</option>
|
<option :value="null">All groups</option>
|
||||||
<option v-for="g in groups" :key="g.id" :value="g.id">
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||||
<span :style="{ color: g.color }">●</span> {{ g.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
</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-slate-400">Views</h2>
|
||||||
|
<span class="text-xs text-slate-400">{{ filteredTickets.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Views -->
|
|
||||||
<nav class="p-3 space-y-1 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
<button
|
||||||
v-for="view in filterViews"
|
v-for="view in visibleViews"
|
||||||
:key="view.key"
|
:key="view.key"
|
||||||
@click="applyFilter({ filter: view.key })"
|
type="button"
|
||||||
:class="[
|
class="flex w-full items-center justify-between rounded-2xl px-3 py-3 text-sm transition"
|
||||||
'w-full text-left px-3 py-1.5 rounded-md text-sm transition-colors',
|
:class="currentFilter === view.key
|
||||||
activeFilter === view.key
|
? 'bg-slate-900 text-white shadow-sm dark:bg-indigo-500'
|
||||||
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200 font-medium'
|
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'"
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
@click="applyFilters({ filter: view.key === 'all' ? 'all' : view.key })"
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ view.label }}
|
<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-slate-200 text-slate-600 dark:bg-slate-800 dark:text-slate-300'"
|
||||||
|
>
|
||||||
|
{{ viewCount(view.key) }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</section>
|
||||||
|
|
||||||
<!-- Priority Filters -->
|
<section class="space-y-3">
|
||||||
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wide">Priority</p>
|
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Priority</h2>
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
<button
|
||||||
v-for="p in priorities"
|
v-if="filters.priority_id"
|
||||||
:key="p.id"
|
type="button"
|
||||||
@click="applyFilter({ priority_id: filters.priority_id === p.id ? undefined : p.id })"
|
class="text-xs text-slate-400 transition hover:text-slate-600 dark:hover:text-slate-200"
|
||||||
:class="[
|
@click="applyFilters({ priority_id: undefined })"
|
||||||
'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 }}
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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-slate-200 text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-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-slate-400">Projects</h2>
|
||||||
|
<button
|
||||||
|
v-if="filters.project_id"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-slate-400 transition hover:text-slate-600 dark:hover:text-slate-200"
|
||||||
|
@click="applyFilters({ project_id: undefined })"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</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">
|
<div class="space-y-1">
|
||||||
<p v-if="!projects.length" class="text-xs text-gray-400 italic">No active projects</p>
|
|
||||||
<button
|
<button
|
||||||
v-for="proj in projects"
|
type="button"
|
||||||
:key="proj.id"
|
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
|
||||||
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"
|
:class="!filters.project_id ? 'bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'"
|
||||||
|
@click="applyFilters({ project_id: undefined })"
|
||||||
>
|
>
|
||||||
📁 {{ proj.name }}
|
<span>All projects</span>
|
||||||
|
<span class="text-xs text-slate-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-slate-100 text-slate-900 dark:bg-slate-800 dark:text-slate-100' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-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-slate-200 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">{{ project.ticket_count ?? 0 }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Middle Panel: Ticket Queue -->
|
<main class="flex min-w-0 flex-1 border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-925 xl:max-w-[34rem]">
|
||||||
<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 min-w-0 flex-1 flex-col">
|
||||||
<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">
|
<div class="border-b border-slate-200 p-5 dark:border-slate-800">
|
||||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
<div class="flex items-center justify-between gap-3">
|
||||||
{{ currentViewLabel }}
|
<div>
|
||||||
<span class="ml-1.5 text-xs font-normal text-gray-400">({{ tickets.total }})</span>
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Queue</p>
|
||||||
</h2>
|
<h2 class="mt-1 text-xl font-semibold tracking-tight">{{ currentViewTitle }}</h2>
|
||||||
<Link
|
</div>
|
||||||
:href="route('ticketing.create')"
|
<div class="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-500 dark:border-slate-700 dark:text-slate-300">
|
||||||
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"
|
{{ tickets.total }} total
|
||||||
>
|
</div>
|
||||||
+ New
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-y-auto flex-1">
|
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||||
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 dark:text-gray-500 text-sm">
|
<div class="relative flex-1">
|
||||||
No tickets found.
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tickets, people, or numbers"
|
||||||
|
class="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-800 dark:focus:border-indigo-500 dark:focus:ring-indigo-500/20"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="sortBy"
|
||||||
|
class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-800 dark:focus:border-indigo-500 dark:focus: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-slate-300 px-8 py-10 dark:border-slate-700">
|
||||||
|
<p class="text-sm font-medium text-slate-500 dark:text-slate-300">Nothing matches the current filters.</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">Try a different view, search, or priority chip.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="ticket in tickets.data"
|
v-for="ticket in filteredTickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
@click="selectTicket(ticket)"
|
type="button"
|
||||||
:class="[
|
class="flex w-full gap-4 border-b border-slate-100 px-5 py-4 text-left transition hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-900/60"
|
||||||
'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',
|
:class="selectedTicketId === ticket.id ? 'bg-indigo-50/80 dark:bg-indigo-500/10' : ''"
|
||||||
selectedTicketId === ticket.id ? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-500' : ''
|
@click="selectTicket(ticket.id)"
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<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="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2 mb-0.5">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Ticket number badge -->
|
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-slate-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-slate-900 dark:text-slate-100">{{ ticket.title }}</p>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="avatar-ring bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-100">{{ 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-slate-500 dark:text-slate-400">{{ ticket.priority?.name || 'No priority' }}</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-[11px] uppercase tracking-[0.16em] text-slate-400">Assignee</span>
|
||||||
<span
|
<span
|
||||||
class="inline-block text-xs font-mono font-semibold px-1.5 py-0.5 rounded text-white"
|
class="avatar-ring"
|
||||||
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
:class="ticket.assignee ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-200' : 'bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-300'"
|
||||||
>
|
>
|
||||||
{{ ticket.number }}
|
{{ ticket.assignee ? initials(ticket.assignee.name || ticket.assignee.email) : '—' }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<div v-if="tickets.last_page > 1" class="flex items-center justify-between border-t border-slate-200 px-5 py-3 text-sm dark:border-slate-800">
|
||||||
<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-slate-500 dark:text-slate-400">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
|
||||||
<span class="text-xs text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex gap-2">
|
<Link
|
||||||
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-xs text-indigo-600 hover:underline">← Prev</Link>
|
v-if="tickets.prev_page_url"
|
||||||
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-xs text-indigo-600 hover:underline">Next →</Link>
|
:href="tickets.prev_page_url"
|
||||||
|
class="rounded-xl border border-slate-200 px-3 py-1.5 text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
>Prev</Link>
|
||||||
|
<Link
|
||||||
|
v-if="tickets.next_page_url"
|
||||||
|
:href="tickets.next_page_url"
|
||||||
|
class="rounded-xl border border-slate-200 px-3 py-1.5 text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
|
>Next</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Right Panel: Ticket Detail -->
|
<section class="flex min-w-0 flex-1 flex-col bg-slate-50 dark:bg-slate-950/80">
|
||||||
<div class="flex-1 min-w-0 flex flex-col bg-white dark:bg-gray-800">
|
<div v-if="!ticketDetail" class="flex h-full items-center justify-center p-8">
|
||||||
<div v-if="!selectedTicketId" class="flex items-center justify-center h-full text-gray-400 dark:text-gray-500 text-sm">
|
<div class="max-w-md rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
||||||
Select a ticket to view details
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-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-slate-500 dark:text-slate-400">
|
||||||
|
The right panel becomes the full thread view: metadata, internal notes, and reply composer included. Miracles do happen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col h-full">
|
</div>
|
||||||
<!-- We use an iframe-like approach: navigate to show page embedded -->
|
|
||||||
<!-- For now, reload the show page in a contained div via Inertia visit -->
|
<template v-else>
|
||||||
<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">
|
<div class="border-b border-slate-200 bg-white px-6 py-5 dark:border-slate-800 dark:bg-slate-900">
|
||||||
<span class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded text-white"
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
:style="{ backgroundColor: selectedTicket?.group?.color || '#6366f1' }">
|
<div class="min-w-0 flex-1">
|
||||||
{{ selectedTicket?.number }}
|
<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-slate-200 px-2.5 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:text-slate-300">
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: ticketDetail.priority.color || '#94a3b8' }"></span>
|
||||||
|
{{ ticketDetail.priority.name }}
|
||||||
</span>
|
</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>
|
||||||
<div class="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 text-sm">
|
|
||||||
<Link
|
<div class="mt-3 flex items-start gap-3">
|
||||||
:href="route('ticketing.show', { ticket: selectedTicketId })"
|
<h3 class="min-w-0 flex-1 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-100">{{ ticketDetail.title }}</h3>
|
||||||
class="inline-flex items-center gap-2 text-sm text-indigo-600 hover:underline"
|
<Link :href="route('ticketing.show', { ticket: ticketDetail.id })" class="text-sm font-medium text-indigo-600 transition hover:text-indigo-500 dark:text-indigo-300">Open full page</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-3 md:grid-cols-5">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-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 dark:bg-indigo-500/20 dark:text-indigo-200">{{ 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-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-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-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-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-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-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-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-950/60">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-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-slate-500 dark:text-slate-400">
|
||||||
|
<span>Group: <strong class="text-slate-700 dark:text-slate-200">{{ 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-6 py-6">
|
||||||
|
<div v-if="ticketDetail.description" class="mb-6 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div class="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Description</div>
|
||||||
|
<p class="whitespace-pre-wrap text-sm leading-6 text-slate-700 dark:text-slate-300">{{ ticketDetail.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!ticketDetail.messages?.length" class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||||
|
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)"
|
||||||
>
|
>
|
||||||
View full conversation →
|
<div class="max-w-3xl rounded-3xl px-5 py-4 shadow-sm" :class="messageCardClass(message)">
|
||||||
</Link>
|
<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-slate-700 dark:bg-slate-900/60 dark:text-slate-100">{{ 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 dark:hover:bg-slate-900/30"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
📎 {{ attachment.filename }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-slate-200 bg-white px-6 py-5 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div v-if="isAgent" class="mb-4 inline-flex rounded-2xl bg-slate-100 p-1 dark:bg-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
|
||||||
|
:class="replyMode === 'reply' ? 'bg-white text-slate-900 shadow-sm dark:bg-slate-700 dark:text-white' : 'text-slate-500 dark:text-slate-300'"
|
||||||
|
@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-slate-500 dark:text-slate-300'"
|
||||||
|
@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 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100 dark:focus:ring-amber-500/20'
|
||||||
|
: 'border-slate-200 bg-slate-50 text-slate-900 focus:border-indigo-400 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:focus:border-indigo-500 dark:focus: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-slate-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-slate-900 hover:bg-slate-700 dark:bg-indigo-500 dark:hover:bg-indigo-400'"
|
||||||
|
>
|
||||||
|
{{ messageForm.processing ? 'Sending…' : replyMode === 'internal' ? 'Save note' : 'Send reply' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { Link, router } from '@inertiajs/vue3'
|
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
tickets: Object,
|
tickets: Object,
|
||||||
@@ -186,70 +422,303 @@ const props = defineProps({
|
|||||||
projects: Array,
|
projects: Array,
|
||||||
isAgent: Boolean,
|
isAgent: Boolean,
|
||||||
filters: Object,
|
filters: Object,
|
||||||
|
ticketDetail: Object,
|
||||||
|
detailAgents: Array,
|
||||||
|
viewCounts: Object,
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedTicketId = ref(null)
|
const search = ref('')
|
||||||
const activeGroupId = ref(props.filters?.group_id || null)
|
const sortBy = ref('newest')
|
||||||
const activeFilter = ref(props.filters?.filter || 'all')
|
const activeGroupId = ref(props.filters?.group_id ? Number(props.filters.group_id) : null)
|
||||||
|
const replyMode = ref('reply')
|
||||||
|
|
||||||
const filterViews = [
|
const visibleViews = computed(() => {
|
||||||
{ key: 'all', label: '📥 All Open' },
|
const base = [
|
||||||
{ key: 'mine', label: '👤 Mine' },
|
{ key: 'all', label: 'All Open' },
|
||||||
{ key: 'unassigned', label: '🔲 Unassigned' },
|
{ key: 'mine', label: 'Mine' },
|
||||||
{ key: 'pending', label: '⏳ Pending' },
|
{ key: 'unassigned', label: 'Unassigned' },
|
||||||
|
{ key: 'pending', label: 'Pending' },
|
||||||
|
{ key: 'resolved', label: 'Resolved' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const currentViewLabel = computed(() => {
|
return props.isAgent ? base : base.filter((view) => ['all', 'pending', 'resolved'].includes(view.key))
|
||||||
return filterViews.find(v => v.key === activeFilter.value)?.label || 'Tickets'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedTicket = computed(() => {
|
const currentFilter = computed(() => props.filters?.filter || 'all')
|
||||||
return props.tickets.data.find(t => t.id === selectedTicketId.value) || null
|
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))
|
||||||
|
|
||||||
function selectTicket(ticket) {
|
const filteredTickets = computed(() => {
|
||||||
selectedTicketId.value = ticket.id
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilter(newFilters) {
|
items.sort((a, b) => {
|
||||||
const merged = { ...props.filters, ...newFilters }
|
if (sortBy.value === 'oldest') {
|
||||||
// Remove undefined/null values
|
return new Date(a.created_at) - new Date(b.created_at)
|
||||||
Object.keys(merged).forEach(k => {
|
}
|
||||||
if (merged[k] === undefined || merged[k] === null) delete merged[k]
|
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
if (newFilters.filter !== undefined) activeFilter.value = newFilters.filter || 'all'
|
|
||||||
router.get(route('ticketing.index'), merged, { preserveState: true, replace: true })
|
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'
|
||||||
|
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 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 dark:bg-sky-500/20 dark:text-sky-200',
|
||||||
|
in_progress: 'bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-200',
|
||||||
|
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
|
||||||
|
resolved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
|
||||||
|
closed: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
|
||||||
|
}[status] || 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityChipStyle(priority, active) {
|
||||||
|
const color = priority.color || '#94a3b8'
|
||||||
|
return active
|
||||||
|
? { borderColor: color, backgroundColor: `${color}18`, color }
|
||||||
|
: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status) {
|
function statusLabel(status) {
|
||||||
const map = {
|
return {
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
in_progress: 'In Progress',
|
in_progress: 'In Progress',
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
resolved: 'Resolved',
|
resolved: 'Resolved',
|
||||||
closed: 'Closed',
|
closed: 'Closed',
|
||||||
}
|
}[status] || status
|
||||||
return map[status] || status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusSortValue(status) {
|
||||||
const map = {
|
return {
|
||||||
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
|
open: 1,
|
||||||
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
|
in_progress: 2,
|
||||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
|
pending: 3,
|
||||||
resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
|
resolved: 4,
|
||||||
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
closed: 5,
|
||||||
}
|
}[status] || 99
|
||||||
return map[status] || 'bg-gray-100 text-gray-600'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr) {
|
function ageLabel(value) {
|
||||||
const date = new Date(dateStr)
|
return relativeTime(value)
|
||||||
const now = new Date()
|
}
|
||||||
const diff = Math.floor((now - date) / 1000)
|
|
||||||
|
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 < 60) return `${diff}s ago`
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
return `${Math.floor(diff / 86400)}d 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 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSubmitterMessage(message)) {
|
||||||
|
return 'border border-slate-200 bg-white text-slate-800 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-slate-900 text-white dark:bg-indigo-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</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-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-500/20;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Dashboard\Ticketing\Models\TicketingProject;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Routing\Controller;
|
use Illuminate\Routing\Controller;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -45,12 +46,35 @@ class TicketController extends Controller
|
|||||||
$agentGroupIds = $this->agentGroupIds();
|
$agentGroupIds = $this->agentGroupIds();
|
||||||
$isAgent = count($agentGroupIds) > 0;
|
$isAgent = count($agentGroupIds) > 0;
|
||||||
|
|
||||||
|
$baseQuery = Ticket::query();
|
||||||
|
|
||||||
|
if ($isAgent) {
|
||||||
|
$baseQuery->whereIn('group_id', $agentGroupIds);
|
||||||
|
} else {
|
||||||
|
$baseQuery->where('submitter_id', $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewCounts = $isAgent
|
||||||
|
? [
|
||||||
|
'all' => (clone $baseQuery)->whereNotIn('status', ['resolved', 'closed'])->count(),
|
||||||
|
'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed'])->count(),
|
||||||
|
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed'])->count(),
|
||||||
|
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
||||||
|
'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'all' => (clone $baseQuery)->count(),
|
||||||
|
'mine' => 0,
|
||||||
|
'unassigned' => 0,
|
||||||
|
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
||||||
|
'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(),
|
||||||
|
];
|
||||||
|
|
||||||
$query = Ticket::with(['group', 'priority', 'project']);
|
$query = Ticket::with(['group', 'priority', 'project']);
|
||||||
|
|
||||||
if ($isAgent) {
|
if ($isAgent) {
|
||||||
$query->whereIn('group_id', $agentGroupIds);
|
$query->whereIn('group_id', $agentGroupIds);
|
||||||
|
|
||||||
// Filters
|
|
||||||
if ($request->filled('group_id')) {
|
if ($request->filled('group_id')) {
|
||||||
$query->where('group_id', $request->group_id);
|
$query->where('group_id', $request->group_id);
|
||||||
}
|
}
|
||||||
@@ -60,24 +84,36 @@ class TicketController extends Controller
|
|||||||
if ($request->filled('priority_id')) {
|
if ($request->filled('priority_id')) {
|
||||||
$query->where('priority_id', $request->priority_id);
|
$query->where('priority_id', $request->priority_id);
|
||||||
}
|
}
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
if ($request->filter === 'mine') {
|
if ($request->filter === 'mine') {
|
||||||
$query->where('assigned_to', $user->id);
|
$query->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed']);
|
||||||
} elseif ($request->filter === 'unassigned') {
|
} elseif ($request->filter === 'unassigned') {
|
||||||
$query->whereNull('assigned_to');
|
$query->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed']);
|
||||||
} elseif ($request->filter === 'pending') {
|
} elseif ($request->filter === 'pending') {
|
||||||
$query->where('status', 'pending');
|
$query->where('status', 'pending');
|
||||||
|
} elseif ($request->filter === 'resolved') {
|
||||||
|
$query->whereIn('status', ['resolved', 'closed']);
|
||||||
} elseif (!$request->filled('status')) {
|
} elseif (!$request->filled('status')) {
|
||||||
$query->whereNotIn('status', ['resolved', 'closed']);
|
$query->whereNotIn('status', ['resolved', 'closed']);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$query->where('submitter_id', $user->id);
|
$query->where('submitter_id', $user->id);
|
||||||
|
if ($request->filled('project_id')) {
|
||||||
|
$query->where('project_id', $request->project_id);
|
||||||
|
}
|
||||||
|
if ($request->filter === 'pending') {
|
||||||
|
$query->where('status', 'pending');
|
||||||
|
} elseif ($request->filter === 'resolved') {
|
||||||
|
$query->whereIn('status', ['resolved', 'closed']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$tickets = $query->latest()->paginate(30)->withQueryString();
|
$tickets = $query->latest()->paginate(30)->withQueryString();
|
||||||
|
|
||||||
// Enrich with submitter name
|
|
||||||
$userIds = $tickets->pluck('submitter_id')->merge($tickets->pluck('assigned_to'))->filter()->unique();
|
$userIds = $tickets->pluck('submitter_id')->merge($tickets->pluck('assigned_to'))->filter()->unique();
|
||||||
$users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
||||||
|
|
||||||
$tickets->getCollection()->transform(function ($ticket) use ($users) {
|
$tickets->getCollection()->transform(function ($ticket) use ($users) {
|
||||||
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
|
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
|
||||||
@@ -87,20 +123,74 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
$groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get();
|
$groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get();
|
||||||
$priorities = PriorityLevel::whereNull('group_id')
|
$priorities = PriorityLevel::whereNull('group_id')
|
||||||
->orWhereIn('group_id', $agentGroupIds ?? [])
|
->orWhereIn('group_id', $agentGroupIds ?: [0])
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->get();
|
->get();
|
||||||
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
|
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
$projectCounts = (clone $baseQuery)
|
||||||
|
->select('project_id', DB::raw('count(*) as aggregate'))
|
||||||
|
->whereNotNull('project_id')
|
||||||
|
->groupBy('project_id')
|
||||||
|
->pluck('aggregate', 'project_id');
|
||||||
|
|
||||||
|
$projects->transform(function ($project) use ($projectCounts) {
|
||||||
|
$project->ticket_count = (int) ($projectCounts[$project->id] ?? 0);
|
||||||
|
return $project;
|
||||||
|
});
|
||||||
|
|
||||||
|
$ticketDetail = null;
|
||||||
|
$detailAgents = [];
|
||||||
|
if ($request->filled('detail')) {
|
||||||
|
$detailQuery = Ticket::with([
|
||||||
|
'group',
|
||||||
|
'priority',
|
||||||
|
'project',
|
||||||
|
'messages.attachments',
|
||||||
|
'attachments',
|
||||||
|
])->whereKey($request->detail);
|
||||||
|
|
||||||
|
if ($isAgent) {
|
||||||
|
$detailQuery->whereIn('group_id', $agentGroupIds);
|
||||||
|
} else {
|
||||||
|
$detailQuery->where('submitter_id', $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dt = $detailQuery->first();
|
||||||
|
|
||||||
|
if ($dt) {
|
||||||
|
$detailUserIds = collect([$dt->submitter_id, $dt->assigned_to])
|
||||||
|
->merge($dt->messages->pluck('user_id'))
|
||||||
|
->filter()
|
||||||
|
->unique();
|
||||||
|
$detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id');
|
||||||
|
|
||||||
|
$dt->submitter = $detailUsers[$dt->submitter_id] ?? null;
|
||||||
|
$dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null;
|
||||||
|
$dt->messages->each(function ($msg) use ($detailUsers) {
|
||||||
|
$msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null;
|
||||||
|
});
|
||||||
|
$ticketDetail = $dt;
|
||||||
|
|
||||||
|
if ($isAgent) {
|
||||||
|
$agentIds = TicketingAgentAccess::where('group_id', $dt->group_id)->pluck('user_id');
|
||||||
|
$detailAgents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('Ticketing/Index', [
|
return Inertia::render('Ticketing/Index', [
|
||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
'priorities' => $priorities,
|
'priorities' => $priorities,
|
||||||
'projects' => $projects,
|
'projects' => $projects,
|
||||||
'isAgent' => $isAgent,
|
'isAgent' => $isAgent,
|
||||||
'filters' => $request->only(['group_id', 'status', 'priority_id', 'filter']),
|
'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']),
|
||||||
|
'ticketDetail' => $ticketDetail,
|
||||||
|
'detailAgents' => $detailAgents,
|
||||||
|
'viewCounts' => $viewCounts,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,19 +244,17 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
|
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
|
||||||
|
|
||||||
// Enrich messages with user data
|
|
||||||
$userIds = $ticket->messages->pluck('user_id')->filter()->unique();
|
$userIds = $ticket->messages->pluck('user_id')->filter()->unique();
|
||||||
$users = \DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
||||||
|
|
||||||
$ticket->messages->each(function ($msg) use ($users) {
|
$ticket->messages->each(function ($msg) use ($users) {
|
||||||
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Agents for assignment picker
|
|
||||||
$agents = [];
|
$agents = [];
|
||||||
if ($isAgent) {
|
if ($isAgent) {
|
||||||
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||||
$agents = \DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
|
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
|
||||||
@@ -191,7 +279,7 @@ class TicketController extends Controller
|
|||||||
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
|
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
|
||||||
->orderBy('sort_order')->get();
|
->orderBy('sort_order')->get();
|
||||||
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
||||||
$agents = \DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
||||||
$projects = TicketingProject::where('group_id', $ticket->group_id)->get();
|
$projects = TicketingProject::where('group_id', $ticket->group_id)->get();
|
||||||
|
|
||||||
return Inertia::render('Ticketing/Edit', [
|
return Inertia::render('Ticketing/Edit', [
|
||||||
@@ -217,7 +305,6 @@ class TicketController extends Controller
|
|||||||
'project_id' => 'nullable|exists:ticketing_projects,id',
|
'project_id' => 'nullable|exists:ticketing_projects,id',
|
||||||
];
|
];
|
||||||
|
|
||||||
// assigned_to only settable by agents
|
|
||||||
if ($this->isAgent($ticket->group_id)) {
|
if ($this->isAgent($ticket->group_id)) {
|
||||||
$rules['assigned_to'] = 'nullable|exists:users,id';
|
$rules['assigned_to'] = 'nullable|exists:users,id';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user