spine-framework-cortex 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/components/CortexSidebar.tsx +130 -0
  2. package/functions/custom_anonymous-sessions.ts +356 -0
  3. package/functions/custom_case_analysis.ts +507 -0
  4. package/functions/custom_community-escalation.ts +234 -0
  5. package/functions/custom_cortex-chunks.ts +52 -0
  6. package/functions/custom_cortex-handler.ts +35 -0
  7. package/functions/custom_funnel-scoring.ts +256 -0
  8. package/functions/custom_funnel-signal.ts +678 -0
  9. package/functions/custom_funnel-timers.ts +449 -0
  10. package/functions/custom_kb-chunker-test.ts +364 -0
  11. package/functions/custom_kb-chunker.ts +576 -0
  12. package/functions/custom_kb-embeddings.ts +481 -0
  13. package/functions/custom_kb-ingestion.ts +448 -0
  14. package/functions/custom_support-triage.ts +649 -0
  15. package/functions/custom_tag_management.ts +314 -0
  16. package/index.tsx +103 -0
  17. package/manifest.json +82 -0
  18. package/package.json +29 -0
  19. package/pages/CortexDashboard.tsx +97 -0
  20. package/pages/community/CommunityPage.tsx +159 -0
  21. package/pages/courses/CoursesPage.tsx +231 -0
  22. package/pages/crm/AccountDetailPage.tsx +393 -0
  23. package/pages/crm/AccountsPage.tsx +164 -0
  24. package/pages/crm/ActivityPage.tsx +82 -0
  25. package/pages/crm/ContactDetailPage.tsx +184 -0
  26. package/pages/crm/ContactsPage.tsx +87 -0
  27. package/pages/crm/DealDetailPage.tsx +191 -0
  28. package/pages/crm/DealsPage.tsx +169 -0
  29. package/pages/crm/HealthPage.tsx +109 -0
  30. package/pages/intelligence/IntelligencePage.tsx +314 -0
  31. package/pages/kb/KBEditorPage.tsx +328 -0
  32. package/pages/kb/KBIngestionPage.tsx +409 -0
  33. package/pages/kb/KBPage.tsx +258 -0
  34. package/pages/support/RedactionReview.tsx +562 -0
  35. package/pages/support/SupportPage.tsx +395 -0
  36. package/pages/support/TicketDetailPage.tsx +919 -0
  37. package/seed/accounts.json +9 -0
  38. package/seed/link-types.json +44 -0
  39. package/seed/triggers.json +80 -0
  40. package/seed/types.json +352 -0
