diff --git a/frontend/src/components/tickets/NewTicketModal.tsx b/frontend/src/components/tickets/NewTicketModal.tsx new file mode 100644 index 00000000..8c08b0be --- /dev/null +++ b/frontend/src/components/tickets/NewTicketModal.tsx @@ -0,0 +1,12 @@ +// Placeholder — full implementation in Task 17 +interface Props { + defaultTab?: 'quick' | 'manual' + initialValues?: Record + summaryHint?: string + onClose: () => void + onCreated: (ticketId?: number, summary?: string) => void +} + +export function NewTicketModal(_props: Props) { + return null +} diff --git a/frontend/src/components/tickets/TicketDetailPanel.tsx b/frontend/src/components/tickets/TicketDetailPanel.tsx new file mode 100644 index 00000000..c691559e --- /dev/null +++ b/frontend/src/components/tickets/TicketDetailPanel.tsx @@ -0,0 +1,12 @@ +// Placeholder — full implementation in Task 16 +import type { PSATicketSearchResult } from '@/types/integrations' + +interface Props { + ticket: PSATicketSearchResult + onClose: () => void + onStatusUpdated?: (ticketId: number, newStatus: string) => void +} + +export function TicketDetailPanel(_props: Props) { + return
Loading ticket details…
+} diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx new file mode 100644 index 00000000..6bdf30c2 --- /dev/null +++ b/frontend/src/pages/TicketsPage.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Plus, Ticket } from 'lucide-react' +import { TicketFilterBar } from '@/components/tickets/TicketFilterBar' +import { TicketListRow } from '@/components/tickets/TicketListRow' +import { TicketDetailPanel } from '@/components/tickets/TicketDetailPanel' +import { NewTicketModal } from '@/components/tickets/NewTicketModal' +import { integrationsApi } from '@/api/integrations' +import { ticketsApi } from '@/api/tickets' +import type { PSATicketSearchResult, PSABoard, PSATicketStatusItem } from '@/types/integrations' +import type { TicketFilters, PSAPriority } from '@/types/tickets' +import { DEFAULT_TICKET_FILTERS } from '@/types/tickets' + +const PAGE_SIZE = 25 + +function filtersFromParams(params: URLSearchParams): TicketFilters & { page: number } { + const assigned = params.get('assigned') ?? 'all' + return { + ...DEFAULT_TICKET_FILTERS, + search: params.get('search') ?? '', + board_id: params.get('board') ? Number(params.get('board')) : null, + status_id: params.get('status') ? Number(params.get('status')) : null, + priority: params.get('priority') ?? null, + company_id: params.get('company') ? Number(params.get('company')) : null, + assigned: (assigned === 'me' || assigned === 'unassigned' || assigned === 'all') + ? assigned + : Number(assigned), + include_closed: params.get('closed') === 'true', + page: params.get('page') ? Number(params.get('page')) : 1, + } +} + +export default function TicketsPage() { + const [searchParams, setSearchParams] = useSearchParams() + const { page, ...filters } = filtersFromParams(searchParams) + + const [tickets, setTickets] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(false) + const [boards, setBoards] = useState([]) + const [statuses, setStatuses] = useState([]) + const [priorities, setPriorities] = useState([]) + const [members, setMembers] = useState<{ id: number; name: string }[]>([]) + const [selectedTicket, setSelectedTicket] = useState(null) + const [showNewTicket, setShowNewTicket] = useState(false) + + // Load filter option data once + useEffect(() => { + integrationsApi.listBoards().then(setBoards).catch(() => {}) + ticketsApi.listPriorities().then(setPriorities).catch(() => {}) + integrationsApi.listMembers() + .then(ms => setMembers(ms.map(m => ({ id: Number(m.id), name: m.name })))) + .catch(() => {}) + }, []) + + // Load statuses when board changes + useEffect(() => { + if (filters.board_id) { + integrationsApi.getTicketStatuses(String(filters.board_id)) + .then(setStatuses).catch(() => {}) + } else { + setStatuses([]) + } + }, [filters.board_id]) + + // Fetch tickets on filter/page change + const fetchTickets = useCallback(async () => { + setLoading(true) + try { + const result = await ticketsApi.searchTickets({ + query: filters.search || undefined, + board_id: filters.board_id ?? undefined, + status_id: filters.status_id ?? undefined, + include_closed: filters.include_closed, + assigned_to_me: filters.assigned === 'me', + unassigned: filters.assigned === 'unassigned', + priority: filters.priority ?? undefined, + company_id: filters.company_id ?? undefined, + page, + page_size: PAGE_SIZE, + }) + setTickets(result.items) + setTotal(result.total) + } catch { + setTickets([]) + setTotal(0) + } finally { + setLoading(false) + } + }, [filters.search, filters.board_id, filters.status_id, filters.include_closed, + filters.assigned, filters.priority, filters.company_id, page]) + + useEffect(() => { fetchTickets() }, [fetchTickets]) + + function updateFilters(updated: Partial) { + const next = new URLSearchParams(searchParams) + if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search') + if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board') + if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status') + if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority') + if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company') + if ('assigned' in updated) { + const a = updated.assigned + a === 'all' ? next.delete('assigned') : next.set('assigned', String(a)) + } + if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed') + next.delete('page') // reset to 1 on filter change + setSearchParams(next) + } + + function updatePage(p: number) { + const next = new URLSearchParams(searchParams) + p === 1 ? next.delete('page') : next.set('page', String(p)) + setSearchParams(next) + } + + return ( +
+ {/* Header */} +
+
+ +

Tickets

+
+ +
+ + {/* Filters */} +
+ +
+ + {/* List + Detail Panel */} +
+ {/* Ticket list */} +
+ {loading && tickets.length === 0 && ( +
+ Loading tickets… +
+ )} + {!loading && tickets.length === 0 && ( +
+ + No tickets match your filters +
+ )} + {tickets.map(t => ( + setSelectedTicket(t)} + /> + ))} +
+ + {/* Detail panel */} + {selectedTicket && ( +
+ setSelectedTicket(null)} + onStatusUpdated={(ticketId, newStatus) => { + setTickets(prev => prev.map(t => + t.id === String(ticketId) ? { ...t, status_name: newStatus } : t + )) + }} + /> +
+ )} +
+ + {/* New Ticket Modal */} + {showNewTicket && ( + setShowNewTicket(false)} + onCreated={() => { setShowNewTicket(false); fetchTickets() }} + /> + )} +
+ ) +}