Fix My Tickets and queue filter
All checks were successful
Build & Push / Build Server (push) Successful in 58s
Build & Push / Build Client (push) Successful in 41s

- 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:
2026-03-30 23:35:33 -04:00
parent 725f91578d
commit d751e36ae8
3 changed files with 190 additions and 28 deletions

View File

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

View File

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

View File

@@ -67,13 +67,15 @@ router.use('/:ticketId/comments', commentRouter)
// GET /api/tickets
router.get('/', async (req: AuthRequest, res) => {
const { status, severity, assigneeId, categoryId, search } = req.query
const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query
const where: Record<string, unknown> = {}
if (status) where.status = status
if (severity) where.severity = Number(severity)
if (assigneeId) where.assigneeId = assigneeId
if (categoryId) where.categoryId = categoryId
if (itemId) where.itemId = itemId
else if (typeId) where.typeId = typeId
else if (categoryId) where.categoryId = categoryId
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },