import { useCallback, useEffect, useRef, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useAppPath } from '@core/hooks/useAppPath' import { apiFetch } from '@core/lib/api' import { Button } from '@core/components/ui/button' import { Input } from '@core/components/ui/input' import { Badge } from '@core/components/ui/badge' import { Skeleton } from '@core/components/ui/skeleton' import { ScrollArea } from '@core/components/ui/scroll-area' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs' import { Textarea } from '@core/components/ui/textarea' import { ArrowLeft, Send, Lock, Globe, Bot, CheckCircle, AlertCircle, FileText, Building2, CreditCard, Package, BookOpen, Eye, EyeOff, Brain, Clock, TrendingUp, Tag as TagIcon, Sparkles, Bug } from 'lucide-react' import { useAuth } from '@core/contexts/AuthContext' interface Ticket { id: string; title: string; status?: string; description?: string; created_at: string; account_id?: string; data?: { status?: string; aim_confidence_threshold?: number; aim_confidence_at_response?: number; aim_escalation_reason?: string; aim_problem_statement?: string; aim_solution_path?: string; aim_tools_used?: string[]; ca_reported_issue?: string; ca_true_problem?: string; ca_diagnostic_steps?: string[]; ca_solution_steps?: string[]; ca_final_solution?: string; ca_customer_temperature?: string; ca_time_to_resolution?: number; ca_escalation_required?: boolean; ca_back_and_forth_count?: number; ca_sentiment_progression?: string[]; ca_automation_potential?: string; ca_kb_candidate?: boolean; ca_analysis_tags?: string[]; kb_approved_at?: string; kb_approved_by?: string; kb_human_edits?: string; kb_proposed_kb_id?: string; kb_redacted_draft?: string; }; } interface Message { id: string; content: string; direction: 'inbound' | 'outbound'; visibility?: 'external' | 'internal'; sender_type?: 'human' | 'agent' | 'system'; created_at: string; } interface Thread { id: string; visibility?: string } interface Account { id: string; name: string; data?: { tier?: string; contract_value?: number; billing_status?: string; mrr?: number; } } const STATUS_BADGE: Record = { open: 'bg-blue-100 text-blue-700', to_customer: 'bg-cyan-100 text-cyan-700', ai_responding: 'bg-purple-100 text-purple-700', human_assigned: 'bg-amber-100 text-amber-700', in_progress: 'bg-amber-100 text-amber-700', resolved: 'bg-green-100 text-green-700', closed: 'bg-muted text-muted-foreground', } // Merged Thread Panel - combines internal and external messages function MergedThreadPanel({ ticketId, externalThread, internalThread, refreshThreads, }: { ticketId: string externalThread: Thread | null internalThread: Thread | null refreshThreads: () => Promise }) { const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) const [reply, setReply] = useState('') const [replyType, setReplyType] = useState<'external' | 'internal'>('external') const [sending, setSending] = useState(false) // Load messages from both threads and merge them useEffect(() => { const loadMessages = async () => { setLoading(true) const allMessages: Message[] = [] if (externalThread?.id) { try { const ext = await apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${externalThread.id}&limit=100`).then(r => r.json()) const extMsgs = Array.isArray(ext?.data) ? ext.data : Array.isArray(ext) ? ext : [] allMessages.push(...extMsgs.map((m: Message) => ({ ...m, visibility: 'external' }))) } catch { /* ignore */ } } if (internalThread?.id) { try { const int = await apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${internalThread.id}&limit=100`).then(r => r.json()) const intMsgs = Array.isArray(int?.data) ? int.data : Array.isArray(int) ? int : [] allMessages.push(...intMsgs.map((m: Message) => ({ ...m, visibility: 'internal' }))) } catch { /* ignore */ } } // Sort by creation time allMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) setMessages(allMessages) setLoading(false) } loadMessages() }, [externalThread?.id, internalThread?.id]) const handleSend = async () => { if (!reply.trim()) return let targetThread = replyType === 'external' ? externalThread : internalThread setSending(true) try { // Create internal thread if it doesn't exist if (!targetThread && replyType === 'internal') { const threadRes = await apiFetch('/api/admin-data?action=create&entity=threads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target_type: 'item', target_id: ticketId, visibility: 'internal', type_slug: 'support_thread' }), }) const newThread = await threadRes.json() if (newThread?.id) { targetThread = newThread await refreshThreads() } } if (!targetThread) { console.error('No thread available for message') return } // Get current message count for sequencing const currentMsgs = await apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${targetThread.id}&limit=1000`).then(r => r.json()) const msgCount = Array.isArray(currentMsgs?.data) ? currentMsgs.data.length : Array.isArray(currentMsgs) ? currentMsgs.length : 0 const nextSeq = msgCount + 1 const res = await apiFetch('/api/admin-data?action=create&entity=messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: targetThread.id, content: reply.trim(), direction: 'outbound', type_slug: 'support_reply', sequence: nextSeq, visibility: replyType }), }) const response = await res.json() const msg = response.data || response if (msg?.id) { // Create a properly formatted message with all required fields const newMessage = { ...msg, visibility: replyType, created_at: msg.created_at || new Date().toISOString(), content: reply.trim() } // Add to messages state and trigger refresh setMessages(prev => { const updated = [...prev, newMessage] // Sort by creation time return updated.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) }) setReply('') } } finally { setSending(false) } } return (
{loading && } {!loading && messages.length === 0 && (

No messages yet.

)} {messages.map(msg => { const isOutbound = msg.direction === 'outbound' const isInternal = msg.visibility === 'internal' const isAI = msg.data?.message_type === 'agent' || msg.sender_type === 'agent' return (
{isAI && } {isInternal && } {!isOutbound && !isAI && } {isAI ? 'AI' : isInternal ? 'Internal' : isOutbound ? 'You' : 'Customer'}
{msg.content} {isAI && msg.data?.confidence !== undefined && (
Confidence: {Math.round(msg.data.confidence * 100)}% {msg.data?.retrieval_confidence !== undefined && ( · Retrieval: {Math.round(msg.data.retrieval_confidence * 100)}% )}
{Array.isArray(msg.data?.retrieved_sources) && msg.data.retrieved_sources.length > 0 && (
Sources ({msg.data.retrieved_sources.length})
    {msg.data.retrieved_sources.slice(0, 3).map((s: any, i: number) => (
  • {s.title || s.document_id} {s.similarity ? ` — ${Math.round(s.similarity * 100)}%` : ''}
  • ))}
)} {Array.isArray(msg.data?.retrieved_sources) && msg.data.retrieved_sources.length === 0 && (
No sources retrieved
)}
)}
) })}
setReplyType(v as 'external' | 'internal')}> To Customer Internal Note
setReply(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()} className="flex-1" />
) } // Case Data Panel function CaseDataPanel({ ticket }: { ticket: Ticket | null }) { const [account, setAccount] = useState(null) const [loading, setLoading] = useState(false) useEffect(() => { if (!ticket?.account_id) return setLoading(true) apiFetch(`/api/admin-data?action=get&entity=accounts&id=${ticket.account_id}`) .then(r => r.json()) .then(data => setAccount(data?.data ?? data ?? null)) .catch(() => setAccount(null)) .finally(() => setLoading(false)) }, [ticket?.account_id]) if (!ticket) return null const tier = account?.data?.tier || '—' const mrr = account?.data?.mrr || account?.data?.contract_value || 0 const billingStatus = account?.data?.billing_status || '—' return (

