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.
- package/components/CortexSidebar.tsx +130 -0
- package/functions/custom_anonymous-sessions.ts +356 -0
- package/functions/custom_case_analysis.ts +507 -0
- package/functions/custom_community-escalation.ts +234 -0
- package/functions/custom_cortex-chunks.ts +52 -0
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +678 -0
- package/functions/custom_funnel-timers.ts +449 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-chunker.ts +576 -0
- package/functions/custom_kb-embeddings.ts +481 -0
- package/functions/custom_kb-ingestion.ts +448 -0
- package/functions/custom_support-triage.ts +649 -0
- package/functions/custom_tag_management.ts +314 -0
- package/index.tsx +103 -0
- package/manifest.json +82 -0
- package/package.json +29 -0
- package/pages/CortexDashboard.tsx +97 -0
- package/pages/community/CommunityPage.tsx +159 -0
- package/pages/courses/CoursesPage.tsx +231 -0
- package/pages/crm/AccountDetailPage.tsx +393 -0
- package/pages/crm/AccountsPage.tsx +164 -0
- package/pages/crm/ActivityPage.tsx +82 -0
- package/pages/crm/ContactDetailPage.tsx +184 -0
- package/pages/crm/ContactsPage.tsx +87 -0
- package/pages/crm/DealDetailPage.tsx +191 -0
- package/pages/crm/DealsPage.tsx +169 -0
- package/pages/crm/HealthPage.tsx +109 -0
- package/pages/intelligence/IntelligencePage.tsx +314 -0
- package/pages/kb/KBEditorPage.tsx +328 -0
- package/pages/kb/KBIngestionPage.tsx +409 -0
- package/pages/kb/KBPage.tsx +258 -0
- package/pages/support/RedactionReview.tsx +562 -0
- package/pages/support/SupportPage.tsx +395 -0
- package/pages/support/TicketDetailPage.tsx +919 -0
- package/seed/accounts.json +9 -0
- package/seed/link-types.json +44 -0
- package/seed/triggers.json +80 -0
- 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
|
+
}
|