@@ -0,0 +1,919 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useParams, useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Button } from '@core/components/ui/button'
5
+ import { Input } from '@core/components/ui/input'
6
+ import { Badge } from '@core/components/ui/badge'
7
+ import { Skeleton } from '@core/components/ui/skeleton'
8
+ import { ScrollArea } from '@core/components/ui/scroll-area'
9
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs'
10
+ import { ArrowLeft, Send, Lock, Globe, Bot, CheckCircle, AlertCircle, FileText, Building2, CreditCard, Package, BookOpen, Eye, EyeOff, Brain, Clock, TrendingUp, Tag as TagIcon } from 'lucide-react'
11
+ import { useAuth } from '@core/contexts/AuthContext'
12
+
13
+ interface Ticket {
14
+ id: string;
15
+ title: string;
16
+ status?: string;
17
+ description?: string;
18
+ created_at: string;
19
+ account_id?: string;
20
+ data?: {
21
+ status?: string;
22
+ aim_confidence_threshold?: number;
23
+ aim_confidence_at_response?: number;
24
+ aim_escalation_reason?: string;
25
+ aim_problem_statement?: string;
26
+ aim_solution_path?: string;
27
+ aim_tools_used?: string[];
28
+ ca_reported_issue?: string;
29
+ ca_true_problem?: string;
30
+ ca_diagnostic_steps?: string[];
31
+ ca_solution_steps?: string[];
32
+ ca_final_solution?: string;
33
+ ca_customer_temperature?: string;
34
+ ca_time_to_resolution?: number;
35
+ ca_escalation_required?: boolean;
36
+ ca_back_and_forth_count?: number;
37
+ ca_sentiment_progression?: string[];
38
+ ca_automation_potential?: string;
39
+ ca_kb_candidate?: boolean;
40
+ ca_analysis_tags?: string[];
41
+ kb_approved_at?: string;
42
+ kb_approved_by?: string;
43
+ kb_human_edits?: string;
44
+ kb_proposed_kb_id?: string;
45
+ kb_redacted_draft?: string;
46
+ };
47
+ }
48
+ interface Message {
49
+ id: string;
50
+ content: string;
51
+ direction: 'inbound' | 'outbound';
52
+ visibility?: 'external' | 'internal';
53
+ sender_type?: 'human' | 'agent' | 'system';
54
+ created_at: string;
55
+ }
56
+ interface Thread { id: string; visibility?: string }
57
+ interface Account {
58
+ id: string;
59
+ name: string;
60
+ data?: {
61
+ tier?: string;
62
+ contract_value?: number;
63
+ billing_status?: string;
64
+ mrr?: number;
65
+ }
66
+ }
67
+
68
+ const STATUS_BADGE: Record<string, string> = {
69
+ open: 'bg-blue-100 text-blue-700',
70
+ to_customer: 'bg-cyan-100 text-cyan-700',
71
+ ai_responding: 'bg-purple-100 text-purple-700',
72
+ human_assigned: 'bg-amber-100 text-amber-700',
73
+ in_progress: 'bg-amber-100 text-amber-700',
74
+ resolved: 'bg-green-100 text-green-700',
75
+ closed: 'bg-muted text-muted-foreground',
76
+ }
77
+
78
+ // Merged Thread Panel - combines internal and external messages
79
+ function MergedThreadPanel({
80
+ ticketId,
81
+ externalThread,
82
+ internalThread
83
+ }: {
84
+ ticketId: string
85
+ externalThread: Thread | null
86
+ internalThread: Thread | null
87
+ }) {
88
+ const [messages, setMessages] = useState<Message[]>([])
89
+ const [loading, setLoading] = useState(true)
90
+ const [reply, setReply] = useState('')
91
+ const [replyType, setReplyType] = useState<'external' | 'internal'>('external')
92
+ const [sending, setSending] = useState(false)
93
+
94
+ // Load messages from both threads and merge them
95
+ useEffect(() => {
96
+ const loadMessages = async () => {
97
+ setLoading(true)
98
+ const allMessages: Message[] = []
99
+
100
+ if (externalThread?.id) {
101
+ try {
102
+ const ext = await apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${externalThread.id}&limit=100`).then(r => r.json())
103
+ const extMsgs = Array.isArray(ext?.data) ? ext.data : Array.isArray(ext) ? ext : []
104
+ allMessages.push(...extMsgs.map((m: Message) => ({ ...m, visibility: 'external' })))
105
+ } catch { /* ignore */ }
106
+ }
107
+
108
+ if (internalThread?.id) {
109
+ try {
110
+ const int = await apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${internalThread.id}&limit=100`).then(r => r.json())
111
+ const intMsgs = Array.isArray(int?.data) ? int.data : Array.isArray(int) ? int : []
112
+ allMessages.push(...intMsgs.map((m: Message) => ({ ...m, visibility: 'internal' })))
113
+ } catch { /* ignore */ }
114
+ }
115
+
116
+ // Sort by creation time
117
+ allMessages.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
118
+ setMessages(allMessages)
119
+ setLoading(false)
120
+ }
121
+
122
+ loadMessages()
123
+ }, [externalThread?.id, internalThread?.id])
124
+
125
+ const handleSend = async () => {
126
+ if (!reply.trim()) return
127
+ let targetThread = replyType === 'external' ? externalThread : internalThread
128
+
129
+ setSending(true)
130
+ try {
131
+ // Create internal thread if it doesn't exist
132
+ if (!targetThread && replyType === 'internal') {
133
+ const threadTypesRes = await apiFetch('/api/types?kind=thread&limit=1').then(r => r.json())
134
+ const threadTypes = Array.isArray(threadTypesRes?.data) ? threadTypesRes.data : Array.isArray(threadTypesRes) ? threadTypesRes : []
135
+ if (!threadTypes.length) {
136
+ console.error('No thread type found')
137
+ return
138
+ }
139
+
140
+ const threadRes = await apiFetch('/api/admin-data?action=create&entity=threads', {
141
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({
143
+ target_type: 'item',
144
+ target_id: ticketId,
145
+ visibility: 'internal',
146
+ type_id: threadTypes[0].id
147
+ }),
148
+ })
149
+ const newThread = await threadRes.json()
150
+ if (newThread?.id) {
151
+ targetThread = newThread
152
+ // Refresh threads list
153
+ const thr = await apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${ticketId}&limit=10`).then(r => r.json())
154
+ const threadList = thr?.data ?? thr
155
+ setThreads(Array.isArray(threadList) ? threadList : [])
156
+ }
157
+ }
158
+
159
+ if (!targetThread) {
160
+ console.error('No thread available for message')
161
+ return
162
+ }
163
+
164
+ // Resolve message type_id
165
+ const typesRes = await apiFetch('/api/types?kind=message&limit=1').then(r => r.json())
166
+ const types = Array.isArray(typesRes?.data) ? typesRes.data : Array.isArray(typesRes) ? typesRes : []
167
+ if (!types.length) {
168
+ console.error('No message type found')
169
+ return
170
+ }
171
+
172
+ // Get current message count for sequencing
173
+ const currentMsgs = await apiFetch(`/api/admin-data?action=list&entity=messages&thread_id=${targetThread.id}&limit=1000`).then(r => r.json())
174
+ const msgCount = Array.isArray(currentMsgs?.data) ? currentMsgs.data.length : Array.isArray(currentMsgs) ? currentMsgs.length : 0
175
+ const nextSeq = msgCount + 1
176
+
177
+ const res = await apiFetch('/api/admin-data?action=create&entity=messages', {
178
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({
180
+ thread_id: targetThread.id,
181
+ content: reply.trim(),
182
+ direction: 'outbound',
183
+ type_id: types[0].id,
184
+ sequence: nextSeq,
185
+ visibility: replyType
186
+ }),
187
+ })
188
+ const response = await res.json()
189
+ const msg = response.data || response
190
+ if (msg?.id) {
191
+ // Create a properly formatted message with all required fields
192
+ const newMessage = {
193
+ ...msg,
194
+ visibility: replyType,
195
+ created_at: msg.created_at || new Date().toISOString(),
196
+ content: reply.trim()
197
+ }
198
+
199
+ // Add to messages state and trigger refresh
200
+ setMessages(prev => {
201
+ const updated = [...prev, newMessage]
202
+ // Sort by creation time
203
+ return updated.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
204
+ })
205
+ setReply('')
206
+ }
207
+ } finally { setSending(false) }
208
+ }
209
+
210
+ return (
211
+ <div className="flex flex-col h-full">
212
+ <ScrollArea className="flex-1 p-4">
213
+ <div className="space-y-3">
214
+ {loading && <Skeleton className="h-10 w-2/3" />}
215
+ {!loading && messages.length === 0 && (
216
+ <p className="text-xs text-muted-foreground italic text-center py-6">No messages yet.</p>
217
+ )}
218
+ {messages.map(msg => {
219
+ const isOutbound = msg.direction === 'outbound'
220
+ const isInternal = msg.visibility === 'internal'
221
+ const isAI = msg.data?.message_type === 'agent' || msg.sender_type === 'agent'
222
+
223
+ return (
224
+ <div key={msg.id} className={`flex ${isOutbound ? 'justify-end' : 'justify-start'}`}>
225
+ <div
226
+ className={`max-w-md rounded-lg px-3 py-2 text-sm relative group ${
227
+ isAI
228
+ ? 'bg-purple-100 text-purple-900 border border-purple-200'
229
+ : isInternal
230
+ ? 'bg-amber-100 text-amber-900 border border-amber-200'
231
+ : isOutbound
232
+ ? 'bg-primary text-primary-foreground'
233
+ : 'bg-muted border border-border'
234
+ }`}
235
+ >
236
+ <div className="flex items-center gap-1.5 mb-1">
237
+ {isAI && <Bot className="h-3 w-3" />}
238
+ {isInternal && <Lock className="h-3 w-3" />}
239
+ {!isOutbound && !isAI && <Globe className="h-3 w-3" />}
240
+ <span className="text-xs opacity-70">
241
+ {isAI ? 'AI' : isInternal ? 'Internal' : isOutbound ? 'You' : 'Customer'}
242
+ </span>
243
+ </div>
244
+ {msg.content}
245
+ </div>
246
+ </div>
247
+ )
248
+ })}
249
+ </div>
250
+ </ScrollArea>
251
+
252
+ <div className="border-t p-3 flex flex-col gap-2 shrink-0">
253
+ <div className="flex items-center gap-2">
254
+ <Tabs value={replyType} onValueChange={v => setReplyType(v as 'external' | 'internal')}>
255
+ <TabsList className="h-7">
256
+ <TabsTrigger value="external" className="text-xs gap-1">
257
+ <Globe className="h-3 w-3" /> To Customer
258
+ </TabsTrigger>
259
+ <TabsTrigger value="internal" className="text-xs gap-1">
260
+ <Lock className="h-3 w-3" /> Internal Note
261
+ </TabsTrigger>
262
+ </TabsList>
263
+ </Tabs>
264
+ </div>
265
+ <div className="flex gap-2">
266
+ <Input
267
+ placeholder={replyType === 'external' ? 'Reply to customer…' : 'Internal note…'}
268
+ value={reply}
269
+ onChange={e => setReply(e.target.value)}
270
+ onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}
271
+ className="flex-1"
272
+ />
273
+ <Button size="icon" onClick={handleSend} disabled={sending || !reply.trim()}>
274
+ <Send className="h-4 w-4" />
275
+ </Button>
276
+ </div>
277
+ </div>
278
+ </div>
279
+ )
280
+ }
281
+
282
+ // Case Data Panel
283
+ function CaseDataPanel({ ticket }: { ticket: Ticket | null }) {
284
+ const [account, setAccount] = useState<Account | null>(null)
285
+ const [loading, setLoading] = useState(false)
286
+
287
+ useEffect(() => {
288
+ if (!ticket?.account_id) return
289
+ setLoading(true)
290
+ apiFetch(`/api/admin-data?action=get&entity=accounts&id=${ticket.account_id}`)
291
+ .then(r => r.json())
292
+ .then(data => setAccount(data?.data ?? data ?? null))
293
+ .catch(() => setAccount(null))
294
+ .finally(() => setLoading(false))
295
+ }, [ticket?.account_id])
296
+
297
+ if (!ticket) return null
298
+
299
+ const tier = account?.data?.tier || '—'
300
+ const mrr = account?.data?.mrr || account?.data?.contract_value || 0
301
+ const billingStatus = account?.data?.billing_status || '—'
302
+
303
+ return (
304
+ <div className="h-full flex flex-col">
305
+ <div className="px-4 py-2 border-b border-border bg-muted/30">
306
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Case Data</p>
307
+ </div>
308
+ <ScrollArea className="flex-1 p-4">
309
+ <div className="space-y-4">
310
+ {/* Account Info */}
311
+ <div className="space-y-2">
312
+ <div className="flex items-center gap-2 text-sm font-medium">
313
+ <Building2 className="h-4 w-4 text-muted-foreground" />
314
+ Account
315
+ </div>
316
+ <div className="pl-6 space-y-1 text-sm">
317
+ <p className="font-medium">{account?.name || 'Unknown'}</p>
318
+ <p className="text-xs text-muted-foreground">ID: {ticket.account_id?.slice(0, 8)}...</p>
319
+ </div>
320
+ </div>
321
+
322
+ <Separator />
323
+
324
+ {/* Contract/Tier */}
325
+ <div className="space-y-2">
326
+ <div className="flex items-center gap-2 text-sm font-medium">
327
+ <Package className="h-4 w-4 text-muted-foreground" />
328
+ Plan & Contract
329
+ </div>
330
+ <div className="pl-6 space-y-1 text-sm">
331
+ <p>Tier: <Badge variant="outline" className="text-xs">{tier}</Badge></p>
332
+ <p className="text-muted-foreground">MRR: ${mrr.toLocaleString()}</p>
333
+ </div>
334
+ </div>
335
+
336
+ <Separator />
337
+
338
+ {/* Billing */}
339
+ <div className="space-y-2">
340
+ <div className="flex items-center gap-2 text-sm font-medium">
341
+ <CreditCard className="h-4 w-4 text-muted-foreground" />
342
+ Billing Status
343
+ </div>
344
+ <div className="pl-6 space-y-1 text-sm">
345
+ <p className={billingStatus === 'active' ? 'text-green-600' : 'text-amber-600'}>
346
+ {billingStatus}
347
+ </p>
348
+ </div>
349
+ </div>
350
+
351
+ <Separator />
352
+
353
+ {/* Related Cases */}
354
+ <div className="space-y-2">
355
+ <div className="flex items-center gap-2 text-sm font-medium">
356
+ <FileText className="h-4 w-4 text-muted-foreground" />
357
+ Related Tickets
358
+ </div>
359
+ <div className="pl-6 text-xs text-muted-foreground">
360
+ <p>Last 90 days: 0 tickets</p>
361
+ <p className="mt-1 italic">Click to view account history</p>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </ScrollArea>
366
+ </div>
367
+ )
368
+ }
369
+
370
+ // AI Metadata Panel
371
+ function AIMetadataPanel({ ticket, onGenerateKB }: { ticket: Ticket | null; onGenerateKB?: () => void }) {
372
+ if (!ticket?.data?.aim_confidence_threshold && !ticket?.data?.aim_confidence_at_response && !ticket?.data?.aim_escalation_reason) {
373
+ return (
374
+ <div className="h-full flex flex-col">
375
+ <div className="px-4 py-2 border-b border-border bg-purple-50">
376
+ <p className="text-xs font-medium uppercase tracking-wide text-purple-700">AI Analysis</p>
377
+ </div>
378
+ <div className="flex-1 p-4 flex items-center justify-center text-sm text-muted-foreground">
379
+ No AI metadata available for this ticket.
380
+ </div>
381
+ </div>
382
+ )
383
+ }
384
+
385
+ const ai = ticket.data
386
+ const isResolved = ticket.status === 'resolved' || ticket.status === 'closed'
387
+
388
+ return (
389
+ <div className="h-full flex flex-col">
390
+ <div className="px-4 py-2 border-b border-border bg-purple-50">
391
+ <p className="text-xs font-medium uppercase tracking-wide text-purple-700">AI Analysis</p>
392
+ </div>
393
+ <ScrollArea className="flex-1 p-4">
394
+ <div className="space-y-4">
395
+ {/* Confidence Score */}
396
+ {ai.aim_confidence_at_response && (
397
+ <div className="space-y-1">
398
+ <div className="flex items-center justify-between text-sm">
399
+ <span className="text-muted-foreground">Confidence</span>
400
+ <span className={`font-mono font-medium ${ai.aim_confidence_at_response >= 0.75 ? 'text-green-600' : 'text-amber-600'}`}>
401
+ {Math.round(ai.aim_confidence_at_response * 100)}%
402
+ </span>
403
+ </div>
404
+ {ai.aim_confidence_threshold && (
405
+ <p className="text-xs text-muted-foreground">Threshold: {Math.round(ai.aim_confidence_threshold * 100)}%</p>
406
+ )}
407
+ </div>
408
+ )}
409
+
410
+ {ai.aim_escalation_reason && ai.aim_escalation_reason !== 'none' && (
411
+ <div className="flex items-start gap-2 p-2 bg-amber-50 rounded-lg">
412
+ <AlertCircle className="h-4 w-4 text-amber-600 mt-0.5" />
413
+ <div className="text-sm">
414
+ <p className="font-medium text-amber-700">Escalated</p>
415
+ <p className="text-amber-600 text-xs">{ai.aim_escalation_reason.replace('_', ' ')}</p>
416
+ </div>
417
+ </div>
418
+ )}
419
+
420
+ {/* Problem Statement */}
421
+ {ai.aim_problem_statement && (
422
+ <div className="space-y-1">
423
+ <p className="text-xs font-medium text-muted-foreground uppercase">Problem</p>
424
+ <p className="text-sm bg-muted p-2 rounded">{ai.aim_problem_statement}</p>
425
+ </div>
426
+ )}
427
+
428
+ {/* Solution Path */}
429
+ {ai.aim_solution_path && (
430
+ <div className="space-y-1">
431
+ <p className="text-xs font-medium text-muted-foreground uppercase">Solution</p>
432
+ <p className="text-sm bg-muted p-2 rounded">{ai.aim_solution_path}</p>
433
+ </div>
434
+ )}
435
+
436
+ {/* Tools Used */}
437
+ {ai.aim_tools_used && ai.aim_tools_used.length > 0 && (
438
+ <div className="space-y-1">
439
+ <p className="text-xs font-medium text-muted-foreground uppercase">Tools Used</p>
440
+ <div className="flex flex-wrap gap-1">
441
+ {ai.aim_tools_used.map((tool, i) => (
442
+ <Badge key={i} variant="outline" className="text-xs">{tool}</Badge>
443
+ ))}
444
+ </div>
445
+ </div>
446
+ )}
447
+
448
+ {/* Post-mortem Section */}
449
+ {isResolved && (
450
+ <>
451
+ <Separator />
452
+ <div className="space-y-3">
453
+ <p className="text-xs font-medium text-muted-foreground uppercase">Post-mortem</p>
454
+
455
+ {postmortem?.root_cause_category && (
456
+ <p className="text-sm">
457
+ <span className="text-muted-foreground">Root cause:</span>{' '}
458
+ {postmortem.root_cause_category}
459
+ </p>
460
+ )}
461
+
462
+ {postmortem?.resolution_time_minutes && (
463
+ <p className="text-sm">
464
+ <span className="text-muted-foreground">Resolution time:</span>{' '}
465
+ {Math.round(postmortem.resolution_time_minutes)} min
466
+ </p>
467
+ )}
468
+
469
+ {postmortem?.customer_satisfaction !== undefined && (
470
+ <div className="flex items-center gap-2">
471
+ <span className="text-sm text-muted-foreground">Satisfaction:</span>
472
+ <div className="flex">
473
+ {[1, 2, 3, 4, 5].map((star) => (
474
+ <span
475
+ key={star}
476
+ className={`text-lg ${star <= (postmortem.customer_satisfaction || 0) ? 'text-amber-400' : 'text-gray-300'}`}
477
+ >
478
+
479
+ </span>
480
+ ))}
481
+ </div>
482
+ </div>
483
+ )}
484
+
485
+ {/* KB Generation */}
486
+ <div className="pt-2">
487
+ {postmortem?.kb_generated ? (
488
+ <div className="flex items-center gap-2 text-green-600 text-sm">
489
+ <CheckCircle className="h-4 w-4" />
490
+ KB article created
491
+ </div>
492
+ ) : (
493
+ <Button
494
+ variant="outline"
495
+ size="sm"
496
+ className="w-full gap-1 text-xs"
497
+ onClick={onGenerateKB}
498
+ >
499
+ <BookOpen className="h-3 w-3" />
500
+ Generate KB Article
501
+ </Button>
502
+ )}
503
+ </div>
504
+ </div>
505
+ </>
506
+ )}
507
+ </div>
508
+ </ScrollArea>
509
+ </div>
510
+ )
511
+ }
512
+
513
+ // Case Analysis Panel
514
+ function CaseAnalysisPanel({ ticket, analysisLoading }: { ticket: Ticket | null; analysisLoading: boolean }) {
515
+ if (!ticket?.data?.ca_reported_issue && !ticket?.data?.ca_true_problem && !ticket?.data?.ca_final_solution) {
516
+ return (
517
+ <div className="h-full flex flex-col">
518
+ <div className="px-4 py-2 border-b border-border bg-green-50">
519
+ <p className="text-xs font-medium uppercase tracking-wide text-green-700">Case Analysis</p>
520
+ </div>
521
+ <div className="flex-1 p-4 flex items-center justify-center text-sm text-muted-foreground">
522
+ {analysisLoading ? (
523
+ <div className="text-center">
524
+ <div className="animate-spin h-8 w-8 mx-auto mb-2 border-2 border-primary border-t-transparent rounded-full"></div>
525
+ <p>Running case analysis...</p>
526
+ <p className="text-xs text-muted-foreground mt-1">This may take a few minutes.</p>
527
+ </div>
528
+ ) : ticket?.status === 'resolved' ? (
529
+ <div className="text-center">
530
+ <Brain className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
531
+ <p>Analysis will be available shortly after resolution.</p>
532
+ <p className="text-xs mt-1">This may take a few minutes.</p>
533
+ </div>
534
+ ) : (
535
+ <div className="text-center">
536
+ <Brain className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
537
+ <p>Case analysis available once ticket is resolved.</p>
538
+ </div>
539
+ )}
540
+ </div>
541
+ </div>
542
+ )
543
+ }
544
+
545
+ const analysis = ticket.data
546
+ const isResolved = ticket.status === 'resolved' || ticket.status === 'closed'
547
+
548
+ const getTemperatureColor = (temp?: string) => {
549
+ switch (temp) {
550
+ case 'positive': return 'text-green-600'
551
+ case 'negative': return 'text-red-600'
552
+ case 'frustrated': return 'text-red-700'
553
+ case 'neutral': return 'text-gray-600'
554
+ default: return 'text-gray-600'
555
+ }
556
+ }
557
+
558
+ const getAutomationColor = (potential?: string) => {
559
+ switch (potential) {
560
+ case 'high': return 'text-green-600 bg-green-50'
561
+ case 'medium': return 'text-amber-600 bg-amber-50'
562
+ case 'low': return 'text-red-600 bg-red-50'
563
+ default: return 'text-gray-600 bg-gray-50'
564
+ }
565
+ }
566
+
567
+ return (
568
+ <div className="h-full flex flex-col">
569
+ <div className="px-4 py-2 border-b border-border bg-green-50">
570
+ <p className="text-xs font-medium uppercase tracking-wide text-green-700">Case Analysis</p>
571
+ </div>
572
+ <ScrollArea className="flex-1 p-4">
573
+ <div className="space-y-4">
574
+ {/* Customer Temperature */}
575
+ {analysis.ca_customer_temperature && (
576
+ <div className="space-y-1">
577
+ <div className="flex items-center gap-2 text-sm">
578
+ <TrendingUp className="h-4 w-4 text-muted-foreground" />
579
+ <span className="text-muted-foreground">Customer Temperature</span>
580
+ </div>
581
+ <Badge variant="outline" className={`text-xs ${getTemperatureColor(analysis.ca_customer_temperature)}`}>
582
+ {analysis.ca_customer_temperature}
583
+ </Badge>
584
+ </div>
585
+ )}
586
+
587
+ <Separator />
588
+
589
+ {/* Time Metrics */}
590
+ <div className="space-y-2">
591
+ <div className="flex items-center gap-2 text-sm">
592
+ <Clock className="h-4 w-4 text-muted-foreground" />
593
+ <span className="text-muted-foreground">Time Analysis</span>
594
+ </div>
595
+ <div className="pl-6 space-y-1 text-sm">
596
+ {analysis.ca_time_to_resolution && (
597
+ <p>Resolution Time: <span className="font-medium">{analysis.ca_time_to_resolution} min</span></p>
598
+ )}
599
+ {analysis.ca_back_and_forth_count && (
600
+ <p>Message Exchanges: <span className="font-medium">{analysis.ca_back_and_forth_count}</span></p>
601
+ )}
602
+ {analysis.ca_escalation_required !== undefined && (
603
+ <p>Escalated: <span className={`font-medium ${analysis.ca_escalation_required ? 'text-amber-600' : 'text-green-600'}`}>
604
+ {analysis.ca_escalation_required ? 'Yes' : 'No'}
605
+ </span></p>
606
+ )}
607
+ </div>
608
+ </div>
609
+
610
+ <Separator />
611
+
612
+ {/* Reported vs True Problem */}
613
+ <div className="space-y-2">
614
+ <div className="text-sm font-medium">Problem Analysis</div>
615
+ <div className="pl-4 space-y-2 text-sm">
616
+ {analysis.ca_reported_issue && (
617
+ <div>
618
+ <p className="text-xs text-muted-foreground mb-1">Reported Issue:</p>
619
+ <p className="text-xs bg-blue-50 p-2 rounded">{analysis.ca_reported_issue}</p>
620
+ </div>
621
+ )}
622
+ {analysis.ca_true_problem && (
623
+ <div>
624
+ <p className="text-xs text-muted-foreground mb-1">True Problem:</p>
625
+ <p className="text-xs bg-purple-50 p-2 rounded">{analysis.ca_true_problem}</p>
626
+ </div>
627
+ )}
628
+ </div>
629
+ </div>
630
+
631
+ <Separator />
632
+
633
+ {/* Solution Steps */}
634
+ {analysis.ca_solution_steps && analysis.ca_solution_steps.length > 0 && (
635
+ <div className="space-y-2">
636
+ <div className="text-sm font-medium">Solution Steps</div>
637
+ <div className="pl-4 space-y-1">
638
+ {analysis.ca_solution_steps.map((step, index) => (
639
+ <div key={index} className="flex items-start gap-2 text-xs">
640
+ <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-green-100 text-green-700 text-xs font-medium mt-0.5">
641
+ {index + 1}
642
+ </span>
643
+ <span className="flex-1">{step}</span>
644
+ </div>
645
+ ))}
646
+ </div>
647
+ </div>
648
+ )}
649
+
650
+ {/* Diagnostic Steps */}
651
+ {analysis.ca_diagnostic_steps && analysis.ca_diagnostic_steps.length > 0 && (
652
+ <div className="space-y-2">
653
+ <div className="text-sm font-medium">Diagnostic Steps</div>
654
+ <div className="pl-4 space-y-1">
655
+ {analysis.ca_diagnostic_steps.map((step, index) => (
656
+ <div key={index} className="flex items-start gap-2 text-xs">
657
+ <span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-100 text-amber-700 text-xs font-medium mt-0.5">
658
+ {index + 1}
659
+ </span>
660
+ <span className="flex-1">{step}</span>
661
+ </div>
662
+ ))}
663
+ </div>
664
+ </div>
665
+ )}
666
+
667
+ <Separator />
668
+
669
+ {/* Automation Potential */}
670
+ {analysis.ca_automation_potential && (
671
+ <div className="space-y-1">
672
+ <div className="flex items-center justify-between text-sm">
673
+ <span className="text-muted-foreground">Automation Potential</span>
674
+ <Badge variant="outline" className={`text-xs ${getAutomationColor(analysis.ca_automation_potential)}`}>
675
+ {analysis.ca_automation_potential}
676
+ </Badge>
677
+ </div>
678
+ </div>
679
+ )}
680
+
681
+ {/* KB Candidate */}
682
+ {analysis.ca_kb_candidate !== undefined && (
683
+ <div className="space-y-1">
684
+ <div className="flex items-center justify-between text-sm">
685
+ <span className="text-muted-foreground">KB Candidate</span>
686
+ <Badge variant={analysis.ca_kb_candidate ? "default" : "secondary"} className="text-xs">
687
+ {analysis.ca_kb_candidate ? 'Yes' : 'No'}
688
+ </Badge>
689
+ </div>
690
+ </div>
691
+ )}
692
+
693
+ {/* Tags */}
694
+ {analysis.ca_analysis_tags && analysis.ca_analysis_tags.length > 0 && (
695
+ <div className="space-y-2">
696
+ <div className="flex items-center gap-2 text-sm">
697
+ <TagIcon className="h-4 w-4 text-muted-foreground" />
698
+ <span className="text-muted-foreground">Analysis Tags</span>
699
+ </div>
700
+ <div className="pl-6 flex flex-wrap gap-1">
701
+ {analysis.ca_analysis_tags.map((tagId, index) => (
702
+ <Badge key={index} variant="secondary" className="text-xs">
703
+ Tag {tagId.slice(0, 8)}...
704
+ </Badge>
705
+ ))}
706
+ </div>
707
+ </div>
708
+ )}
709
+
710
+ {/* Final Solution */}
711
+ {analysis.ca_final_solution && (
712
+ <div className="space-y-2">
713
+ <div className="text-sm font-medium">Final Solution</div>
714
+ <div className="pl-4">
715
+ <p className="text-xs bg-green-50 p-2 rounded">{analysis.ca_final_solution}</p>
716
+ </div>
717
+ </div>
718
+ )}
719
+ </div>
720
+ </ScrollArea>
721
+ </div>
722
+ )
723
+ }
724
+
725
+ function Separator() {
726
+ return <div className="h-px bg-border" />
727
+ }
728
+
729
+ export default function TicketDetailPage() {
730
+ const { id } = useParams<{ id: string }>()
731
+ const navigate = useNavigate()
732
+ const { user } = useAuth()
733
+ const [ticket, setTicket] = useState<Ticket | null>(null)
734
+ const [threads, setThreads] = useState<Thread[]>([])
735
+ const [loading, setLoading] = useState(true)
736
+ const [activeSidePanel, setActiveSidePanel] = useState<'case' | 'ai' | 'analysis'>('case')
737
+ const [isWatching, setIsWatching] = useState(false)
738
+ const [watcherId, setWatcherId] = useState<string | null>(null)
739
+ const [watchLoading, setWatchLoading] = useState(false)
740
+ const [analysisLoading, setAnalysisLoading] = useState(false)
741
+
742
+ useEffect(() => {
743
+ if (!id) return
744
+ Promise.all([
745
+ apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).then(r => r.json()),
746
+ apiFetch(`/api/admin-data?action=list&entity=threads&target_id=${id}&limit=10`).then(r => r.json()),
747
+ ]).then(([ir, thr]) => {
748
+ setTicket(ir?.data ?? ir ?? null)
749
+ const threadList = thr?.data ?? thr
750
+ setThreads(Array.isArray(threadList) ? threadList : [])
751
+ }).catch(() => {}).finally(() => setLoading(false))
752
+ }, [id])
753
+
754
+ // Check if current user is watching this ticket
755
+ useEffect(() => {
756
+ if (!id || !user?.id) return
757
+ apiFetch(`/api/admin-data?action=list&entity=watchers&target_type=item&target_id=${id}&person_id=${user.id}`)
758
+ .then(r => r.json())
759
+ .then(j => {
760
+ const watchers = Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : []
761
+ if (watchers.length > 0) {
762
+ setIsWatching(true)
763
+ setWatcherId(watchers[0].id)
764
+ }
765
+ })
766
+ .catch(() => {})
767
+ }, [id, user?.id])
768
+
769
+ const toggleWatch = async () => {
770
+ if (!id || !user?.id) return
771
+ setWatchLoading(true)
772
+ try {
773
+ if (isWatching && watcherId) {
774
+ await apiFetch(`/api/admin-data?entity=watchers&id=${watcherId}`, { method: 'DELETE' })
775
+ setIsWatching(false)
776
+ setWatcherId(null)
777
+ } else {
778
+ // Resolve watcher type_id via types API
779
+ const typeRes = await apiFetch('/api/types?kind=watcher&limit=1').then(r => r.json())
780
+ const types = Array.isArray(typeRes?.data) ? typeRes.data : Array.isArray(typeRes) ? typeRes : []
781
+ if (!types.length) { console.error('No watcher type found'); return }
782
+ const res = await apiFetch('/api/admin-data?action=create&entity=watchers', {
783
+ method: 'POST',
784
+ headers: { 'Content-Type': 'application/json' },
785
+ body: JSON.stringify({
786
+ entity: 'watchers',
787
+ type_id: types[0].id,
788
+ target_type: 'item',
789
+ target_id: id,
790
+ person_id: user.id,
791
+ watch_type: 'all',
792
+ }),
793
+ })
794
+ const created = await res.json()
795
+ if (created?.id || created?.data?.id) {
796
+ setIsWatching(true)
797
+ setWatcherId(created?.id || created?.data?.id)
798
+ }
799
+ }
800
+ } catch (err) {
801
+ console.error('Watch toggle failed:', err)
802
+ } finally {
803
+ setWatchLoading(false)
804
+ }
805
+ }
806
+
807
+ const handleStatusChange = async (status: string) => {
808
+ if (!id || !ticket) return
809
+
810
+ // Update status (system field only, no redundant data.status)
811
+ await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, {
812
+ method: 'PATCH',
813
+ headers: { 'Content-Type': 'application/json' },
814
+ body: JSON.stringify({ status }),
815
+ })
816
+
817
+ setTicket(prev => prev ? { ...prev, status } : prev)
818
+
819
+ // Trigger case analysis if resolved
820
+ if (status === 'resolved') {
821
+ setAnalysisLoading(true)
822
+ try {
823
+ await apiFetch('/.netlify/functions/custom_case_analysis?action=analyze_ticket', {
824
+ method: 'POST',
825
+ body: JSON.stringify({ ticket_id: id })
826
+ })
827
+
828
+ // Refresh ticket data to show analysis results
829
+ const updatedTicket = await apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).then(r => r.json())
830
+ setTicket(updatedTicket?.data ?? updatedTicket)
831
+ } catch (error) {
832
+ console.error('Case analysis failed:', error)
833
+ // Status is still resolved, analysis can be retried later
834
+ } finally {
835
+ setAnalysisLoading(false)
836
+ }
837
+ }
838
+ }
839
+
840
+ const handleGenerateKB = () => {
841
+ // Trigger KB generation flow - would navigate to redaction review
842
+ navigate(`/cortex/support/${id}/kb-review`)
843
+ }
844
+
845
+ const externalThread = threads.find(t => !t.visibility || t.visibility === 'external') ?? null
846
+ const internalThread = threads.find(t => t.visibility === 'internal') ?? null
847
+
848
+ if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-64" /><Skeleton className="h-4 w-48" /></div>
849
+ if (!ticket) return <div className="p-6 text-muted-foreground text-sm">Ticket not found.</div>
850
+
851
+ return (
852
+ <div className="flex flex-col h-full">
853
+ <div className="px-6 py-4 border-b border-border shrink-0 flex items-center justify-between">
854
+ <div className="flex items-center gap-3">
855
+ <Button variant="ghost" size="sm" className="gap-1 text-muted-foreground" onClick={() => navigate('/cortex/support')}>
856
+ <ArrowLeft className="h-3.5 w-3.5" /> Support
857
+ </Button>
858
+ <div>
859
+ <h1 className="text-base font-semibold">{ticket.title}</h1>
860
+ <p className="text-xs text-muted-foreground font-mono">{ticket.id.slice(0, 8)}…</p>
861
+ </div>
862
+ </div>
863
+ <div className="flex items-center gap-2">
864
+ <Button
865
+ variant={isWatching ? 'default' : 'outline'}
866
+ size="sm"
867
+ className="gap-1 text-xs"
868
+ onClick={toggleWatch}
869
+ disabled={watchLoading}
870
+ >
871
+ {isWatching ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
872
+ {isWatching ? 'Watching' : 'Watch'}
873
+ </Button>
874
+ <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${STATUS_BADGE[ticket.status] || 'bg-muted text-muted-foreground'}`}>
875
+ {ticket.status?.replace('_', ' ')}
876
+ </span>
877
+ <select
878
+ value={ticket.status}
879
+ onChange={e => handleStatusChange(e.target.value)}
880
+ className="text-xs border border-border rounded px-2 py-1 bg-background"
881
+ >
882
+ {['open', 'to_customer', 'ai_responding', 'human_assigned', 'in_progress', 'resolved', 'closed'].map(s => <option key={s} value={s}>{s.replace('_', ' ')}</option>)}
883
+ </select>
884
+ </div>
885
+ </div>
886
+
887
+ <div className="flex flex-1 min-h-0">
888
+ {/* Main Conversation Panel */}
889
+ <div className="flex-1 min-w-0 border-r border-border">
890
+ <MergedThreadPanel
891
+ ticketId={id || ''}
892
+ externalThread={externalThread}
893
+ internalThread={internalThread}
894
+ />
895
+ </div>
896
+
897
+ {/* Right Side Panel Tabs */}
898
+ <div className="w-80 shrink-0 flex flex-col">
899
+ <Tabs value={activeSidePanel} onValueChange={v => setActiveSidePanel(v as 'case' | 'ai' | 'analysis')}>
900
+ <TabsList className="w-full rounded-none border-b">
901
+ <TabsTrigger value="case" className="flex-1 gap-1 text-xs">
902
+ <Building2 className="h-3 w-3" /> Case
903
+ </TabsTrigger>
904
+ <TabsTrigger value="ai" className="flex-1 gap-1 text-xs">
905
+ <Bot className="h-3 w-3" /> AI
906
+ </TabsTrigger>
907
+ <TabsTrigger value="analysis" className="flex-1 gap-1 text-xs">
908
+ <Brain className="h-3 w-3" /> Analysis
909
+ </TabsTrigger>
910
+ </TabsList>
911
+ </Tabs>
912
+ {activeSidePanel === 'case' && <CaseDataPanel ticket={ticket} />}
913
+ {activeSidePanel === 'ai' && <AIMetadataPanel ticket={ticket} onGenerateKB={handleGenerateKB} />}
914
+ {activeSidePanel === 'analysis' && <CaseAnalysisPanel ticket={ticket} analysisLoading={analysisLoading} />}
915
+ </div>
916
+ </div>
917
+ </div>
918
+ )
919
+ }