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 { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Search } from 'lucide-react'
|
import { Search, ChevronRight, X } from 'lucide-react'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import Layout from '../components/Layout'
|
import Layout from '../components/Layout'
|
||||||
import SeverityBadge from '../components/SeverityBadge'
|
import SeverityBadge from '../components/SeverityBadge'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import Avatar from '../components/Avatar'
|
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 }[] = [
|
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||||
{ value: '', label: 'All Statuses' },
|
{ value: '', label: 'All Statuses' },
|
||||||
@@ -20,41 +20,97 @@ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
|||||||
const selectClass =
|
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'
|
'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() {
|
export default function Dashboard() {
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([])
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [status, setStatus] = useState<TicketStatus | ''>('')
|
const [status, setStatus] = useState<TicketStatus | ''>('')
|
||||||
const [severity, setSeverity] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
|
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(() => {
|
const fetchTickets = useCallback(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const params: Record<string, string> = {}
|
const params: Record<string, string> = { ...queueParams }
|
||||||
if (status) params.status = status
|
if (status) params.status = status
|
||||||
if (severity) params.severity = severity
|
if (severity) params.severity = severity
|
||||||
if (categoryId) params.categoryId = categoryId
|
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
api
|
api
|
||||||
.get<Ticket[]>('/tickets', { params })
|
.get<Ticket[]>('/tickets', { params })
|
||||||
.then((r) => setTickets(r.data))
|
.then((r) => setTickets(r.data))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [status, severity, categoryId, search])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [status, severity, search, selectedCategory, selectedType, selectedItem])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(fetchTickets, 300)
|
const t = setTimeout(fetchTickets, 300)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [fetchTickets])
|
}, [fetchTickets])
|
||||||
|
|
||||||
|
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="All Tickets">
|
<Layout title="All Tickets">
|
||||||
{/* Filters */}
|
{/* 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">
|
<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} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
|
||||||
<input
|
<input
|
||||||
@@ -91,18 +147,117 @@ export default function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
{/* Queue picker */}
|
||||||
value={categoryId}
|
<div className="relative">
|
||||||
onChange={(e) => setCategoryId(e.target.value)}
|
<button
|
||||||
className={selectClass}
|
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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">All Queues</option>
|
{activeQueue ? (
|
||||||
{categories.map((c) => (
|
<>
|
||||||
<option key={c.id} value={c.id}>
|
<span className="max-w-48 truncate">{activeQueue}</span>
|
||||||
{c.name}
|
<span
|
||||||
</option>
|
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>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Ticket list */}
|
{/* Ticket list */}
|
||||||
@@ -118,7 +273,6 @@ export default function Dashboard() {
|
|||||||
to={`/tickets/${ticket.displayId}`}
|
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"
|
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
|
<div
|
||||||
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
||||||
ticket.severity === 1
|
ticket.severity === 1
|
||||||
|
|||||||
@@ -15,9 +15,16 @@ export default function MyTickets() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
api
|
// Only show active tickets — OPEN and IN_PROGRESS
|
||||||
.get<Ticket[]>('/tickets', { params: { assigneeId: user.id } })
|
Promise.all([
|
||||||
.then((r) => setTickets(r.data))
|
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))
|
.finally(() => setLoading(false))
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
@@ -27,7 +34,7 @@ export default function MyTickets() {
|
|||||||
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
|
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
|
||||||
) : tickets.length === 0 ? (
|
) : tickets.length === 0 ? (
|
||||||
<div className="text-center py-16 text-gray-600 text-sm">
|
<div className="text-center py-16 text-gray-600 text-sm">
|
||||||
No tickets assigned to you
|
No active tickets assigned to you
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -37,7 +44,6 @@ export default function MyTickets() {
|
|||||||
to={`/tickets/${ticket.displayId}`}
|
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"
|
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
|
<div
|
||||||
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
|
||||||
ticket.severity === 1
|
ticket.severity === 1
|
||||||
|
|||||||
@@ -67,13 +67,15 @@ router.use('/:ticketId/comments', commentRouter)
|
|||||||
|
|
||||||
// GET /api/tickets
|
// GET /api/tickets
|
||||||
router.get('/', async (req: AuthRequest, res) => {
|
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> = {}
|
const where: Record<string, unknown> = {}
|
||||||
if (status) where.status = status
|
if (status) where.status = status
|
||||||
if (severity) where.severity = Number(severity)
|
if (severity) where.severity = Number(severity)
|
||||||
if (assigneeId) where.assigneeId = assigneeId
|
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) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search as string, mode: 'insensitive' } },
|
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||||
|
|||||||
Reference in New Issue
Block a user