diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e957c82f..dbdc5c16 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -22,4 +22,4 @@ export { assistantChatApi } from './assistantChat' export { flowTransferApi } from './flowTransfer' export { kbAcceleratorApi } from './kbAccelerator' export { scriptsApi } from './scripts' -export { integrationsApi } from './integrations' +export { integrationsApi, sessionPsaApi } from './integrations' diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index f290979c..4e17029b 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -1,5 +1,6 @@ import { apiClient } from './client' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' +import type { TicketLinkResponse } from '@/types/integrations' export const integrationsApi = { getConnection: () => @@ -13,3 +14,8 @@ export const integrationsApi = { testConnection: (id: string) => apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data), } + +export const sessionPsaApi = { + linkTicket: (sessionId: string, psaTicketId: string | null) => + apiClient.patch(`/sessions/${sessionId}/ticket-link`, { psa_ticket_id: psaTicketId }).then(r => r.data), +} diff --git a/frontend/src/components/session/TicketLinkIndicator.tsx b/frontend/src/components/session/TicketLinkIndicator.tsx new file mode 100644 index 00000000..862a587e --- /dev/null +++ b/frontend/src/components/session/TicketLinkIndicator.tsx @@ -0,0 +1,74 @@ +import { Ticket, Unlink, Link2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { Session } from '@/types' +import type { PSATicketInfo } from '@/types/integrations' + +interface Props { + session: Session + hasConnection: boolean + onLinkClick: () => void + onUnlink: () => void + ticketInfo?: PSATicketInfo | null +} + +export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, ticketInfo }: Props) { + // No connection — show nothing + if (!hasConnection) return null + + // No ticket linked — show subtle "Link Ticket" button + if (!session.psa_ticket_id) { + return ( + + ) + } + + // Ticket linked + return ( +
+ +
+

+ CW #{session.psa_ticket_id} + {ticketInfo?.summary && ( + — {ticketInfo.summary} + )} +

