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,395 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { apiFetch } from '@core/lib/api'
|
|
4
|
+
import { useAuth } from '@core/contexts/AuthContext'
|
|
5
|
+
import { Input } from '@core/components/ui/input'
|
|
6
|
+
import { Badge } from '@core/components/ui/badge'
|
|
7
|
+
import { Button } from '@core/components/ui/button'
|
|
8
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
9
|
+
import { Tabs, TabsList, TabsTrigger } from '@core/components/ui/tabs'
|
|
10
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
11
|
+
import { Headphones, Clock, ChevronRight, List, LayoutGrid, Filter, AlertCircle, User, Timer } from 'lucide-react'
|
|
12
|
+
|
|
13
|
+
interface Ticket {
|
|
14
|
+
id: string
|
|
15
|
+
title: string
|
|
16
|
+
status?: string
|
|
17
|
+
priority?: string
|
|
18
|
+
description?: string
|
|
19
|
+
created_at: string
|
|
20
|
+
account_id?: string
|
|
21
|
+
data?: {
|
|
22
|
+
aim_confidence_threshold?: number
|
|
23
|
+
aim_confidence_at_response?: number
|
|
24
|
+
aim_escalation_reason?: string
|
|
25
|
+
aim_human_assignee_id?: string
|
|
26
|
+
status?: string
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 3-Factor Priority Score Calculation
|
|
31
|
+
interface PriorityScore {
|
|
32
|
+
total: number
|
|
33
|
+
urgency: number
|
|
34
|
+
risk: number
|
|
35
|
+
staleness: number
|
|
36
|
+
explanation: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function calculatePriorityScore(ticket: Ticket): PriorityScore {
|
|
40
|
+
// Urgency: Plan tier (core=1, custom=2, enterprise=3) - use priority as proxy
|
|
41
|
+
const tierWeights: Record<string, number> = { low: 1, medium: 2, high: 3, urgent: 3 }
|
|
42
|
+
const urgency = tierWeights[ticket.priority || 'low'] || 1
|
|
43
|
+
|
|
44
|
+
// Risk: Escalation reason
|
|
45
|
+
const escalationReason = ticket.data?.aim_escalation_reason
|
|
46
|
+
let risk = 2 // default medium
|
|
47
|
+
if (escalationReason === 'thumbs_down') risk = 3
|
|
48
|
+
else if (escalationReason === 'low_confidence') risk = 2
|
|
49
|
+
else if (escalationReason === 'customer_request') risk = 1
|
|
50
|
+
|
|
51
|
+
// Staleness: Hours since creation (capped at 72h)
|
|
52
|
+
const hoursSince = Math.min(
|
|
53
|
+
Math.floor((Date.now() - new Date(ticket.created_at).getTime()) / 3600000),
|
|
54
|
+
72
|
|
55
|
+
)
|
|
56
|
+
const staleness = Math.ceil(hoursSince / 24) // 0-3 scale
|
|
57
|
+
|
|
58
|
+
// Weighted composite
|
|
59
|
+
const total = Math.round((urgency * 0.4 + risk * 0.35 + staleness * 0.25) / 3 * 100)
|
|
60
|
+
|
|
61
|
+
const explanationParts: string[] = []
|
|
62
|
+
if (urgency >= 3) explanationParts.push('High urgency')
|
|
63
|
+
if (risk >= 3) explanationParts.push('Negative feedback')
|
|
64
|
+
if (staleness >= 2) explanationParts.push(`${staleness}d stale`)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
total,
|
|
68
|
+
urgency,
|
|
69
|
+
risk,
|
|
70
|
+
staleness,
|
|
71
|
+
explanation: explanationParts.join(', ') || 'Standard priority'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const STATUS_BADGE: Record<string, string> = {
|
|
76
|
+
open: 'bg-blue-100 text-blue-700',
|
|
77
|
+
ai_responding: 'bg-purple-100 text-purple-700',
|
|
78
|
+
human_assigned: 'bg-amber-100 text-amber-700',
|
|
79
|
+
in_progress: 'bg-amber-100 text-amber-700',
|
|
80
|
+
resolved: 'bg-green-100 text-green-700',
|
|
81
|
+
closed: 'bg-muted text-muted-foreground',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ageLabel(created_at: string) {
|
|
85
|
+
const diff = Date.now() - new Date(created_at).getTime()
|
|
86
|
+
const hours = Math.floor(diff / 3600000)
|
|
87
|
+
if (hours < 1) return 'just now'
|
|
88
|
+
if (hours < 24) return `${hours}h ago`
|
|
89
|
+
return `${Math.floor(hours / 24)}d ago`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type FilterType = 'all' | 'mine' | 'unassigned' | 'high_priority'
|
|
93
|
+
type ViewType = 'list' | 'kanban'
|
|
94
|
+
|
|
95
|
+
// Kanban columns based on AI-first workflow
|
|
96
|
+
const KANBAN_COLUMNS = [
|
|
97
|
+
{ id: 'open', label: 'New', color: 'border-blue-200' },
|
|
98
|
+
{ id: 'to_customer', label: 'To Customer', color: 'border-cyan-200' },
|
|
99
|
+
{ id: 'ai_responding', label: 'AI Responding', color: 'border-purple-200' },
|
|
100
|
+
{ id: 'human_assigned', label: 'Human Assigned', color: 'border-amber-200' },
|
|
101
|
+
{ id: 'in_progress', label: 'In Progress', color: 'border-orange-200' },
|
|
102
|
+
{ id: 'resolved', label: 'Resolved', color: 'border-green-200' },
|
|
103
|
+
{ id: 'closed', label: 'Closed', color: 'border-gray-200' },
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
export default function SupportPage() {
|
|
107
|
+
const navigate = useNavigate()
|
|
108
|
+
const { user } = useAuth()
|
|
109
|
+
const [tickets, setTickets] = useState<Ticket[]>([])
|
|
110
|
+
const [loading, setLoading] = useState(true)
|
|
111
|
+
const [search, setSearch] = useState('')
|
|
112
|
+
const [filter, setFilter] = useState<FilterType>('all')
|
|
113
|
+
const [view, setView] = useState<ViewType>('list')
|
|
114
|
+
const [myWatchedIds, setMyWatchedIds] = useState<Set<string>>(new Set())
|
|
115
|
+
const [showAllStatuses, setShowAllStatuses] = useState(false)
|
|
116
|
+
const [showClosed, setShowClosed] = useState(false)
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=support_ticket&limit=500')
|
|
120
|
+
.then(r => r.json())
|
|
121
|
+
.then(j => setTickets(Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : []))
|
|
122
|
+
.catch(() => setTickets([]))
|
|
123
|
+
.finally(() => setLoading(false))
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
// Fetch watched ticket IDs for current user
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!user?.id) return
|
|
129
|
+
apiFetch(`/api/admin-data?action=list&entity=watchers&target_type=item&person_id=${user.id}&limit=500`)
|
|
130
|
+
.then(r => r.json())
|
|
131
|
+
.then(j => {
|
|
132
|
+
const watchers = Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : []
|
|
133
|
+
setMyWatchedIds(new Set(watchers.map((w: any) => w.target_id)))
|
|
134
|
+
})
|
|
135
|
+
.catch(() => setMyWatchedIds(new Set()))
|
|
136
|
+
}, [user?.id])
|
|
137
|
+
|
|
138
|
+
// Calculate priority scores for all tickets
|
|
139
|
+
const ticketsWithScores = useMemo(() => {
|
|
140
|
+
return tickets.map(t => ({
|
|
141
|
+
...t,
|
|
142
|
+
score: calculatePriorityScore(t),
|
|
143
|
+
effectiveStatus: t.data?.status || t.status
|
|
144
|
+
}))
|
|
145
|
+
}, [tickets])
|
|
146
|
+
|
|
147
|
+
const filtered = ticketsWithScores.filter(t => {
|
|
148
|
+
const matchesSearch = !search || t.title.toLowerCase().includes(search.toLowerCase())
|
|
149
|
+
const matchesFilter =
|
|
150
|
+
filter === 'all' ||
|
|
151
|
+
(filter === 'high_priority' && t.score.total >= 70) ||
|
|
152
|
+
(filter === 'unassigned' && !t.data?.aim_human_assignee_id) ||
|
|
153
|
+
(filter === 'mine' && myWatchedIds.has(t.id))
|
|
154
|
+
return matchesSearch && matchesFilter
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// Sort by priority score descending, then by age
|
|
158
|
+
const sorted = [...filtered].sort((a, b) => {
|
|
159
|
+
if (b.score.total !== a.score.total) return b.score.total - a.score.total
|
|
160
|
+
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const counts = {
|
|
164
|
+
all: tickets.length,
|
|
165
|
+
new: tickets.filter(t => (t.data?.status || t.status) === 'open').length,
|
|
166
|
+
ai_responding: tickets.filter(t => (t.data?.status || t.status) === 'ai_responding').length,
|
|
167
|
+
human_assigned: tickets.filter(t => (t.data?.status || t.status) === 'human_assigned').length,
|
|
168
|
+
resolved: tickets.filter(t => (t.data?.status || t.status) === 'resolved').length,
|
|
169
|
+
high_priority: ticketsWithScores.filter(t => t.score.total >= 70).length,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Kanban view: group by status with toggle filters
|
|
173
|
+
const kanbanGroups = useMemo(() => {
|
|
174
|
+
const groups: Record<string, typeof sorted> = {}
|
|
175
|
+
KANBAN_COLUMNS.forEach(col => {
|
|
176
|
+
let columnTickets = sorted.filter(t => t.effectiveStatus === col.id)
|
|
177
|
+
|
|
178
|
+
// Apply showClosed toggle (hide closed if toggle is off)
|
|
179
|
+
if (!showClosed && col.id === 'closed') {
|
|
180
|
+
columnTickets = []
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
groups[col.id] = columnTickets
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// Apply showAllStatuses toggle (hide empty columns if toggle is off)
|
|
187
|
+
if (!showAllStatuses) {
|
|
188
|
+
Object.keys(groups).forEach(key => {
|
|
189
|
+
if (groups[key].length === 0) {
|
|
190
|
+
delete groups[key]
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return groups
|
|
196
|
+
}, [sorted, showAllStatuses, showClosed])
|
|
197
|
+
|
|
198
|
+
const renderListView = () => (
|
|
199
|
+
<div className="border rounded-lg overflow-hidden">
|
|
200
|
+
{loading ? (
|
|
201
|
+
<div className="p-4 space-y-3">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}</div>
|
|
202
|
+
) : sorted.length === 0 ? (
|
|
203
|
+
<div className="p-12 text-center text-muted-foreground">
|
|
204
|
+
<Headphones className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
|
205
|
+
<p className="text-sm">{search ? 'No tickets match your search.' : 'No tickets found.'}</p>
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
<table className="w-full text-sm">
|
|
209
|
+
<thead>
|
|
210
|
+
<tr className="text-xs text-muted-foreground uppercase tracking-wider border-b bg-muted/30">
|
|
211
|
+
<th className="text-left px-5 py-3 font-medium">Score</th>
|
|
212
|
+
<th className="text-left px-5 py-3 font-medium">Subject</th>
|
|
213
|
+
<th className="text-left px-5 py-3 font-medium">Status</th>
|
|
214
|
+
<th className="text-left px-5 py-3 font-medium">Why</th>
|
|
215
|
+
<th className="text-right px-5 py-3 font-medium">Time Waiting</th>
|
|
216
|
+
<th className="px-3" />
|
|
217
|
+
</tr>
|
|
218
|
+
</thead>
|
|
219
|
+
<tbody className="divide-y">
|
|
220
|
+
{sorted.map(ticket => (
|
|
221
|
+
<tr
|
|
222
|
+
key={ticket.id}
|
|
223
|
+
onClick={() => navigate(`/cortex/support/${ticket.id}`)}
|
|
224
|
+
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
|
225
|
+
>
|
|
226
|
+
<td className="px-5 py-3">
|
|
227
|
+
<div className="flex items-center gap-2">
|
|
228
|
+
<Badge
|
|
229
|
+
variant={ticket.score.total >= 70 ? 'destructive' : ticket.score.total >= 50 ? 'default' : 'secondary'}
|
|
230
|
+
className="font-mono"
|
|
231
|
+
>
|
|
232
|
+
{ticket.score.total}
|
|
233
|
+
</Badge>
|
|
234
|
+
</div>
|
|
235
|
+
</td>
|
|
236
|
+
<td className="px-5 py-3">
|
|
237
|
+
<p className="font-medium truncate max-w-xs">{ticket.title}</p>
|
|
238
|
+
{ticket.description && <p className="text-xs text-muted-foreground truncate max-w-xs mt-0.5">{ticket.description}</p>}
|
|
239
|
+
</td>
|
|
240
|
+
<td className="px-5 py-3">
|
|
241
|
+
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${STATUS_BADGE[ticket.effectiveStatus] || 'bg-muted text-muted-foreground'}`}>
|
|
242
|
+
{ticket.effectiveStatus?.replace('_', ' ')}
|
|
243
|
+
</span>
|
|
244
|
+
</td>
|
|
245
|
+
<td className="px-5 py-3">
|
|
246
|
+
<span className="text-xs text-muted-foreground">{ticket.score.explanation}</span>
|
|
247
|
+
</td>
|
|
248
|
+
<td className="px-5 py-3 text-right">
|
|
249
|
+
<span className="flex items-center justify-end gap-1 text-xs text-muted-foreground">
|
|
250
|
+
<Clock className="h-3 w-3" /> {ageLabel(ticket.created_at)}
|
|
251
|
+
</span>
|
|
252
|
+
</td>
|
|
253
|
+
<td className="px-3 py-3"><ChevronRight className="h-4 w-4 text-muted-foreground/50" /></td>
|
|
254
|
+
</tr>
|
|
255
|
+
))}
|
|
256
|
+
</tbody>
|
|
257
|
+
</table>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const renderKanbanView = () => (
|
|
263
|
+
<div>
|
|
264
|
+
{/* Kanban Controls */}
|
|
265
|
+
<div className="flex items-center gap-4 mb-4 px-1">
|
|
266
|
+
<div className="flex items-center gap-2">
|
|
267
|
+
<input
|
|
268
|
+
type="checkbox"
|
|
269
|
+
id="show-all-statuses"
|
|
270
|
+
checked={showAllStatuses}
|
|
271
|
+
onChange={(e) => setShowAllStatuses(e.target.checked)}
|
|
272
|
+
className="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
|
|
273
|
+
/>
|
|
274
|
+
<label htmlFor="show-all-statuses" className="text-sm font-medium text-gray-700">
|
|
275
|
+
Show all statuses
|
|
276
|
+
</label>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="flex items-center gap-2">
|
|
279
|
+
<input
|
|
280
|
+
type="checkbox"
|
|
281
|
+
id="show-closed"
|
|
282
|
+
checked={showClosed}
|
|
283
|
+
onChange={(e) => setShowClosed(e.target.checked)}
|
|
284
|
+
className="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
|
|
285
|
+
/>
|
|
286
|
+
<label htmlFor="show-closed" className="text-sm font-medium text-gray-700">
|
|
287
|
+
Show closed cases
|
|
288
|
+
</label>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* Kanban Board */}
|
|
293
|
+
<div className="flex gap-4 overflow-x-auto pb-4">
|
|
294
|
+
{Object.entries(kanbanGroups).map(([columnId, tickets]) => {
|
|
295
|
+
const column = KANBAN_COLUMNS.find(col => col.id === columnId)
|
|
296
|
+
if (!column) return null
|
|
297
|
+
return (
|
|
298
|
+
<div key={columnId} className={`w-80 shrink-0 flex flex-col ${column.color} border-t-4 rounded-lg bg-muted/20`}>
|
|
299
|
+
<div className="px-3 py-2 border-b border-border/50 flex items-center justify-between">
|
|
300
|
+
<span className="text-sm font-medium">{column.label}</span>
|
|
301
|
+
<Badge variant="secondary" className="text-xs">{tickets?.length || 0}</Badge>
|
|
302
|
+
</div>
|
|
303
|
+
<ScrollArea className="flex-1 h-[calc(100vh-280px)]">
|
|
304
|
+
<div className="p-2 space-y-2">
|
|
305
|
+
{tickets?.map(ticket => (
|
|
306
|
+
<div
|
|
307
|
+
key={ticket.id}
|
|
308
|
+
onClick={() => navigate(`/cortex/support/${ticket.id}`)}
|
|
309
|
+
className="bg-background border rounded-lg p-3 cursor-pointer hover:shadow-sm transition-shadow"
|
|
310
|
+
>
|
|
311
|
+
<div className="flex items-start justify-between gap-2">
|
|
312
|
+
<p className="text-sm font-medium line-clamp-2 flex-1">{ticket.title}</p>
|
|
313
|
+
<Badge
|
|
314
|
+
variant={ticket.score.total >= 70 ? 'destructive' : 'secondary'}
|
|
315
|
+
className="text-xs font-mono shrink-0"
|
|
316
|
+
>
|
|
317
|
+
{ticket.score.total}
|
|
318
|
+
</Badge>
|
|
319
|
+
</div>
|
|
320
|
+
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
|
321
|
+
<span className="flex items-center gap-1">
|
|
322
|
+
<Timer className="h-3 w-3" />
|
|
323
|
+
{ageLabel(ticket.created_at)}
|
|
324
|
+
</span>
|
|
325
|
+
{ticket.data?.aim_human_assignee_id && (
|
|
326
|
+
<span className="flex items-center gap-1">
|
|
327
|
+
<User className="h-3 w-3" />
|
|
328
|
+
Assigned
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
))}
|
|
334
|
+
{(!tickets || tickets.length === 0) && (
|
|
335
|
+
<div className="text-center py-8 text-xs text-muted-foreground">
|
|
336
|
+
No tickets
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
</ScrollArea>
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
})}
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div className="p-6 space-y-5">
|
|
350
|
+
<div className="flex items-center justify-between">
|
|
351
|
+
<div>
|
|
352
|
+
<h1 className="text-2xl font-bold">Support Queue</h1>
|
|
353
|
+
<p className="text-muted-foreground text-sm mt-1">
|
|
354
|
+
{tickets.length} tickets · {counts.new} new · {counts.human_assigned} need human attention
|
|
355
|
+
</p>
|
|
356
|
+
</div>
|
|
357
|
+
<div className="flex items-center gap-2">
|
|
358
|
+
<Tabs value={view} onValueChange={v => setView(v as ViewType)}>
|
|
359
|
+
<TabsList>
|
|
360
|
+
<TabsTrigger value="list" className="gap-1">
|
|
361
|
+
<List className="h-4 w-4" /> List
|
|
362
|
+
</TabsTrigger>
|
|
363
|
+
<TabsTrigger value="kanban" className="gap-1">
|
|
364
|
+
<LayoutGrid className="h-4 w-4" /> Kanban
|
|
365
|
+
</TabsTrigger>
|
|
366
|
+
</TabsList>
|
|
367
|
+
</Tabs>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<div className="flex items-center gap-3">
|
|
372
|
+
<Input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search tickets…" className="w-72" />
|
|
373
|
+
<Tabs value={filter} onValueChange={v => setFilter(v as FilterType)}>
|
|
374
|
+
<TabsList>
|
|
375
|
+
<TabsTrigger value="all">All <Badge variant="secondary" className="ml-1.5">{counts.all}</Badge></TabsTrigger>
|
|
376
|
+
<TabsTrigger value="high_priority">
|
|
377
|
+
<AlertCircle className="h-3 w-3 mr-1" />
|
|
378
|
+
High Priority <Badge variant="secondary" className="ml-1.5">{counts.high_priority}</Badge>
|
|
379
|
+
</TabsTrigger>
|
|
380
|
+
<TabsTrigger value="unassigned">
|
|
381
|
+
<Filter className="h-3 w-3 mr-1" />
|
|
382
|
+
Unassigned
|
|
383
|
+
</TabsTrigger>
|
|
384
|
+
<TabsTrigger value="mine">
|
|
385
|
+
<User className="h-3 w-3 mr-1" />
|
|
386
|
+
My Tickets
|
|
387
|
+
</TabsTrigger>
|
|
388
|
+
</TabsList>
|
|
389
|
+
</Tabs>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{view === 'list' ? renderListView() : renderKanbanView()}
|
|
393
|
+
</div>
|
|
394
|
+
)
|
|
395
|
+
}
|