Case Data

{/* Account Info */}
Account

{account?.name || 'Unknown'}

ID: {ticket.account_id?.slice(0, 8)}...

{/* Contract/Tier */}
Plan & Contract

Tier: {tier}

MRR: ${mrr.toLocaleString()}

{/* Billing */}
Billing Status

{billingStatus}

{/* Related Cases */}
Related Tickets

Last 90 days: 0 tickets

Click to view account history

) } // Solution AI Panel — internal AI collaborator for support agents interface AIMessage { role: 'user' | 'ai'; content: string } function SolutionAIPanel({ ticket, onGenerateKB }: { ticket: Ticket | null; onGenerateKB?: () => void }) { const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [sending, setSending] = useState(false) const [threadId, setThreadId] = useState(null) const bottomRef = useRef(null) const ai = ticket?.data const isResolved = ticket?.status === 'resolved' || ticket?.status === 'closed' // Auto-scroll to bottom when new messages arrive useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) const sendMessage = async () => { if (!input.trim() || !ticket?.id || sending) return const userText = input.trim() setInput('') setMessages(prev => [...prev, { role: 'user', content: userText }]) setSending(true) try { const isFirst = !threadId const res = await apiFetch( `/.netlify/functions/custom_support-solution?action=${isFirst ? 'start' : 'message'}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(isFirst ? { ticket_id: ticket.id, message: userText } : { thread_id: threadId, message: userText } ), } ).then(r => r.json()) if (res.threadId && !threadId) setThreadId(res.threadId) if (res.content) setMessages(prev => [...prev, { role: 'ai', content: res.content }]) } catch (err) { setMessages(prev => [...prev, { role: 'ai', content: 'Error contacting Solution AI. Please try again.' }]) } finally { setSending(false) } } return (
{/* Header */}

Solution AI

Internal only

{/* Triage metadata strip */} {(ai?.aim_confidence_at_response || ai?.aim_escalation_reason) && (
{ai.aim_confidence_at_response && ( = 0.75 ? 'text-green-600' : 'text-amber-600'}> Triage confidence: {Math.round(ai.aim_confidence_at_response * 100)}% )} {ai.aim_escalation_reason && ai.aim_escalation_reason !== 'none' && ( Escalated )}
)} {/* Chat history */} {messages.length === 0 ? (

Ask Solution AI for help with this case.

Searches KB · recalls similar cases · drafts replies

) : (
{messages.map((msg, i) => (
{msg.content}
))} {sending && (
Thinking…
)}
)} {/* KB generation (resolved only) */} {isResolved && (
{ai?.kb_proposed_kb_id ? (
KB article created
) : ( )}
)} {/* Input */}