+ {ticketInfo && ( +
+ {ticketInfo.company_name && {ticketInfo.company_name}} + {ticketInfo.board_name && ( + <> + + {ticketInfo.board_name} + + )} + {ticketInfo.status_name && ( + <> + + {ticketInfo.status_name} + + )} +
+ )} +
+ +
+ ) +} diff --git a/frontend/src/components/session/TicketPickerModal.tsx b/frontend/src/components/session/TicketPickerModal.tsx new file mode 100644 index 00000000..fc9f9c8e --- /dev/null +++ b/frontend/src/components/session/TicketPickerModal.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react' +import { Ticket, Search, AlertCircle, CheckCircle2 } 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' + +interface Props { + open: boolean + onClose: () => void + sessionId: string + onLinked: (ticketId: string, ticket: PSATicketInfo) => void +} + +export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) { + const [ticketId, setTicketId] = useState('') + 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 + + setIsLooking(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') + } + } 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.') + } finally { + setIsLooking(false) + } + } + + const handleLink = () => { + if (!ticketInfo) return + onLinked(ticketId.trim(), ticketInfo) + handleReset() + } + + const handleReset = () => { + setTicketId('') + setTicketInfo(null) + setError(null) + setIsLooking(false) + setIsLinking(false) + } + + const handleClose = () => { + handleReset() + onClose() + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && ticketId.trim() && !isLooking && !ticketInfo) { + handleLookup() + } + } + + return ( + +
+ {/* Ticket ID input */} +
+ +
+ { + setTicketId(e.target.value) + if (ticketInfo) { + setTicketInfo(null) + } + if (error) { + setError(null) + } + }} + onKeyDown={handleKeyDown} + disabled={isLooking || isLinking} + className="flex-1" + /> + +
+
+ + {/* Error */} + {error && ( +
+ +

{error}

+
+ )} + + {/* Ticket info card */} + {ticketInfo && ( +
+
+ +
+

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

+
+ {ticketInfo.company_name && ( + {ticketInfo.company_name} + )} + {ticketInfo.board_name && ( + <> + + {ticketInfo.board_name} + + )} + {ticketInfo.status_name && ( + <> + + {ticketInfo.status_name} + + )} + {ticketInfo.priority_name && ( + <> + + {ticketInfo.priority_name} + + )} +
+
+
+ + +
+ )} + + {/* Skip link */} +
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index 9184e177..90226fbd 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -24,6 +24,10 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal' import { PostStepActionModal } from '@/components/session/PostStepActionModal' import { CopilotPanel } from '@/components/copilot/CopilotPanel' import { CopilotToggle } from '@/components/copilot/CopilotToggle' +import { integrationsApi, sessionPsaApi } from '@/api/integrations' +import { TicketPickerModal } from '@/components/session/TicketPickerModal' +import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' +import type { PSATicketInfo } from '@/types/integrations' interface StepState { notes: string @@ -86,6 +90,11 @@ export function ProceduralNavigationPage() { const [isSavingStep, setIsSavingStep] = useState(false) const [copilotOpen, setCopilotOpen] = useState(false) + // PSA ticket link state + const [hasConnection, setHasConnection] = useState(false) + const [showTicketPicker, setShowTicketPicker] = useState(false) + const [psaTicketInfo, setPsaTicketInfo] = useState(null) + // Editable variables panel state const [editingVarName, setEditingVarName] = useState(null) const [editingVarValue, setEditingVarValue] = useState('') @@ -131,6 +140,32 @@ export function ProceduralNavigationPage() { } }, [treeId]) + // Check for PSA connection on mount + useEffect(() => { + integrationsApi.getConnection() + .then((conn) => setHasConnection(!!conn)) + .catch(() => setHasConnection(false)) + }, []) + + const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => { + setPsaTicketInfo(ticket) + setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev) + setShowTicketPicker(false) + toast.success(`Linked to CW #${linkedTicketId}`) + } + + const handleTicketUnlink = async () => { + if (!session) return + try { + await sessionPsaApi.linkTicket(session.id, null) + setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev) + setPsaTicketInfo(null) + toast.success('Ticket unlinked') + } catch { + toast.error('Failed to unlink ticket') + } + } + // Parse backend timestamp — ensure UTC if no timezone info const parseTimestamp = (ts: string) => { if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) { @@ -584,6 +619,17 @@ export function ProceduralNavigationPage() { Exit + {session && ( +
+ setShowTicketPicker(true)} + onUnlink={handleTicketUnlink} + ticketInfo={psaTicketInfo} + /> +
+ )}
setCopilotOpen(true)} /> )} + + {/* Ticket Picker Modal */} + {session && ( + setShowTicketPicker(false)} + sessionId={session.id} + onLinked={handleTicketLinked} + /> + )}
) } diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index ff389130..2511f0d2 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -22,6 +22,10 @@ import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sess import { CopilotPanel } from '@/components/copilot/CopilotPanel' import { CopilotToggle } from '@/components/copilot/CopilotToggle' import { Button } from '@/components/ui/Button' +import { integrationsApi, sessionPsaApi } from '@/api/integrations' +import { TicketPickerModal } from '@/components/session/TicketPickerModal' +import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator' +import type { PSATicketInfo } from '@/types/integrations' interface LocationState { sessionId?: string @@ -65,6 +69,11 @@ export function TreeNavigationPage() { const sharePopoverRef = useRef(null) const [copilotOpen, setCopilotOpen] = useState(false) + // PSA ticket link state + const [hasConnection, setHasConnection] = useState(false) + const [showTicketPicker, setShowTicketPicker] = useState(false) + const [ticketInfo, setTicketInfo] = useState(null) + const handleCopyCommand = (text: string) => { navigator.clipboard.writeText(text) setCopiedCommand(text) @@ -272,6 +281,32 @@ export function TreeNavigationPage() { } }, [treeId]) + // Check for PSA connection on mount + useEffect(() => { + integrationsApi.getConnection() + .then((conn) => setHasConnection(!!conn)) + .catch(() => setHasConnection(false)) + }, []) + + const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => { + setTicketInfo(ticket) + setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev) + setShowTicketPicker(false) + toast.success(`Linked to CW #${linkedTicketId}`) + } + + const handleTicketUnlink = async () => { + if (!session) return + try { + await sessionPsaApi.linkTicket(session.id, null) + setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev) + setTicketInfo(null) + toast.success('Ticket unlinked') + } catch { + toast.error('Failed to unlink ticket') + } + } + const loadTreeAndSession = async () => { setIsLoading(true) setError(null) @@ -656,6 +691,15 @@ export function TreeNavigationPage() { {clientName && `Client: ${clientName}`}

)} + {session && ( + setShowTicketPicker(true)} + onUnlink={handleTicketUnlink} + ticketInfo={ticketInfo} + /> + )}
{/* Share Progress Popover */} @@ -1251,6 +1295,16 @@ export function TreeNavigationPage() { onClose={() => setShowShareModal(false)} /> )} + + {/* Ticket Picker Modal */} + {session && ( + setShowTicketPicker(false)} + sessionId={session.id} + onLinked={handleTicketLinked} + /> + )}
diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index 95b7c03b..ef5542da 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -37,3 +37,18 @@ export interface PsaConnectionTestResponse { message: string server_version: string | null } + +export interface PSATicketInfo { + id: string + summary: string + company_name: string | null + board_name: string | null + status_name: string | null + priority_name: string | null +} + +export interface TicketLinkResponse { + session_id: string + psa_ticket_id: string | null + ticket: PSATicketInfo | null +} diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts index 8089d781..9f87b191 100644 --- a/frontend/src/types/session.ts +++ b/frontend/src/types/session.ts @@ -64,6 +64,8 @@ export interface Session { assigned_to_id?: string | null batch_id?: string target_label?: string + psa_ticket_id?: string | null + psa_connection_id?: string | null } export interface SessionCreate {