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,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
+ }