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