Fix My Tickets and queue filter
- My Tickets: exclude RESOLVED and CLOSED, show active tickets only - Queue filter: cascading Category > Type > Item picker — each leaf is a distinct queue (e.g. TheWrightServer > Automation > Backup vs Sync) - Server: support typeId and itemId as ticket list filter params Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Search, ChevronRight, X } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import api from '../api/client'
|
||||
import Layout from '../components/Layout'
|
||||
import SeverityBadge from '../components/SeverityBadge'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Avatar from '../components/Avatar'
|
||||
import { Ticket, TicketStatus, Category } from '../types'
|
||||
import { Ticket, TicketStatus, Category, CTIType, Item } from '../types'
|
||||
|
||||
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
@@ -20,41 +20,97 @@ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||
const selectClass =
|
||||
'bg-gray-800 border border-gray-700 text-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
|
||||
// Queue label built from whatever CTI level is selected
|
||||
function queueLabel(
|
||||
category: Category | null,
|
||||
type: CTIType | null,
|
||||
item: Item | null,
|
||||
): string {
|
||||
if (item && type && category) return `${category.name} › ${type.name} › ${item.name}`
|
||||
if (type && category) return `${category.name} › ${type.name}`
|
||||
if (category) return category.name
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState<TicketStatus | ''>('')
|
||||
const [severity, setSeverity] = useState('')
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
|
||||
// CTI queue filter state
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [types, setTypes] = useState<CTIType[]>([])
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null)
|
||||
const [selectedType, setSelectedType] = useState<CTIType | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
const [showQueueFilter, setShowQueueFilter] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
|
||||
}, [])
|
||||
|
||||
const handleCategorySelect = (cat: Category) => {
|
||||
setSelectedCategory(cat)
|
||||
setSelectedType(null)
|
||||
setSelectedItem(null)
|
||||
setTypes([])
|
||||
setItems([])
|
||||
api.get<CTIType[]>('/cti/types', { params: { categoryId: cat.id } }).then((r) => setTypes(r.data))
|
||||
}
|
||||
|
||||
const handleTypeSelect = (type: CTIType) => {
|
||||
setSelectedType(type)
|
||||
setSelectedItem(null)
|
||||
setItems([])
|
||||
api.get<Item[]>('/cti/items', { params: { typeId: type.id } }).then((r) => setItems(r.data))
|
||||
}
|
||||
|
||||
const handleItemSelect = (item: Item) => {
|
||||
setSelectedItem(item)
|
||||
setShowQueueFilter(false)
|
||||
}
|
||||
|
||||
const clearQueue = () => {
|
||||
setSelectedCategory(null)
|
||||
setSelectedType(null)
|
||||
setSelectedItem(null)
|
||||
setTypes([])
|
||||
setItems([])
|
||||
}
|
||||
|
||||
// Derive the most specific filter param
|
||||
const queueParams: Record<string, string> = {}
|
||||
if (selectedItem) queueParams.itemId = selectedItem.id
|
||||
else if (selectedType) queueParams.typeId = selectedType.id
|
||||
else if (selectedCategory) queueParams.categoryId = selectedCategory.id
|
||||
|
||||
const fetchTickets = useCallback(() => {
|
||||
setLoading(true)
|
||||
const params: Record<string, string> = {}
|
||||
const params: Record<string, string> = { ...queueParams }
|
||||
if (status) params.status = status
|
||||
if (severity) params.severity = severity
|
||||
if (categoryId) params.categoryId = categoryId
|
||||
if (search) params.search = search
|
||||
api
|
||||
.get<Ticket[]>('/tickets', { params })
|
||||
.then((r) => setTickets(r.data))
|
||||
.finally(() => setLoading(false))
|
||||
}, [status, severity, categoryId, search])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, severity, search, selectedCategory, selectedType, selectedItem])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(fetchTickets, 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [fetchTickets])
|
||||
|
||||
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem)
|
||||
|
||||
return (
|
||||
<Layout title="All Tickets">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-5 flex-wrap">
|
||||
<div className="flex gap-3 mb-5 flex-wrap items-start">
|
||||
<div className="relative flex-1 min-w-48 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
|
||||
<input
|
||||
@@ -91,18 +147,117 @@ export default function Dashboard() {
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">All Queues</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Queue picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowQueueFilter((v) => !v)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm border transition-colors ${
|
||||
activeQueue
|
||||
? 'bg-blue-600/20 border-blue-500/40 text-blue-400'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{activeQueue ? (
|
||||
<>
|
||||
<span className="max-w-48 truncate">{activeQueue}</span>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); clearQueue() }}
|
||||
className="text-blue-400 hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={13} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
'All Queues'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showQueueFilter && (
|
||||
<div className="absolute z-20 top-full mt-1 left-0 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex"
|
||||
style={{ minWidth: '520px' }}
|
||||
>
|
||||
{/* Categories */}
|
||||
<div className="w-44 border-r border-gray-800">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
|
||||
Category
|
||||
</p>
|
||||
<div className="overflow-auto max-h-64">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => handleCategorySelect(cat)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
|
||||
selectedCategory?.id === cat.id
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
<ChevronRight size={12} className="text-gray-600" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Types */}
|
||||
<div className="w-44 border-r border-gray-800">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
|
||||
Type
|
||||
</p>
|
||||
<div className="overflow-auto max-h-64">
|
||||
{!selectedCategory ? (
|
||||
<p className="text-xs text-gray-600 px-3 py-4">Select category</p>
|
||||
) : types.length === 0 ? (
|
||||
<p className="text-xs text-gray-600 px-3 py-4">No types</p>
|
||||
) : (
|
||||
types.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => handleTypeSelect(type)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
|
||||
selectedType?.id === type.id
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{type.name}
|
||||
<ChevronRight size={12} className="text-gray-600" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="w-44">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
|
||||
Item
|
||||
</p>
|
||||
<div className="overflow-auto max-h-64">
|
||||
{!selectedType ? (
|
||||
<p className="text-xs text-gray-600 px-3 py-4">Select type</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-xs text-gray-600 px-3 py-4">No items</p>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
className={`w-full px-3 py-2 text-sm text-left transition-colors ${
|
||||
selectedItem?.id === item.id
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-gray-300 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticket list */}
|
||||
@@ -118,7 +273,6 @@ export default function Dashboard() {
|
||||
to={`/tickets/${ticket.displayId}`}
|
||||
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 hover:bg-gray-900/80 transition-all group"
|
||||
>
|
||||
{/* Severity stripe */}
|
||||
<div
|
||||
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
||||
ticket.severity === 1
|
||||
|
||||
@@ -15,9 +15,16 @@ export default function MyTickets() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
api
|
||||
.get<Ticket[]>('/tickets', { params: { assigneeId: user.id } })
|
||||
.then((r) => setTickets(r.data))
|
||||
// Only show active tickets — OPEN and IN_PROGRESS
|
||||
Promise.all([
|
||||
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'OPEN' } }),
|
||||
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'IN_PROGRESS' } }),
|
||||
])
|
||||
.then(([openRes, inProgressRes]) => {
|
||||
const combined = [...openRes.data, ...inProgressRes.data]
|
||||
combined.sort((a, b) => a.severity - b.severity || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
setTickets(combined)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [user])
|
||||
|
||||
@@ -27,7 +34,7 @@ export default function MyTickets() {
|
||||
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-600 text-sm">
|
||||
No tickets assigned to you
|
||||
No active tickets assigned to you
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -37,7 +44,6 @@ export default function MyTickets() {
|
||||
to={`/tickets/${ticket.displayId}`}
|
||||
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 transition-all group"
|
||||
>
|
||||
{/* Severity stripe */}
|
||||
<div
|
||||
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
||||
ticket.severity === 1
|
||||
|
||||
Reference in New Issue
Block a user