From dcf8bce2bf3e26e78b739fd63e8eb5b68ab349da Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:14:56 -0400 Subject: [PATCH] feat(psa): upgrade ticket picker with search and fix lookup/link separation Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/integrations.ts | 8 +- .../components/session/TicketPickerModal.tsx | 415 ++++++++++++++---- frontend/src/types/integrations.ts | 16 + 3 files changed, 356 insertions(+), 83 deletions(-) diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index 4e17029b..b77d511e 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -1,6 +1,6 @@ import { apiClient } from './client' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' -import type { TicketLinkResponse } from '@/types/integrations' +import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem } from '@/types/integrations' export const integrationsApi = { getConnection: () => @@ -13,6 +13,12 @@ export const integrationsApi = { apiClient.delete(`/integrations/psa/connections/${id}`), testConnection: (id: string) => apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data), + searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) => + apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), + getTicket: (id: string) => + apiClient.get(`/integrations/psa/tickets/${id}`).then(r => r.data), + getTicketStatuses: (ticketId: string) => + apiClient.get(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data), } export const sessionPsaApi = { diff --git a/frontend/src/components/session/TicketPickerModal.tsx b/frontend/src/components/session/TicketPickerModal.tsx index fc9f9c8e..5f01f0cf 100644 --- a/frontend/src/components/session/TicketPickerModal.tsx +++ b/frontend/src/components/session/TicketPickerModal.tsx @@ -1,11 +1,13 @@ -import { useState } from 'react' -import { Ticket, Search, AlertCircle, CheckCircle2 } from 'lucide-react' +import { useState, useEffect, useRef, useCallback } from 'react' +import { Ticket, Search, AlertCircle, CheckCircle2, Hash, Loader2 } from 'lucide-react' import { Modal } from '@/components/common/Modal' import { Input } from '@/components/ui/Input' import { Button } from '@/components/ui/Button' import { cn } from '@/lib/utils' -import { sessionPsaApi } from '@/api/integrations' -import type { PSATicketInfo } from '@/types/integrations' +import { integrationsApi, sessionPsaApi } from '@/api/integrations' +import type { PSATicketInfo, PSATicketSearchResult } from '@/types/integrations' + +type Mode = 'search' | 'manual' interface Props { open: boolean @@ -15,103 +17,341 @@ interface Props { } export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) { - const [ticketId, setTicketId] = useState('') + const [mode, setMode] = useState('search') + + // Search mode state + const [searchQuery, setSearchQuery] = useState('') + const [includeClosed, setIncludeClosed] = useState(false) + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + + // Manual mode state + const [manualId, setManualId] = useState('') + + // Shared state + const [selectedTicket, setSelectedTicket] = useState(null) + const [selectedTicketId, setSelectedTicketId] = useState(null) const [isLooking, setIsLooking] = useState(false) const [isLinking, setIsLinking] = useState(false) - const [ticketInfo, setTicketInfo] = useState(null) const [error, setError] = useState(null) - const handleLookup = async () => { - const trimmed = ticketId.trim() - if (!trimmed) return + const debounceRef = useRef | null>(null) - setIsLooking(true) + // Debounced search + const performSearch = useCallback(async (query: string, closed: boolean) => { + if (!query.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + setIsSearching(true) setError(null) - setTicketInfo(null) - try { - const result = await sessionPsaApi.linkTicket(sessionId, trimmed) - if (result.ticket) { - setTicketInfo(result.ticket) - } else { - setError('Ticket not found in ConnectWise') - } + const results = await integrationsApi.searchTickets({ + query: query.trim(), + include_closed: closed, + }) + setSearchResults(results) + setHasSearched(true) } catch (err: unknown) { const message = err && typeof err === 'object' && 'response' in err ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail : null - setError(message || 'Failed to look up ticket. Please check the ticket number and try again.') + setError(message || 'Failed to search tickets.') + setSearchResults([]) + setHasSearched(true) + } finally { + setIsSearching(false) + } + }, []) + + // Trigger debounced search when query or includeClosed changes + useEffect(() => { + if (mode !== 'search') return + + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + if (!searchQuery.trim()) { + setSearchResults([]) + setHasSearched(false) + return + } + + debounceRef.current = setTimeout(() => { + performSearch(searchQuery, includeClosed) + }, 300) + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + } + }, [searchQuery, includeClosed, mode, performSearch]) + + const handleSelectSearchResult = async (result: PSATicketSearchResult) => { + setIsLooking(true) + setError(null) + try { + const ticket = await integrationsApi.getTicket(result.id) + setSelectedTicket(ticket) + setSelectedTicketId(result.id) + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null + setError(message || 'Failed to load ticket details.') } finally { setIsLooking(false) } } - const handleLink = () => { - if (!ticketInfo) return - onLinked(ticketId.trim(), ticketInfo) - handleReset() + const handleManualLookup = async () => { + const trimmed = manualId.trim() + if (!trimmed) return + + setIsLooking(true) + setError(null) + setSelectedTicket(null) + setSelectedTicketId(null) + + try { + const ticket = await integrationsApi.getTicket(trimmed) + setSelectedTicket(ticket) + setSelectedTicketId(trimmed) + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null + setError(message || 'Ticket not found. Please check the ticket number and try again.') + } finally { + setIsLooking(false) + } + } + + const handleLink = async () => { + if (!selectedTicket || !selectedTicketId) return + + setIsLinking(true) + setError(null) + try { + await sessionPsaApi.linkTicket(sessionId, selectedTicketId) + onLinked(selectedTicketId, selectedTicket) + handleReset() + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null + setError(message || 'Failed to link ticket.') + } finally { + setIsLinking(false) + } } const handleReset = () => { - setTicketId('') - setTicketInfo(null) + setSearchQuery('') + setSearchResults([]) + setHasSearched(false) + setManualId('') + setSelectedTicket(null) + setSelectedTicketId(null) setError(null) setIsLooking(false) setIsLinking(false) + setIsSearching(false) + setIncludeClosed(false) } const handleClose = () => { handleReset() + setMode('search') onClose() } - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && ticketId.trim() && !isLooking && !ticketInfo) { - handleLookup() + const handleClearSelection = () => { + setSelectedTicket(null) + setSelectedTicketId(null) + setError(null) + } + + const handleManualKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && manualId.trim() && !isLooking && !selectedTicket) { + handleManualLookup() } } + const switchMode = (newMode: Mode) => { + setMode(newMode) + setSelectedTicket(null) + setSelectedTicketId(null) + setError(null) + } + return (
- {/* Ticket ID input */} -
- -
- { - setTicketId(e.target.value) - if (ticketInfo) { - setTicketInfo(null) - } - if (error) { - setError(null) - } - }} - onKeyDown={handleKeyDown} - disabled={isLooking || isLinking} - className="flex-1" - /> - -
+ {/* Mode tabs */} +
+ +
+ {/* Search mode */} + {mode === 'search' && !selectedTicket && ( +
+
+ setSearchQuery(e.target.value)} + disabled={isLooking} + className="w-full" + autoFocus + /> +
+ + {/* Include closed toggle */} + + + {/* Search results */} + {isSearching && ( +
+ +
+ )} + + {!isSearching && hasSearched && searchResults.length === 0 && ( +
+ No tickets found +
+ )} + + {!isSearching && searchResults.length > 0 && ( +
+ {searchResults.map((result) => ( + + ))} +
+ )} + + {isLooking && ( +
+ + Loading ticket details... +
+ )} +
+ )} + + {/* Manual mode */} + {mode === 'manual' && !selectedTicket && ( +
+ +
+ { + setManualId(e.target.value) + if (error) setError(null) + }} + onKeyDown={handleManualKeyDown} + disabled={isLooking} + className="flex-1" + autoFocus + /> + +
+
+ )} + {/* Error */} {error && (
@@ -120,49 +360,60 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
)} - {/* Ticket info card */} - {ticketInfo && ( + {/* Selected ticket confirmation card */} + {selectedTicket && selectedTicketId && (

- CW #{ticketId.trim()} — {ticketInfo.summary} + CW #{selectedTicketId} — {selectedTicket.summary}

- {ticketInfo.company_name && ( - {ticketInfo.company_name} + {selectedTicket.company_name && ( + {selectedTicket.company_name} )} - {ticketInfo.board_name && ( + {selectedTicket.board_name && ( <> - {ticketInfo.board_name} + {selectedTicket.board_name} )} - {ticketInfo.status_name && ( + {selectedTicket.status_name && ( <> - {ticketInfo.status_name} + {selectedTicket.status_name} )} - {ticketInfo.priority_name && ( + {selectedTicket.priority_name && ( <> - {ticketInfo.priority_name} + {selectedTicket.priority_name} )}
- +
+ + +
)} diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index ef5542da..5667a2c3 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -52,3 +52,19 @@ export interface TicketLinkResponse { psa_ticket_id: string | null ticket: PSATicketInfo | null } + +export interface PSATicketSearchResult { + id: string + summary: string + company_name: string | null + board_name: string | null + status_name: string | null + priority_name: string | null + closed: boolean +} + +export interface PSATicketStatusItem { + id: number + name: string + is_closed: boolean +}