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,314 @@
|
|
|
1
|
+
import { createHandler } from './_shared/middleware'
|
|
2
|
+
import { adminDb } from './_shared/db'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Custom Tag Management Handler
|
|
6
|
+
*
|
|
7
|
+
* Actions:
|
|
8
|
+
* - POST ?action=create_or_get_tag - Get existing tag or create new one
|
|
9
|
+
* - POST ?action=list_tags - List tags with filtering
|
|
10
|
+
* - POST ?action=update_tag_usage - Increment tag usage count
|
|
11
|
+
* - POST ?action=merge_tags - Merge duplicate tags
|
|
12
|
+
*
|
|
13
|
+
* Provides centralized tag management for case analysis and other systems.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const TAG_TYPE_ID = 'tag' // Will be looked up by slug
|
|
19
|
+
|
|
20
|
+
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface TagRequest {
|
|
23
|
+
slug: string
|
|
24
|
+
name: string
|
|
25
|
+
purpose?: string
|
|
26
|
+
category: 'bug_classification' | 'knowledge_value' | 'process_type' | 'sentiment'
|
|
27
|
+
applicable_to?: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TagResponse {
|
|
31
|
+
id: string
|
|
32
|
+
slug: string
|
|
33
|
+
name: string
|
|
34
|
+
purpose?: string
|
|
35
|
+
category: string
|
|
36
|
+
applicable_to: string[]
|
|
37
|
+
usage_count: number
|
|
38
|
+
created_at: string
|
|
39
|
+
updated_at: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ListTagsRequest {
|
|
43
|
+
category?: string
|
|
44
|
+
applicable_to?: string
|
|
45
|
+
limit?: number
|
|
46
|
+
offset?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
async function getTagTypeId(): Promise<string> {
|
|
52
|
+
const { data: tagType } = await adminDb
|
|
53
|
+
.from('types')
|
|
54
|
+
.select('id')
|
|
55
|
+
.eq('slug', 'tag')
|
|
56
|
+
.single()
|
|
57
|
+
|
|
58
|
+
if (!tagType) throw new Error('Tag type not found')
|
|
59
|
+
return tagType.id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function findExistingTag(slug: string): Promise<TagResponse | null> {
|
|
63
|
+
const { data: tag } = await adminDb
|
|
64
|
+
.from('items')
|
|
65
|
+
.select('*')
|
|
66
|
+
.eq('type_id', await getTagTypeId())
|
|
67
|
+
.eq('data->>slug', slug)
|
|
68
|
+
.single()
|
|
69
|
+
|
|
70
|
+
if (!tag) return null
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
id: tag.id,
|
|
74
|
+
slug: tag.data?.slug || '',
|
|
75
|
+
name: tag.title,
|
|
76
|
+
purpose: tag.description,
|
|
77
|
+
category: tag.data?.category || '',
|
|
78
|
+
applicable_to: tag.data?.applicable_to || ['ticket'],
|
|
79
|
+
usage_count: tag.data?.usage_count || 0,
|
|
80
|
+
created_at: tag.created_at,
|
|
81
|
+
updated_at: tag.updated_at
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function createTag(
|
|
86
|
+
tagData: TagRequest,
|
|
87
|
+
accountId: string
|
|
88
|
+
): Promise<TagResponse> {
|
|
89
|
+
const tagTypeId = await getTagTypeId()
|
|
90
|
+
|
|
91
|
+
const { data: tag, error } = await adminDb
|
|
92
|
+
.from('items')
|
|
93
|
+
.insert({
|
|
94
|
+
type_id: tagTypeId,
|
|
95
|
+
account_id: accountId,
|
|
96
|
+
title: tagData.name,
|
|
97
|
+
description: tagData.purpose,
|
|
98
|
+
data: {
|
|
99
|
+
slug: tagData.slug,
|
|
100
|
+
name: tagData.name,
|
|
101
|
+
purpose: tagData.purpose,
|
|
102
|
+
applicable_to: tagData.applicable_to || ['ticket'],
|
|
103
|
+
category: tagData.category,
|
|
104
|
+
usage_count: 1
|
|
105
|
+
},
|
|
106
|
+
status: 'active'
|
|
107
|
+
})
|
|
108
|
+
.select('*')
|
|
109
|
+
.single()
|
|
110
|
+
|
|
111
|
+
if (error || !tag) throw new Error(`Failed to create tag: ${error?.message}`)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: tag.id,
|
|
115
|
+
slug: tag.data?.slug || '',
|
|
116
|
+
name: tag.title,
|
|
117
|
+
purpose: tag.description,
|
|
118
|
+
category: tag.data?.category || '',
|
|
119
|
+
applicable_to: tag.data?.applicable_to || ['ticket'],
|
|
120
|
+
usage_count: tag.data?.usage_count || 0,
|
|
121
|
+
created_at: tag.created_at,
|
|
122
|
+
updated_at: tag.updated_at
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function incrementTagUsage(tagId: string): Promise<void> {
|
|
127
|
+
await adminDb
|
|
128
|
+
.from('items')
|
|
129
|
+
.update({
|
|
130
|
+
data: adminDb.sql`jsonb_set(data, '{usage_count}', COALESCE((data->>'usage_count')::int, 0) + 1)`,
|
|
131
|
+
updated_at: new Date().toISOString()
|
|
132
|
+
})
|
|
133
|
+
.eq('id', tagId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── ACTIONS ───────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async function handleCreateOrGetTag(
|
|
139
|
+
ctx: any,
|
|
140
|
+
body: TagRequest
|
|
141
|
+
): Promise<TagResponse> {
|
|
142
|
+
const account_id = ctx.accountId as string
|
|
143
|
+
|
|
144
|
+
if (!account_id) throw new Error('Account context required')
|
|
145
|
+
if (!body.slug || !body.name || !body.category) {
|
|
146
|
+
throw new Error('slug, name, and category are required')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Validate slug format
|
|
150
|
+
if (!/^[a-z0-9_-]+$/.test(body.slug)) {
|
|
151
|
+
throw new Error('Slug must contain only lowercase letters, numbers, hyphens, and underscores')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Try to find existing tag
|
|
155
|
+
const existingTag = await findExistingTag(body.slug)
|
|
156
|
+
|
|
157
|
+
if (existingTag) {
|
|
158
|
+
// Increment usage count
|
|
159
|
+
await incrementTagUsage(existingTag.id)
|
|
160
|
+
|
|
161
|
+
// Return updated tag with incremented count
|
|
162
|
+
const updatedTag = await findExistingTag(body.slug)
|
|
163
|
+
if (!updatedTag) throw new Error('Failed to retrieve updated tag')
|
|
164
|
+
|
|
165
|
+
return updatedTag
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Create new tag
|
|
169
|
+
return await createTag(body, account_id)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function handleListTags(
|
|
173
|
+
ctx: any,
|
|
174
|
+
body: ListTagsRequest
|
|
175
|
+
): Promise<{ tags: TagResponse[]; total: number }> {
|
|
176
|
+
const { category, applicable_to, limit = 50, offset = 0 } = body
|
|
177
|
+
const tagTypeId = await getTagTypeId()
|
|
178
|
+
|
|
179
|
+
let query = adminDb
|
|
180
|
+
.from('items')
|
|
181
|
+
.select('*', { count: 'exact' })
|
|
182
|
+
.eq('type_id', tagTypeId)
|
|
183
|
+
.eq('status', 'active')
|
|
184
|
+
.order('data->>usage_count', { ascending: false })
|
|
185
|
+
.range(offset, offset + limit - 1)
|
|
186
|
+
|
|
187
|
+
// Add filters
|
|
188
|
+
if (category) {
|
|
189
|
+
query = query.eq('data->>category', category)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (applicable_to) {
|
|
193
|
+
query = query.contains('data->>applicable_to', [applicable_to])
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { data: tags, error, count } = await query
|
|
197
|
+
|
|
198
|
+
if (error) throw new Error(`Failed to list tags: ${error?.message}`)
|
|
199
|
+
|
|
200
|
+
const formattedTags: TagResponse[] = (tags || []).map(tag => ({
|
|
201
|
+
id: tag.id,
|
|
202
|
+
slug: tag.data?.slug || '',
|
|
203
|
+
name: tag.title,
|
|
204
|
+
purpose: tag.description,
|
|
205
|
+
category: tag.data?.category || '',
|
|
206
|
+
applicable_to: tag.data?.applicable_to || ['ticket'],
|
|
207
|
+
usage_count: tag.data?.usage_count || 0,
|
|
208
|
+
created_at: tag.created_at,
|
|
209
|
+
updated_at: tag.updated_at
|
|
210
|
+
}))
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
tags: formattedTags,
|
|
214
|
+
total: count || 0
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleUpdateTagUsage(
|
|
219
|
+
ctx: any,
|
|
220
|
+
body: { tag_id: string }
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
if (!body.tag_id) throw new Error('tag_id is required')
|
|
223
|
+
|
|
224
|
+
await incrementTagUsage(body.tag_id)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function handleMergeTags(
|
|
228
|
+
ctx: any,
|
|
229
|
+
body: { source_tag_id: string; target_tag_id: string }
|
|
230
|
+
): Promise<{ merged_count: number }> {
|
|
231
|
+
const { source_tag_id, target_tag_id } = body
|
|
232
|
+
|
|
233
|
+
if (!source_tag_id || !target_tag_id) {
|
|
234
|
+
throw new Error('source_tag_id and target_tag_id are required')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (source_tag_id === target_tag_id) {
|
|
238
|
+
throw new Error('Source and target tags cannot be the same')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Get source tag info
|
|
242
|
+
const { data: sourceTag } = await adminDb
|
|
243
|
+
.from('items')
|
|
244
|
+
.select('data')
|
|
245
|
+
.eq('id', source_tag_id)
|
|
246
|
+
.single()
|
|
247
|
+
|
|
248
|
+
if (!sourceTag) throw new Error('Source tag not found')
|
|
249
|
+
|
|
250
|
+
// Update all references to source tag to point to target tag
|
|
251
|
+
const sourceTagSlug = sourceTag.data?.slug
|
|
252
|
+
|
|
253
|
+
// Update ticket analysis_tags arrays
|
|
254
|
+
const { data: ticketsToUpdate } = await adminDb
|
|
255
|
+
.from('items')
|
|
256
|
+
.select('id, data')
|
|
257
|
+
.eq('status', 'active')
|
|
258
|
+
.contains('data->>case_analysis->>analysis_tags', [source_tag_id])
|
|
259
|
+
|
|
260
|
+
let mergedCount = 0
|
|
261
|
+
|
|
262
|
+
for (const ticket of ticketsToUpdate || []) {
|
|
263
|
+
const caseAnalysis = ticket.data?.case_analysis || {}
|
|
264
|
+
const analysisTags = caseAnalysis.analysis_tags || []
|
|
265
|
+
|
|
266
|
+
// Replace source tag with target tag
|
|
267
|
+
const updatedTags = analysisTags.map((tagId: string) =>
|
|
268
|
+
tagId === source_tag_id ? target_tag_id : tagId
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
await adminDb
|
|
272
|
+
.from('items')
|
|
273
|
+
.update({
|
|
274
|
+
data: adminDb.sql`jsonb_set(data, '{case_analysis,analysis_tags}', ${updatedTags}::jsonb)`,
|
|
275
|
+
updated_at: new Date().toISOString()
|
|
276
|
+
})
|
|
277
|
+
.eq('id', ticket.id)
|
|
278
|
+
|
|
279
|
+
mergedCount++
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Increment target tag usage by source tag's usage count
|
|
283
|
+
const sourceUsage = sourceTag.data?.usage_count || 0
|
|
284
|
+
for (let i = 0; i < sourceUsage; i++) {
|
|
285
|
+
await incrementTagUsage(target_tag_id)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Delete source tag
|
|
289
|
+
await adminDb
|
|
290
|
+
.from('items')
|
|
291
|
+
.update({ status: 'deleted', updated_at: new Date().toISOString() })
|
|
292
|
+
.eq('id', source_tag_id)
|
|
293
|
+
|
|
294
|
+
return { merged_count }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── HANDLER ──────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
export const handler = createHandler(async (ctx, body) => {
|
|
300
|
+
const action = ctx.query?.action
|
|
301
|
+
|
|
302
|
+
switch (action) {
|
|
303
|
+
case 'create_or_get_tag':
|
|
304
|
+
return await handleCreateOrGetTag(ctx, body)
|
|
305
|
+
case 'list_tags':
|
|
306
|
+
return await handleListTags(ctx, body)
|
|
307
|
+
case 'update_tag_usage':
|
|
308
|
+
return await handleUpdateTagUsage(ctx, body)
|
|
309
|
+
case 'merge_tags':
|
|
310
|
+
return await handleMergeTags(ctx, body)
|
|
311
|
+
default:
|
|
312
|
+
throw new Error(`Unknown action: ${action}. Use create_or_get_tag, list_tags, update_tag_usage, or merge_tags.`)
|
|
313
|
+
}
|
|
314
|
+
})
|
package/index.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { lazy, Suspense } from 'react'
|
|
2
|
+
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
|
3
|
+
import { LoadingSpinner } from '@core/components/ui/LoadingSpinner'
|
|
4
|
+
import { AppShell } from '@core/components/layout/AppShell'
|
|
5
|
+
import { CortexSidebar } from './components/CortexSidebar'
|
|
6
|
+
import { TooltipProvider } from '@core/components/ui/tooltip'
|
|
7
|
+
|
|
8
|
+
const CortexDashboard = lazy(() => import('./pages/CortexDashboard'))
|
|
9
|
+
|
|
10
|
+
// CRM
|
|
11
|
+
const AccountsPage = lazy(() => import('./pages/crm/AccountsPage'))
|
|
12
|
+
const AccountDetailPage = lazy(() => import('./pages/crm/AccountDetailPage'))
|
|
13
|
+
const ContactsPage = lazy(() => import('./pages/crm/ContactsPage'))
|
|
14
|
+
const ContactDetailPage = lazy(() => import('./pages/crm/ContactDetailPage'))
|
|
15
|
+
const DealsPage = lazy(() => import('./pages/crm/DealsPage'))
|
|
16
|
+
const DealDetailPage = lazy(() => import('./pages/crm/DealDetailPage'))
|
|
17
|
+
const HealthPage = lazy(() => import('./pages/crm/HealthPage'))
|
|
18
|
+
const ActivityPage = lazy(() => import('./pages/crm/ActivityPage'))
|
|
19
|
+
|
|
20
|
+
// Support
|
|
21
|
+
const SupportPage = lazy(() => import('./pages/support/SupportPage'))
|
|
22
|
+
const TicketDetailPage = lazy(() => import('./pages/support/TicketDetailPage'))
|
|
23
|
+
const RedactionReview = lazy(() => import('./pages/support/RedactionReview'))
|
|
24
|
+
|
|
25
|
+
// Community
|
|
26
|
+
const CommunityPage = lazy(() => import('./pages/community/CommunityPage'))
|
|
27
|
+
|
|
28
|
+
// KB
|
|
29
|
+
const KBPage = lazy(() => import('./pages/kb/KBPage'))
|
|
30
|
+
const KBEditorPage = lazy(() => import('./pages/kb/KBEditorPage'))
|
|
31
|
+
const KBIngestionPage = lazy(() => import('./pages/kb/KBIngestionPage'))
|
|
32
|
+
|
|
33
|
+
// Courses
|
|
34
|
+
const CoursesPage = lazy(() => import('./pages/courses/CoursesPage'))
|
|
35
|
+
|
|
36
|
+
// Intelligence
|
|
37
|
+
const IntelligencePage = lazy(() => import('./pages/intelligence/IntelligencePage'))
|
|
38
|
+
|
|
39
|
+
const Fallback = <div className="min-h-[400px] flex items-center justify-center"><LoadingSpinner /></div>
|
|
40
|
+
|
|
41
|
+
function CortexLayout() {
|
|
42
|
+
const location = useLocation()
|
|
43
|
+
const segments = location.pathname.split('/').filter(Boolean)
|
|
44
|
+
const breadcrumbs: { title: string; url?: string }[] = [{ title: 'Cortex', url: '/cortex/dashboard' }]
|
|
45
|
+
if (segments[1] && segments[1] !== 'dashboard') {
|
|
46
|
+
breadcrumbs.push({ title: segments[1].charAt(0).toUpperCase() + segments[1].slice(1) })
|
|
47
|
+
}
|
|
48
|
+
if (segments[2]) {
|
|
49
|
+
breadcrumbs.push({ title: segments[2].charAt(0).toUpperCase() + segments[2].slice(1) })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<AppShell sidebar={<CortexSidebar />} breadcrumbs={breadcrumbs}>
|
|
54
|
+
<Suspense fallback={Fallback}>
|
|
55
|
+
<Routes>
|
|
56
|
+
<Route index element={<Navigate to="dashboard" replace />} />
|
|
57
|
+
<Route path="dashboard" element={<CortexDashboard />} />
|
|
58
|
+
|
|
59
|
+
{/* CRM */}
|
|
60
|
+
<Route path="crm/accounts/:id" element={<AccountDetailPage />} />
|
|
61
|
+
<Route path="crm/accounts" element={<AccountsPage />} />
|
|
62
|
+
<Route path="crm/contacts/:id" element={<ContactDetailPage />} />
|
|
63
|
+
<Route path="crm/contacts" element={<ContactsPage />} />
|
|
64
|
+
<Route path="crm/deals/new" element={<DealDetailPage />} />
|
|
65
|
+
<Route path="crm/deals/:id" element={<DealDetailPage />} />
|
|
66
|
+
<Route path="crm/deals" element={<DealsPage />} />
|
|
67
|
+
<Route path="crm/health" element={<HealthPage />} />
|
|
68
|
+
<Route path="crm/activity" element={<ActivityPage />} />
|
|
69
|
+
|
|
70
|
+
{/* Support */}
|
|
71
|
+
<Route path="support/:id/kb-review" element={<RedactionReview />} />
|
|
72
|
+
<Route path="support/:id" element={<TicketDetailPage />} />
|
|
73
|
+
<Route path="support" element={<SupportPage />} />
|
|
74
|
+
|
|
75
|
+
{/* Community */}
|
|
76
|
+
<Route path="community" element={<CommunityPage />} />
|
|
77
|
+
|
|
78
|
+
{/* KB */}
|
|
79
|
+
<Route path="kb/new" element={<KBEditorPage />} />
|
|
80
|
+
<Route path="kb/:id/edit" element={<KBEditorPage />} />
|
|
81
|
+
<Route path="kb/ingestion" element={<KBIngestionPage />} />
|
|
82
|
+
<Route path="kb" element={<KBPage />} />
|
|
83
|
+
|
|
84
|
+
{/* Courses */}
|
|
85
|
+
<Route path="courses/*" element={<CoursesPage />} />
|
|
86
|
+
|
|
87
|
+
{/* Intelligence */}
|
|
88
|
+
<Route path="intelligence" element={<IntelligencePage />} />
|
|
89
|
+
|
|
90
|
+
<Route path="*" element={<Navigate to="dashboard" replace />} />
|
|
91
|
+
</Routes>
|
|
92
|
+
</Suspense>
|
|
93
|
+
</AppShell>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default function CortexApp() {
|
|
98
|
+
return (
|
|
99
|
+
<TooltipProvider>
|
|
100
|
+
<CortexLayout />
|
|
101
|
+
</TooltipProvider>
|
|
102
|
+
)
|
|
103
|
+
}
|
package/manifest.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Cortex",
|
|
3
|
+
"slug": "cortex",
|
|
4
|
+
"description": "Unified workspace for CRM, Support, Community, and Knowledge Base",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"required_roles": ["member"],
|
|
7
|
+
"routes": [
|
|
8
|
+
"/cortex",
|
|
9
|
+
"/cortex/dashboard",
|
|
10
|
+
"/cortex/crm",
|
|
11
|
+
"/cortex/crm/accounts",
|
|
12
|
+
"/cortex/crm/accounts/:id",
|
|
13
|
+
"/cortex/crm/contacts",
|
|
14
|
+
"/cortex/crm/deals",
|
|
15
|
+
"/cortex/crm/deals/:id",
|
|
16
|
+
"/cortex/crm/health",
|
|
17
|
+
"/cortex/crm/activity",
|
|
18
|
+
"/cortex/support",
|
|
19
|
+
"/cortex/support/:id",
|
|
20
|
+
"/cortex/community",
|
|
21
|
+
"/cortex/kb",
|
|
22
|
+
"/cortex/kb/editor",
|
|
23
|
+
"/cortex/kb/ingestion",
|
|
24
|
+
"/cortex/courses",
|
|
25
|
+
"/cortex/intelligence"
|
|
26
|
+
],
|
|
27
|
+
"nav_items": [
|
|
28
|
+
{
|
|
29
|
+
"title": "Dashboard",
|
|
30
|
+
"path": "/cortex/dashboard",
|
|
31
|
+
"icon": "LayoutDashboard",
|
|
32
|
+
"order": 1
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"title": "CRM",
|
|
36
|
+
"path": "/cortex/crm",
|
|
37
|
+
"icon": "Users",
|
|
38
|
+
"order": 2,
|
|
39
|
+
"children": [
|
|
40
|
+
{ "title": "Accounts", "path": "/cortex/crm/accounts" },
|
|
41
|
+
{ "title": "Contacts", "path": "/cortex/crm/contacts" },
|
|
42
|
+
{ "title": "Deals", "path": "/cortex/crm/deals" },
|
|
43
|
+
{ "title": "Health", "path": "/cortex/crm/health" },
|
|
44
|
+
{ "title": "Activity", "path": "/cortex/crm/activity" }
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"title": "Support",
|
|
49
|
+
"path": "/cortex/support",
|
|
50
|
+
"icon": "Headphones",
|
|
51
|
+
"order": 3
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"title": "Community",
|
|
55
|
+
"path": "/cortex/community",
|
|
56
|
+
"icon": "Users",
|
|
57
|
+
"order": 4
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"title": "Knowledge Base",
|
|
61
|
+
"path": "/cortex/kb",
|
|
62
|
+
"icon": "BookOpen",
|
|
63
|
+
"order": 5
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"title": "Courses",
|
|
67
|
+
"path": "/cortex/courses",
|
|
68
|
+
"icon": "GraduationCap",
|
|
69
|
+
"order": 6
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"title": "Intelligence",
|
|
73
|
+
"path": "/cortex/intelligence",
|
|
74
|
+
"icon": "Brain",
|
|
75
|
+
"order": 7
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"features": ["crm", "support", "community", "kb", "courses", "intelligence"],
|
|
79
|
+
"dependencies": ["items", "accounts", "pipelines", "integrations"],
|
|
80
|
+
"entry_point": "./index.tsx",
|
|
81
|
+
"sidebar_component": "./components/CortexSidebar.tsx"
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spine-framework-cortex",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Cortex — AI-powered support, CRM, and knowledge base app for Spine Framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/art-mojo-admin/spine",
|
|
10
|
+
"directory": "custom/apps/cortex"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"spine-framework": ">=0.1.0"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.tsx",
|
|
17
|
+
"manifest.json",
|
|
18
|
+
"seed/",
|
|
19
|
+
"pages/",
|
|
20
|
+
"components/",
|
|
21
|
+
"functions/",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"spine": {
|
|
25
|
+
"type": "app",
|
|
26
|
+
"slug": "cortex",
|
|
27
|
+
"manifestPath": "manifest.json"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { apiFetch } from '@core/lib/api'
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@core/components/ui/card'
|
|
5
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
6
|
+
import { Building2, Headphones, MessageSquare, BookOpen, GraduationCap, TrendingUp } from 'lucide-react'
|
|
7
|
+
|
|
8
|
+
interface Stats {
|
|
9
|
+
accounts: number
|
|
10
|
+
openTickets: number
|
|
11
|
+
unansweredPosts: number
|
|
12
|
+
kbArticles: number
|
|
13
|
+
pipeline: number
|
|
14
|
+
deals: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function StatCard({
|
|
18
|
+
title, value, sub, icon: Icon, href, loading
|
|
19
|
+
}: {
|
|
20
|
+
title: string; value: string | number; sub?: string; icon: React.ElementType; href: string; loading: boolean
|
|
21
|
+
}) {
|
|
22
|
+
const navigate = useNavigate()
|
|
23
|
+
return (
|
|
24
|
+
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(href)}>
|
|
25
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
26
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
|
27
|
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
28
|
+
</CardHeader>
|
|
29
|
+
<CardContent>
|
|
30
|
+
{loading ? (
|
|
31
|
+
<Skeleton className="h-8 w-16" />
|
|
32
|
+
) : (
|
|
33
|
+
<div className="text-3xl font-bold">{value}</div>
|
|
34
|
+
)}
|
|
35
|
+
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
|
36
|
+
</CardContent>
|
|
37
|
+
</Card>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function CortexDashboard() {
|
|
42
|
+
const [stats, setStats] = useState<Stats>({ accounts: 0, openTickets: 0, unansweredPosts: 0, kbArticles: 0, pipeline: 0, deals: 0 })
|
|
43
|
+
const [loading, setLoading] = useState(true)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
Promise.all([
|
|
47
|
+
apiFetch('/api/admin-data?action=list&entity=accounts&limit=1').then(r => r.json()),
|
|
48
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=support_ticket&limit=200').then(r => r.json()),
|
|
49
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=community_post&limit=200').then(r => r.json()),
|
|
50
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=kb_article&limit=1').then(r => r.json()),
|
|
51
|
+
apiFetch('/api/admin-data?action=list&entity=items&type_slug=deal&limit=200').then(r => r.json()),
|
|
52
|
+
]).then(([ar, tr, pr, kr, dr]) => {
|
|
53
|
+
const accounts = ar?.data ?? ar
|
|
54
|
+
const tickets = tr?.data ?? tr
|
|
55
|
+
const posts = pr?.data ?? pr
|
|
56
|
+
const kbArticles = kr?.data ?? kr
|
|
57
|
+
const deals = dr?.data ?? dr
|
|
58
|
+
const openTickets = (tickets || []).filter((t: any) => !['resolved', 'closed'].includes(t.status))
|
|
59
|
+
const pipeline = (deals || [])
|
|
60
|
+
.filter((d: any) => !['closed_won', 'closed_lost'].includes(d.data?.stage))
|
|
61
|
+
.reduce((sum: number, d: any) => sum + (d.data?.value || 0), 0)
|
|
62
|
+
setStats({
|
|
63
|
+
accounts: ar?.meta?.total ?? (accounts || []).length,
|
|
64
|
+
openTickets: openTickets.length,
|
|
65
|
+
unansweredPosts: (posts || []).length,
|
|
66
|
+
kbArticles: kr?.meta?.total ?? (kbArticles || []).length,
|
|
67
|
+
pipeline,
|
|
68
|
+
deals: (deals || []).length,
|
|
69
|
+
})
|
|
70
|
+
}).catch(() => {}).finally(() => setLoading(false))
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="p-6 space-y-6">
|
|
75
|
+
<div>
|
|
76
|
+
<h1 className="text-2xl font-bold">Cortex</h1>
|
|
77
|
+
<p className="text-muted-foreground text-sm mt-1">Operations overview</p>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
|
81
|
+
<StatCard title="Accounts" value={stats.accounts} icon={Building2} href="/cortex/crm/accounts" loading={loading} />
|
|
82
|
+
<StatCard title="Open Tickets" value={stats.openTickets} sub="support queue" icon={Headphones} href="/cortex/support" loading={loading} />
|
|
83
|
+
<StatCard title="Community Posts" value={stats.unansweredPosts} sub="all channels" icon={MessageSquare} href="/cortex/community" loading={loading} />
|
|
84
|
+
<StatCard title="KB Articles" value={stats.kbArticles} icon={BookOpen} href="/cortex/kb" loading={loading} />
|
|
85
|
+
<StatCard title="Deals" value={stats.deals} sub="all stages" icon={TrendingUp} href="/cortex/crm/deals" loading={loading} />
|
|
86
|
+
<StatCard
|
|
87
|
+
title="Pipeline"
|
|
88
|
+
value={loading ? '…' : `$${(stats.pipeline / 1000).toFixed(0)}k`}
|
|
89
|
+
sub="open deal value"
|
|
90
|
+
icon={GraduationCap}
|
|
91
|
+
href="/cortex/crm/deals"
|
|
92
|
+
loading={false}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|