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,393 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useParams, useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs'
5
+ import { Badge } from '@core/components/ui/badge'
6
+ import { Skeleton } from '@core/components/ui/skeleton'
7
+ import { Button } from '@core/components/ui/button'
8
+ import { ScrollArea } from '@core/components/ui/scroll-area'
9
+ import { Separator } from '@core/components/ui/separator'
10
+ import { ArrowLeft, User, Ticket, Handshake, Activity, Heart, Funnel, TrendingUp, Target, Clock } from 'lucide-react'
11
+
12
+ interface Account {
13
+ id: string
14
+ slug: string
15
+ display_name?: string
16
+ created_at: string
17
+ data?: {
18
+ segment?: string
19
+ lifecycle_stage?: string
20
+ lead_score?: number
21
+ temperature?: 'cold' | 'warm' | 'hot'
22
+ last_signal_at?: string
23
+ ratings?: {
24
+ anonymous?: { rating: number; raw_score: number }
25
+ identified?: { rating: number; raw_score: number }
26
+ engaged?: { rating: number; raw_score: number }
27
+ }
28
+ attribution?: {
29
+ anonymous_first_touch?: { referrer?: string; url?: string; at?: string }
30
+ identified_first_touch?: { referrer?: string; url?: string; at?: string }
31
+ }
32
+ queue?: {
33
+ pending_opportunity_id?: string
34
+ primary_opportunity_type?: string
35
+ }
36
+ }
37
+ }
38
+
39
+ interface Person {
40
+ id: string
41
+ email: string
42
+ full_name?: string
43
+ created_at: string
44
+ }
45
+
46
+ interface Item {
47
+ id: string
48
+ title: string
49
+ status?: string
50
+ data?: Record<string, any>
51
+ created_at: string
52
+ }
53
+
54
+ function PeopleTab({ accountId }: { accountId: string }) {
55
+ const [people, setPeople] = useState<Person[]>([])
56
+ const [loading, setLoading] = useState(true)
57
+ useEffect(() => {
58
+ apiFetch(`/api/admin-data?action=list&entity=people&account_id=${accountId}&limit=100`)
59
+ .then(r => r.json()).then(j => setPeople(Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : [])).catch(() => setPeople([])).finally(() => setLoading(false))
60
+ }, [accountId])
61
+ if (loading) return <div className="p-4 space-y-2">{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
62
+ if (people.length === 0) return <p className="p-6 text-sm text-muted-foreground text-center">No people found.</p>
63
+ return (
64
+ <div className="divide-y">
65
+ {people.map(p => (
66
+ <div key={p.id} className="flex items-center gap-3 px-4 py-3">
67
+ <div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0">
68
+ <User className="h-4 w-4 text-muted-foreground" />
69
+ </div>
70
+ <div>
71
+ <p className="text-sm font-medium">{p.full_name || '—'}</p>
72
+ <p className="text-xs text-muted-foreground">{p.email}</p>
73
+ </div>
74
+ <p className="ml-auto text-xs text-muted-foreground">{new Date(p.created_at).toLocaleDateString()}</p>
75
+ </div>
76
+ ))}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ function ItemsTab({ accountId, typeSlug, emptyText }: { accountId: string; typeSlug: string; emptyText: string }) {
82
+ const navigate = useNavigate()
83
+ const [items, setItems] = useState<Item[]>([])
84
+ const [loading, setLoading] = useState(true)
85
+ useEffect(() => {
86
+ apiFetch(`/api/admin-data?action=list&entity=items&type_slug=${typeSlug}&account_id=${accountId}&limit=100`)
87
+ .then(r => r.json()).then(j => setItems(Array.isArray(j?.data) ? j.data : Array.isArray(j) ? j : [])).catch(() => setItems([])).finally(() => setLoading(false))
88
+ }, [accountId, typeSlug])
89
+ if (loading) return <div className="p-4 space-y-2">{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
90
+ if (items.length === 0) return <p className="p-6 text-sm text-muted-foreground text-center">{emptyText}</p>
91
+ return (
92
+ <div className="divide-y">
93
+ {items.map(item => (
94
+ <div key={item.id}
95
+ className="flex items-center gap-3 px-4 py-3 hover:bg-accent/50 cursor-pointer transition-colors"
96
+ onClick={() => typeSlug === 'support_ticket' ? navigate(`/cortex/support/${item.id}`) : typeSlug === 'deal' ? navigate(`/cortex/crm/deals/${item.id}`) : undefined}
97
+ >
98
+ <div className="flex-1 min-w-0">
99
+ <p className="text-sm font-medium truncate">{item.title}</p>
100
+ <p className="text-xs text-muted-foreground">{new Date(item.created_at).toLocaleDateString()}</p>
101
+ </div>
102
+ {item.status && <Badge variant="secondary">{item.status}</Badge>}
103
+ </div>
104
+ ))}
105
+ </div>
106
+ )
107
+ }
108
+
109
+ function ActivityTab({ accountId }: { accountId: string }) {
110
+ const [items, setItems] = useState<Item[]>([])
111
+ const [loading, setLoading] = useState(true)
112
+ useEffect(() => {
113
+ Promise.all([
114
+ apiFetch(`/api/admin-data?action=list&entity=items&type_slug=marketing_touch&account_id=${accountId}&limit=20`).then(r => r.json()),
115
+ apiFetch(`/api/admin-data?action=list&entity=items&type_slug=site_visit&account_id=${accountId}&limit=20`).then(r => r.json()),
116
+ apiFetch(`/api/admin-data?action=list&entity=items&type_slug=support_ticket&account_id=${accountId}&limit=20`).then(r => r.json()),
117
+ ]).then(([tor, vor, tkr]) => {
118
+ const touches = tor?.data ?? tor
119
+ const visits = vor?.data ?? vor
120
+ const tickets = tkr?.data ?? tkr
121
+ const all = [...(touches || []), ...(visits || []), ...(tickets || [])]
122
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
123
+ setItems(all)
124
+ }).catch(() => setItems([])).finally(() => setLoading(false))
125
+ }, [accountId])
126
+ if (loading) return <div className="p-4 space-y-2">{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}</div>
127
+ if (items.length === 0) return <p className="p-6 text-sm text-muted-foreground text-center">No activity yet.</p>
128
+ return (
129
+ <div className="divide-y">
130
+ {items.map(item => (
131
+ <div key={item.id} className="flex items-center gap-3 px-4 py-3">
132
+ <Activity className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
133
+ <div className="flex-1 min-w-0">
134
+ <p className="text-sm truncate">{item.title}</p>
135
+ </div>
136
+ <p className="text-xs text-muted-foreground shrink-0">{new Date(item.created_at).toLocaleDateString()}</p>
137
+ </div>
138
+ ))}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ function FunnelTab({ account }: { account: Account }) {
144
+ const stage = account.data?.lifecycle_stage
145
+ const score = account.data?.lead_score ?? 0
146
+ const temp = account.data?.temperature
147
+ const ratings = account.data?.ratings
148
+ const attr = account.data?.attribution
149
+ const queue = account.data?.queue
150
+
151
+ return (
152
+ <div className="p-6 space-y-6">
153
+ {/* Header Stats */}
154
+ <div className="grid grid-cols-3 gap-4">
155
+ <div className="border rounded-lg p-4">
156
+ <div className="flex items-center gap-2 mb-2">
157
+ <Target className="h-4 w-4 text-muted-foreground" />
158
+ <span className="text-xs text-muted-foreground uppercase tracking-wider">Lifecycle Stage</span>
159
+ </div>
160
+ {stage ? (
161
+ <Badge className="capitalize text-sm" variant={stage === 'customer' ? 'default' : 'secondary'}>
162
+ {stage.replace(/_/g, ' ')}
163
+ </Badge>
164
+ ) : (
165
+ <span className="text-muted-foreground text-sm">Not set</span>
166
+ )}
167
+ </div>
168
+
169
+ <div className="border rounded-lg p-4">
170
+ <div className="flex items-center gap-2 mb-2">
171
+ <TrendingUp className="h-4 w-4 text-muted-foreground" />
172
+ <span className="text-xs text-muted-foreground uppercase tracking-wider">Lead Score</span>
173
+ </div>
174
+ <div className="flex items-center gap-3">
175
+ <div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
176
+ <div
177
+ className={`h-full rounded-full ${score >= 70 ? 'bg-green-500' : score >= 40 ? 'bg-yellow-500' : 'bg-gray-400'}`}
178
+ style={{ width: `${Math.min(score, 100)}%` }}
179
+ />
180
+ </div>
181
+ <span className="font-semibold">{score}</span>
182
+ </div>
183
+ </div>
184
+
185
+ <div className="border rounded-lg p-4">
186
+ <div className="flex items-center gap-2 mb-2">
187
+ <Clock className="h-4 w-4 text-muted-foreground" />
188
+ <span className="text-xs text-muted-foreground uppercase tracking-wider">Last Signal</span>
189
+ </div>
190
+ <span className="text-sm">
191
+ {account.data?.last_signal_at
192
+ ? new Date(account.data.last_signal_at).toLocaleDateString()
193
+ : 'Never'
194
+ }
195
+ </span>
196
+ </div>
197
+ </div>
198
+
199
+ {/* Temperature Badge */}
200
+ {temp && (
201
+ <div className="flex items-center gap-2">
202
+ <span className="text-sm text-muted-foreground">Temperature:</span>
203
+ <Badge
204
+ variant="outline"
205
+ className={`capitalize ${
206
+ temp === 'hot' ? 'border-red-400 text-red-600 bg-red-50' :
207
+ temp === 'warm' ? 'border-yellow-400 text-yellow-600 bg-yellow-50' :
208
+ 'border-blue-400 text-blue-600 bg-blue-50'
209
+ }`}
210
+ >
211
+ {temp}
212
+ </Badge>
213
+ </div>
214
+ )}
215
+
216
+ {/* Queue Status */}
217
+ {queue?.pending_opportunity_id && (
218
+ <div className="border rounded-lg p-4 bg-yellow-50 border-yellow-200">
219
+ <div className="flex items-center gap-2">
220
+ <Funnel className="h-4 w-4 text-yellow-600" />
221
+ <span className="font-medium text-sm">Opportunity in Queue</span>
222
+ </div>
223
+ <p className="text-xs text-muted-foreground mt-1">
224
+ Type: {queue.primary_opportunity_type?.replace(/_/g, ' ') || 'Unknown'}
225
+ </p>
226
+ </div>
227
+ )}
228
+
229
+ {/* Stage Ratings */}
230
+ {ratings && (
231
+ <div className="border rounded-lg p-4">
232
+ <h3 className="font-medium mb-4">Stage Ratings</h3>
233
+ <div className="space-y-3">
234
+ {ratings.anonymous && (
235
+ <div className="flex items-center justify-between">
236
+ <span className="text-sm text-muted-foreground">Anonymous</span>
237
+ <div className="flex items-center gap-2">
238
+ <div className="flex gap-0.5">
239
+ {Array.from({ length: 5 }).map((_, i) => (
240
+ <div
241
+ key={i}
242
+ className={`w-2 h-2 rounded-full ${i < ratings.anonymous!.rating ? 'bg-blue-500' : 'bg-gray-200'}`}
243
+ />
244
+ ))}
245
+ </div>
246
+ <span className="text-xs font-medium w-12 text-right">{ratings.anonymous.raw_score}</span>
247
+ </div>
248
+ </div>
249
+ )}
250
+ {ratings.identified && (
251
+ <div className="flex items-center justify-between">
252
+ <span className="text-sm text-muted-foreground">Identified</span>
253
+ <div className="flex items-center gap-2">
254
+ <div className="flex gap-0.5">
255
+ {Array.from({ length: 5 }).map((_, i) => (
256
+ <div
257
+ key={i}
258
+ className={`w-2 h-2 rounded-full ${i < ratings.identified!.rating ? 'bg-green-500' : 'bg-gray-200'}`}
259
+ />
260
+ ))}
261
+ </div>
262
+ <span className="text-xs font-medium w-12 text-right">{ratings.identified.raw_score}</span>
263
+ </div>
264
+ </div>
265
+ )}
266
+ {ratings.engaged && (
267
+ <div className="flex items-center justify-between">
268
+ <span className="text-sm text-muted-foreground">Engaged</span>
269
+ <div className="flex items-center gap-2">
270
+ <div className="flex gap-0.5">
271
+ {Array.from({ length: 5 }).map((_, i) => (
272
+ <div
273
+ key={i}
274
+ className={`w-2 h-2 rounded-full ${i < ratings.engaged!.rating ? 'bg-orange-500' : 'bg-gray-200'}`}
275
+ />
276
+ ))}
277
+ </div>
278
+ <span className="text-xs font-medium w-12 text-right">{ratings.engaged.raw_score}</span>
279
+ </div>
280
+ </div>
281
+ )}
282
+ </div>
283
+ </div>
284
+ )}
285
+
286
+ {/* Attribution */}
287
+ {(attr?.anonymous_first_touch || attr?.identified_first_touch) && (
288
+ <div className="border rounded-lg p-4">
289
+ <h3 className="font-medium mb-4">Attribution</h3>
290
+ <div className="space-y-3 text-sm">
291
+ {attr.anonymous_first_touch && (
292
+ <div className="flex justify-between">
293
+ <span className="text-muted-foreground">Anonymous First Touch</span>
294
+ <span className="text-right">
295
+ <span className="font-medium">{attr.anonymous_first_touch.referrer || 'Direct'}</span>
296
+ <span className="text-xs text-muted-foreground block">
297
+ {attr.anonymous_first_touch.at ? new Date(attr.anonymous_first_touch.at).toLocaleDateString() : ''}
298
+ </span>
299
+ </span>
300
+ </div>
301
+ )}
302
+ {attr.identified_first_touch && (
303
+ <div className="flex justify-between">
304
+ <span className="text-muted-foreground">Identified First Touch</span>
305
+ <span className="text-right">
306
+ <span className="font-medium">{attr.identified_first_touch.referrer || 'Direct'}</span>
307
+ <span className="text-xs text-muted-foreground block">
308
+ {attr.identified_first_touch.at ? new Date(attr.identified_first_touch.at).toLocaleDateString() : ''}
309
+ </span>
310
+ </span>
311
+ </div>
312
+ )}
313
+ </div>
314
+ </div>
315
+ )}
316
+
317
+ {/* Empty State */}
318
+ {!stage && !score && !temp && !ratings && !queue?.pending_opportunity_id && (
319
+ <div className="text-center py-12 text-muted-foreground">
320
+ <Funnel className="h-10 w-10 mx-auto mb-3 opacity-30" />
321
+ <p className="text-sm">No funnel data yet.</p>
322
+ <p className="text-xs mt-1">Signals will appear here as they are processed.</p>
323
+ </div>
324
+ )}
325
+ </div>
326
+ )
327
+ }
328
+
329
+ export default function AccountDetailPage() {
330
+ const { id } = useParams<{ id: string }>()
331
+ const navigate = useNavigate()
332
+ const [account, setAccount] = useState<Account | null>(null)
333
+ const [loading, setLoading] = useState(true)
334
+
335
+ useEffect(() => {
336
+ if (!id) return
337
+ apiFetch(`/api/admin-data?action=get&entity=accounts&id=${id}`)
338
+ .then(r => r.json()).then(j => setAccount(j?.data ?? j ?? null)).catch(() => setAccount(null)).finally(() => setLoading(false))
339
+ }, [id])
340
+
341
+ if (loading) return <div className="p-6 space-y-4"><Skeleton className="h-8 w-64" /><Skeleton className="h-4 w-48" /></div>
342
+ if (!account) return <div className="p-6 text-muted-foreground text-sm">Account not found.</div>
343
+
344
+ return (
345
+ <div className="flex flex-col h-full">
346
+ <div className="px-6 py-4 border-b border-border shrink-0">
347
+ <Button variant="ghost" size="sm" className="mb-2 -ml-2 gap-1 text-muted-foreground" onClick={() => navigate('/cortex/crm/accounts')}>
348
+ <ArrowLeft className="h-3.5 w-3.5" /> Accounts
349
+ </Button>
350
+ <div className="flex items-center gap-3">
351
+ <div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
352
+ <span className="text-primary font-bold text-sm">{(account.display_name || account.slug).charAt(0).toUpperCase()}</span>
353
+ </div>
354
+ <div>
355
+ <h1 className="text-xl font-bold">{account.display_name || account.slug}</h1>
356
+ <p className="text-xs text-muted-foreground font-mono">{account.slug}</p>
357
+ </div>
358
+ {account.data?.segment && <Badge variant="secondary" className="ml-2">{account.data.segment}</Badge>}
359
+ </div>
360
+ </div>
361
+
362
+ <Tabs defaultValue="people" className="flex flex-col flex-1 min-h-0">
363
+ <div className="px-6 border-b border-border shrink-0">
364
+ <TabsList className="h-9 bg-transparent p-0 gap-4">
365
+ {[
366
+ { value: 'people', label: 'People', icon: User },
367
+ { value: 'funnel', label: 'Funnel', icon: Funnel },
368
+ { value: 'tickets', label: 'Tickets', icon: Ticket },
369
+ { value: 'deals', label: 'Deals', icon: Handshake },
370
+ { value: 'health', label: 'Health', icon: Heart },
371
+ { value: 'activity', label: 'Activity', icon: Activity },
372
+ ].map(tab => (
373
+ <TabsTrigger key={tab.value} value={tab.value}
374
+ className="gap-1.5 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-9 px-1">
375
+ <tab.icon className="h-3.5 w-3.5" />
376
+ {tab.label}
377
+ </TabsTrigger>
378
+ ))}
379
+ </TabsList>
380
+ </div>
381
+
382
+ <ScrollArea className="flex-1">
383
+ <TabsContent value="people" className="mt-0"><PeopleTab accountId={id!} /></TabsContent>
384
+ <TabsContent value="funnel" className="mt-0"><FunnelTab account={account} /></TabsContent>
385
+ <TabsContent value="tickets" className="mt-0"><ItemsTab accountId={id!} typeSlug="support_ticket" emptyText="No tickets." /></TabsContent>
386
+ <TabsContent value="deals" className="mt-0"><ItemsTab accountId={id!} typeSlug="deal" emptyText="No deals." /></TabsContent>
387
+ <TabsContent value="health" className="mt-0"><ItemsTab accountId={id!} typeSlug="csm_health" emptyText="No health records." /></TabsContent>
388
+ <TabsContent value="activity" className="mt-0"><ActivityTab accountId={id!} /></TabsContent>
389
+ </ScrollArea>
390
+ </Tabs>
391
+ </div>
392
+ )
393
+ }
@@ -0,0 +1,164 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Input } from '@core/components/ui/input'
5
+ import { Badge } from '@core/components/ui/badge'
6
+ import { Skeleton } from '@core/components/ui/skeleton'
7
+ import { Button } from '@core/components/ui/button'
8
+ import { Building2, ChevronRight } from 'lucide-react'
9
+
10
+ interface Account {
11
+ id: string
12
+ slug: string
13
+ display_name?: string
14
+ created_at: string
15
+ data?: {
16
+ segment?: string
17
+ status?: string
18
+ lifecycle_stage?: string
19
+ lead_score?: number
20
+ temperature?: 'cold' | 'warm' | 'hot'
21
+ last_signal_at?: string
22
+ }
23
+ }
24
+
25
+ type Filter = 'all'
26
+
27
+ export default function AccountsPage() {
28
+ const navigate = useNavigate()
29
+ const [accounts, setAccounts] = useState<Account[]>([])
30
+ const [loading, setLoading] = useState(true)
31
+ const [search, setSearch] = useState('')
32
+ const [filter] = useState<Filter>('all')
33
+
34
+ useEffect(() => {
35
+ apiFetch('/api/admin-data?action=list&entity=accounts&limit=500')
36
+ .then(r => r.json())
37
+ .then(json => setAccounts(Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []))
38
+ .catch(() => setAccounts([]))
39
+ .finally(() => setLoading(false))
40
+ }, [])
41
+
42
+ const filtered = accounts.filter(a => {
43
+ const q = search.toLowerCase()
44
+ return !q || (a.display_name || a.slug || '').toLowerCase().includes(q)
45
+ })
46
+
47
+ return (
48
+ <div className="p-6 space-y-5">
49
+ <div className="flex items-center justify-between">
50
+ <div>
51
+ <h1 className="text-2xl font-bold">Accounts</h1>
52
+ <p className="text-muted-foreground text-sm mt-1">{accounts.length} accounts</p>
53
+ </div>
54
+ </div>
55
+
56
+ <div className="flex items-center gap-3">
57
+ <Input
58
+ value={search}
59
+ onChange={e => setSearch(e.target.value)}
60
+ placeholder="Search accounts…"
61
+ className="w-72"
62
+ />
63
+ <div className="flex gap-1">
64
+ <Button size="sm" variant={filter === 'all' ? 'default' : 'outline'}>All</Button>
65
+ </div>
66
+ </div>
67
+
68
+ <div className="border rounded-lg overflow-hidden">
69
+ {loading ? (
70
+ <div className="p-4 space-y-3">
71
+ {Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-14 w-full" />)}
72
+ </div>
73
+ ) : filtered.length === 0 ? (
74
+ <div className="p-12 text-center text-muted-foreground">
75
+ <Building2 className="h-10 w-10 mx-auto mb-3 opacity-30" />
76
+ <p className="text-sm">{search ? 'No accounts match your search.' : 'No accounts yet.'}</p>
77
+ </div>
78
+ ) : (
79
+ <table className="w-full text-sm">
80
+ <thead>
81
+ <tr className="text-xs text-muted-foreground uppercase tracking-wider border-b bg-muted/30">
82
+ <th className="text-left px-5 py-3 font-medium">Account</th>
83
+ <th className="text-left px-5 py-3 font-medium">Stage</th>
84
+ <th className="text-left px-5 py-3 font-medium">Score</th>
85
+ <th className="text-left px-5 py-3 font-medium">Temp</th>
86
+ <th className="text-right px-5 py-3 font-medium">Last Signal</th>
87
+ <th className="px-3 py-3" />
88
+ </tr>
89
+ </thead>
90
+ <tbody className="divide-y">
91
+ {filtered.map(account => (
92
+ <tr
93
+ key={account.id}
94
+ onClick={() => navigate(`/cortex/crm/accounts/${account.id}`)}
95
+ className="hover:bg-accent/50 cursor-pointer transition-colors"
96
+ >
97
+ <td className="px-5 py-3 font-medium">
98
+ {account.display_name || account.slug}
99
+ <div className="text-muted-foreground font-mono text-xs">{account.slug}</div>
100
+ </td>
101
+ <td className="px-5 py-3">
102
+ {account.data?.lifecycle_stage ? (
103
+ <Badge
104
+ variant={account.data.lifecycle_stage === 'customer' ? 'default' : 'secondary'}
105
+ className="capitalize"
106
+ >
107
+ {account.data.lifecycle_stage.replace(/_/g, ' ')}
108
+ </Badge>
109
+ ) : (
110
+ <span className="text-muted-foreground text-xs">—</span>
111
+ )}
112
+ </td>
113
+ <td className="px-5 py-3">
114
+ {account.data?.lead_score !== undefined ? (
115
+ <div className="flex items-center gap-2">
116
+ <div className="w-16 h-2 bg-muted rounded-full overflow-hidden">
117
+ <div
118
+ className={`h-full rounded-full ${
119
+ account.data.lead_score >= 70 ? 'bg-green-500' :
120
+ account.data.lead_score >= 40 ? 'bg-yellow-500' : 'bg-gray-400'
121
+ }`}
122
+ style={{ width: `${Math.min(account.data.lead_score, 100)}%` }}
123
+ />
124
+ </div>
125
+ <span className="text-xs font-medium">{account.data.lead_score}</span>
126
+ </div>
127
+ ) : (
128
+ <span className="text-muted-foreground text-xs">—</span>
129
+ )}
130
+ </td>
131
+ <td className="px-5 py-3">
132
+ {account.data?.temperature ? (
133
+ <Badge
134
+ variant="outline"
135
+ className={`capitalize ${
136
+ account.data.temperature === 'hot' ? 'border-red-400 text-red-600' :
137
+ account.data.temperature === 'warm' ? 'border-yellow-400 text-yellow-600' :
138
+ 'border-blue-400 text-blue-600'
139
+ }`}
140
+ >
141
+ {account.data.temperature}
142
+ </Badge>
143
+ ) : (
144
+ <span className="text-muted-foreground text-xs">—</span>
145
+ )}
146
+ </td>
147
+ <td className="px-5 py-3 text-right text-muted-foreground text-xs">
148
+ {account.data?.last_signal_at
149
+ ? new Date(account.data.last_signal_at).toLocaleDateString()
150
+ : 'Never'
151
+ }
152
+ </td>
153
+ <td className="px-3 py-3">
154
+ <ChevronRight className="h-4 w-4 text-muted-foreground/50" />
155
+ </td>
156
+ </tr>
157
+ ))}
158
+ </tbody>
159
+ </table>
160
+ )}
161
+ </div>
162
+ </div>
163
+ )
164
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { apiFetch } from '@core/lib/api'
3
+
4
+ interface ActivityItem {
5
+ id: string
6
+ title: string
7
+ data: Record<string, unknown>
8
+ created_at: string
9
+ type_slug?: string
10
+ }
11
+
12
+ const TYPE_LABELS: Record<string, { label: string; color: string }> = {
13
+ site_visit: { label: 'Site Visit', color: 'bg-purple-100 text-purple-700' },
14
+ marketing_touch: { label: 'Marketing Touch', color: 'bg-yellow-100 text-yellow-700' },
15
+ deal: { label: 'Deal', color: 'bg-blue-100 text-blue-700' },
16
+ csm_health: { label: 'Health Check', color: 'bg-green-100 text-green-700' },
17
+ }
18
+
19
+ export default function ActivityPage() {
20
+ const [items, setItems] = useState<ActivityItem[]>([])
21
+ const [loading, setLoading] = useState(true)
22
+
23
+ useEffect(() => {
24
+ Promise.all([
25
+ apiFetch('/api/admin-data?action=list&entity=items&type_slug=site_visit&limit=50').then(r => r.json()),
26
+ apiFetch('/api/admin-data?action=list&entity=items&type_slug=marketing_touch&limit=50').then(r => r.json()),
27
+ ])
28
+ .then(([vr, tr]) => {
29
+ const visits = vr?.data ?? vr
30
+ const touches = tr?.data ?? tr
31
+ const all = [
32
+ ...(visits || []).map((i: ActivityItem) => ({ ...i, type_slug: 'site_visit' })),
33
+ ...(touches || []).map((i: ActivityItem) => ({ ...i, type_slug: 'marketing_touch' })),
34
+ ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
35
+ setItems(all)
36
+ })
37
+ .catch(() => setItems([]))
38
+ .finally(() => setLoading(false))
39
+ }, [])
40
+
41
+ return (
42
+ <div className="p-6 space-y-5">
43
+ <div>
44
+ <h1 className="text-2xl font-bold">Activity Feed</h1>
45
+ <p className="text-muted-foreground text-sm mt-1">Site visits and marketing touches</p>
46
+ </div>
47
+
48
+ <div className="border rounded-lg overflow-hidden">
49
+ {loading ? (
50
+ <div className="p-8 text-center text-muted-foreground">Loading activity…</div>
51
+ ) : items.length === 0 ? (
52
+ <div className="p-8 text-center text-muted-foreground">No activity recorded yet.</div>
53
+ ) : (
54
+ <div className="divide-y">
55
+ {items.map(item => {
56
+ const typeInfo = TYPE_LABELS[item.type_slug || ''] || { label: item.type_slug || 'Event', color: 'bg-muted text-muted-foreground' }
57
+ return (
58
+ <div key={item.id} className="px-5 py-4 flex items-start gap-4">
59
+ <span className={`flex-shrink-0 inline-block px-2 py-0.5 rounded text-xs font-medium mt-0.5 ${typeInfo.color}`}>
60
+ {typeInfo.label}
61
+ </span>
62
+ <div className="flex-1 min-w-0">
63
+ <div className="text-sm font-medium truncate">{item.title}</div>
64
+ {item.type_slug === 'site_visit' && item.data?.url && (
65
+ <div className="text-xs text-muted-foreground truncate mt-0.5">{String(item.data.url)}</div>
66
+ )}
67
+ {item.type_slug === 'marketing_touch' && item.data?.channel && (
68
+ <div className="text-xs text-muted-foreground mt-0.5">via {String(item.data.channel)}{item.data?.campaign ? ` · ${item.data.campaign}` : ''}</div>
69
+ )}
70
+ </div>
71
+ <div className="flex-shrink-0 text-xs text-muted-foreground">
72
+ {new Date(item.created_at).toLocaleDateString()}
73
+ </div>
74
+ </div>
75
+ )
76
+ })}
77
+ </div>
78
+ )}
79
+ </div>
80
+ </div>
81
+ )
82
+ }