feat(tickets): add TicketFilterBar and TicketListRow components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
150
frontend/src/components/tickets/TicketFilterBar.tsx
Normal file
150
frontend/src/components/tickets/TicketFilterBar.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
// frontend/src/components/tickets/TicketFilterBar.tsx
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TicketFilters, PSAPriority } from '@/types/tickets'
|
||||
import type { PSABoard, PSATicketStatusItem } from '@/types/integrations'
|
||||
|
||||
interface TicketFilterBarProps {
|
||||
filters: TicketFilters
|
||||
onChange: (updated: Partial<TicketFilters>) => void
|
||||
boards: PSABoard[]
|
||||
statuses: PSATicketStatusItem[]
|
||||
priorities: PSAPriority[]
|
||||
members: { id: number; name: string }[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function TicketFilterBar({
|
||||
filters, onChange, boards, statuses, priorities, members,
|
||||
total, page, pageSize, onPageChange, loading,
|
||||
}: TicketFilterBarProps) {
|
||||
const start = (page - 1) * pageSize + 1
|
||||
const end = Math.min(page * pageSize, total)
|
||||
const hasNext = page * pageSize < total
|
||||
const hasPrev = page > 1
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
className="bg-input border border-default rounded-[5px] pl-8 pr-3 py-1.5 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none w-48"
|
||||
placeholder="Search tickets..."
|
||||
value={filters.search}
|
||||
onChange={e => onChange({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assignment */}
|
||||
<select
|
||||
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
|
||||
value={typeof filters.assigned === 'number' ? String(filters.assigned) : filters.assigned}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
onChange({ assigned: v === 'me' || v === 'unassigned' || v === 'all' ? v : Number(v) })
|
||||
}}
|
||||
>
|
||||
<option value="all">All Tickets</option>
|
||||
<option value="me">My Tickets</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{members.map(m => (
|
||||
<option key={m.id} value={String(m.id)}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Board */}
|
||||
<select
|
||||
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
|
||||
value={filters.board_id ?? ''}
|
||||
onChange={e => onChange({ board_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">All Boards</option>
|
||||
{boards.map(b => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Status */}
|
||||
<select
|
||||
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
|
||||
value={filters.status_id ?? ''}
|
||||
onChange={e => onChange({ status_id: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{statuses.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Priority */}
|
||||
<select
|
||||
className="bg-input border border-default rounded-[5px] px-3 py-1.5 text-sm text-primary focus:border-accent focus:outline-none"
|
||||
value={filters.priority ?? ''}
|
||||
onChange={e => onChange({ priority: e.target.value || null })}
|
||||
>
|
||||
<option value="">All Priorities</option>
|
||||
{priorities.map(p => <option key={p.id} value={p.name}>{p.name}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Include closed */}
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={filters.include_closed}
|
||||
onChange={e => onChange({ include_closed: e.target.checked })}
|
||||
/>
|
||||
Include closed
|
||||
</label>
|
||||
|
||||
{/* Clear filters */}
|
||||
{(filters.search || filters.board_id || filters.status_id || filters.priority || filters.assigned !== 'all' || filters.include_closed) && (
|
||||
<button
|
||||
onClick={() => onChange({ search: '', board_id: null, status_id: null, priority: null, assigned: 'all', include_closed: false })}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination row */}
|
||||
{total > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{loading ? 'Loading…' : `Showing ${start}–${end} of ${total} tickets`}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
disabled={!hasPrev}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded border text-xs transition-colors',
|
||||
hasPrev
|
||||
? 'border-default text-primary hover:border-hover'
|
||||
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
disabled={!hasNext}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded border text-xs transition-colors',
|
||||
hasNext
|
||||
? 'border-default text-primary hover:border-hover'
|
||||
: 'border-default text-muted-foreground opacity-40 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/tickets/TicketListRow.tsx
Normal file
72
frontend/src/components/tickets/TicketListRow.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// frontend/src/components/tickets/TicketListRow.tsx
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PSATicketSearchResult } from '@/types/integrations'
|
||||
|
||||
interface TicketListRowProps {
|
||||
ticket: PSATicketSearchResult
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
Critical: 'text-danger',
|
||||
High: 'text-danger',
|
||||
Medium: 'text-warning',
|
||||
Low: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
New: { bg: 'bg-accent/10', text: 'text-accent' },
|
||||
'In Progress': { bg: 'bg-warning/10', text: 'text-warning' },
|
||||
Waiting: { bg: 'bg-success/10', text: 'text-success' },
|
||||
Resolved: { bg: 'bg-elevated/50', text: 'text-muted-foreground' },
|
||||
}
|
||||
|
||||
function statusStyle(name: string | null) {
|
||||
if (!name) return { bg: 'bg-elevated', text: 'text-muted-foreground' }
|
||||
return STATUS_STYLES[name] ?? { bg: 'bg-elevated', text: 'text-muted-foreground' }
|
||||
}
|
||||
|
||||
export function TicketListRow({ ticket, selected, onClick }: TicketListRowProps) {
|
||||
const { bg, text } = statusStyle(ticket.status_name)
|
||||
const priorityClass = PRIORITY_STYLES[ticket.priority_name ?? ''] ?? 'text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={e => e.key === 'Enter' && onClick()}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-default text-sm',
|
||||
selected ? 'bg-accent/5' : 'hover:bg-elevated'
|
||||
)}
|
||||
>
|
||||
{/* ID */}
|
||||
<span className="w-12 shrink-0 text-accent text-xs font-mono">#{ticket.id}</span>
|
||||
|
||||
{/* Summary */}
|
||||
<span className="flex-1 truncate text-primary font-medium">{ticket.summary}</span>
|
||||
|
||||
{/* Company */}
|
||||
<span className="w-32 shrink-0 truncate text-muted-foreground text-xs hidden md:block">
|
||||
{ticket.company_name ?? '—'}
|
||||
</span>
|
||||
|
||||
{/* Board */}
|
||||
<span className="w-28 shrink-0 truncate text-muted-foreground text-xs hidden lg:block">
|
||||
{ticket.board_name ?? '—'}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<span className={cn('shrink-0 px-1.5 py-0.5 rounded text-[11px] font-medium', bg, text)}>
|
||||
{ticket.status_name ?? '—'}
|
||||
</span>
|
||||
|
||||
{/* Priority */}
|
||||
<span className={cn('w-14 shrink-0 text-xs text-right', priorityClass)}>
|
||||
{ticket.priority_name ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user