diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index b77d511e..49958403 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, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem } from '@/types/integrations' +import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry } from '@/types/integrations' export const integrationsApi = { getConnection: () => @@ -24,4 +24,10 @@ export const integrationsApi = { export const sessionPsaApi = { linkTicket: (sessionId: string, psaTicketId: string | null) => apiClient.patch(`/sessions/${sessionId}/ticket-link`, { psa_ticket_id: psaTicketId }).then(r => r.data), + getPostPreview: (sessionId: string) => + apiClient.get(`/sessions/${sessionId}/psa-post/preview`).then(r => r.data), + postToTicket: (sessionId: string, data: { note_type: string; content: string; update_status_id?: number }) => + apiClient.post(`/sessions/${sessionId}/psa-post`, data).then(r => r.data), + getPostHistory: (sessionId: string) => + apiClient.get(`/sessions/${sessionId}/psa-posts`).then(r => r.data), } diff --git a/frontend/src/components/session/TicketLinkIndicator.tsx b/frontend/src/components/session/TicketLinkIndicator.tsx index 862a587e..6318830e 100644 --- a/frontend/src/components/session/TicketLinkIndicator.tsx +++ b/frontend/src/components/session/TicketLinkIndicator.tsx @@ -1,4 +1,4 @@ -import { Ticket, Unlink, Link2 } from 'lucide-react' +import { Ticket, Unlink, Link2, Send } from 'lucide-react' import { cn } from '@/lib/utils' import type { Session } from '@/types' import type { PSATicketInfo } from '@/types/integrations' @@ -8,10 +8,11 @@ interface Props { hasConnection: boolean onLinkClick: () => void onUnlink: () => void + onUpdateClick?: () => void ticketInfo?: PSATicketInfo | null } -export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, ticketInfo }: Props) { +export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnlink, onUpdateClick, ticketInfo }: Props) { // No connection — show nothing if (!hasConnection) return null @@ -61,14 +62,30 @@ export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnl )} - +
+ {onUpdateClick && ( + + )} + +
) } diff --git a/frontend/src/components/session/UpdateTicketModal.tsx b/frontend/src/components/session/UpdateTicketModal.tsx new file mode 100644 index 00000000..d198d2a2 --- /dev/null +++ b/frontend/src/components/session/UpdateTicketModal.tsx @@ -0,0 +1,313 @@ +import { useState, useEffect } from 'react' +import { Loader2, Copy, AlertTriangle, AlertCircle, RefreshCw } from 'lucide-react' +import { Modal } from '@/components/common/Modal' +import { Textarea } from '@/components/ui/Textarea' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +import { sessionPsaApi } from '@/api/integrations' +import type { PsaPreviewResponse } from '@/types/integrations' + +interface Props { + open: boolean + onClose: () => void + sessionId: string + onPosted?: () => void +} + +type NoteType = 'internal_analysis' | 'resolution' | 'description' + +const NOTE_TYPE_OPTIONS: { value: NoteType; label: string; description: string; warning?: string }[] = [ + { value: 'internal_analysis', label: 'Internal Analysis', description: 'Internal only, no notifications' }, + { value: 'resolution', label: 'Resolution', description: 'Internal only, triggers notifications' }, + { value: 'description', label: 'Description', description: 'Visible to the customer', warning: 'This note will be visible to the customer' }, +] + +const CHAR_WARNING_THRESHOLD = 15000 + +export function UpdateTicketModal({ open, onClose, sessionId, onPosted }: Props) { + const [preview, setPreview] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [loadError, setLoadError] = useState(null) + + const [content, setContent] = useState('') + const [noteType, setNoteType] = useState('internal_analysis') + const [selectedStatusId, setSelectedStatusId] = useState(null) + + const [isPosting, setIsPosting] = useState(false) + const [postError, setPostError] = useState(null) + + useEffect(() => { + if (open) { + loadPreview() + } else { + // Reset state when closed + setPreview(null) + setContent('') + setNoteType('internal_analysis') + setSelectedStatusId(null) + setPostError(null) + setLoadError(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, sessionId]) + + const loadPreview = async () => { + setIsLoading(true) + setLoadError(null) + try { + const data = await sessionPsaApi.getPostPreview(sessionId) + setPreview(data) + setContent(data.content) + // Find current status ID from available_statuses matching ticket status_name + const currentStatus = data.available_statuses.find( + (s) => s.name === data.ticket.status_name + ) + setSelectedStatusId(currentStatus?.id ?? null) + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setLoadError(axiosErr.response?.data?.detail || 'Failed to load preview') + console.error(err) + } finally { + setIsLoading(false) + } + } + + const handleCopyContent = async () => { + try { + await navigator.clipboard.writeText(content) + toast.success('Content copied to clipboard') + } catch { + toast.error('Failed to copy content') + } + } + + const handlePost = async () => { + setIsPosting(true) + setPostError(null) + try { + // Determine if status should be updated + const currentStatus = preview?.available_statuses.find( + (s) => s.name === preview?.ticket.status_name + ) + const statusChanged = selectedStatusId !== null && selectedStatusId !== currentStatus?.id + + await sessionPsaApi.postToTicket(sessionId, { + note_type: noteType, + content, + ...(statusChanged ? { update_status_id: selectedStatusId } : {}), + }) + + toast.success('Note posted to ticket successfully') + onPosted?.() + onClose() + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setPostError(axiosErr.response?.data?.detail || 'Failed to post to ticket') + console.error(err) + } finally { + setIsPosting(false) + } + } + + const currentStatusId = preview?.available_statuses.find( + (s) => s.name === preview?.ticket.status_name + )?.id + + const footer = ( +
+ {postError && ( +
+
+ + {postError} +
+ +
+ )} +
+ + +
+
+ ) + + return ( + + {isLoading && ( +
+ +
+ )} + + {loadError && ( +
+
+ + {loadError} +
+ +
+ )} + + {preview && !isLoading && ( +
+ {/* Left panel - Content editor */} +
+
+

+ Note Content +

+ +
+ +