spine-framework-cortex 0.1.12 → 0.1.14

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.
@@ -0,0 +1,377 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Badge } from '@core/components/ui/badge'
5
+ import { Button } from '@core/components/ui/button'
6
+ import { Skeleton } from '@core/components/ui/skeleton'
7
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@core/components/ui/tabs'
8
+ import {
9
+ Flame, Download, ScanSearch, TrendingUp, Users,
10
+ ArrowUpRight, ChevronRight, Circle
11
+ } from 'lucide-react'
12
+
13
+ // ─── TYPES ────────────────────────────────────────────────────────────────────
14
+
15
+ interface KpiData {
16
+ launchPipeline: number
17
+ managedOpsArr: number
18
+ paidAuditRequests: number
19
+ claimedInstalls: number
20
+ loading: boolean
21
+ }
22
+
23
+ interface Opportunity {
24
+ id: string
25
+ title: string
26
+ account_id?: string
27
+ data?: {
28
+ recommendation?: { opportunity_type?: string; confidence?: string; suggested_priority?: number }
29
+ trigger?: { trigger_stage?: string; trigger_rating?: number; trigger_reason?: string }
30
+ identity?: { account_id?: string }
31
+ review?: { status?: string }
32
+ }
33
+ created_at: string
34
+ account?: { display_name?: string; slug?: string; data?: any }
35
+ }
36
+
37
+ interface FunnelSignal {
38
+ id: string
39
+ data?: {
40
+ classification?: { pipeline_slug?: string; stage?: string }
41
+ action?: { action_type?: string }
42
+ scoring_components?: { raw_score?: { rating?: number } }
43
+ }
44
+ created_at: string
45
+ }
46
+
47
+ // ─── HELPERS ──────────────────────────────────────────────────────────────────
48
+
49
+ function nextAction(stage?: string, temperature?: string): string {
50
+ if (temperature === 'hot') {
51
+ if (stage === 'installed') return 'Create Claim Link'
52
+ if (stage === 'identified') return 'Send Proposal'
53
+ return 'Schedule Exec Call'
54
+ }
55
+ if (temperature === 'warm') return 'Schedule Discovery Call'
56
+ return 'Review'
57
+ }
58
+
59
+ function temperatureBadge(temp?: string) {
60
+ if (temp === 'hot') return <Badge className="bg-red-100 text-red-700 border-red-200">Hot</Badge>
61
+ if (temp === 'warm') return <Badge className="bg-yellow-100 text-yellow-700 border-yellow-200">Warm</Badge>
62
+ return <Badge variant="secondary">New</Badge>
63
+ }
64
+
65
+ function opportunityTypeBadge(type?: string) {
66
+ const map: Record<string, string> = {
67
+ advanced_portal: 'Paid Audit',
68
+ managed_services: 'Managed Ops',
69
+ expansion: 'Expansion',
70
+ support_plan: 'Support Plan',
71
+ implementation: 'Unclaimed Install',
72
+ advocate: 'Partner',
73
+ }
74
+ return <Badge variant="outline">{map[type || ''] || type || '—'}</Badge>
75
+ }
76
+
77
+ function scoreBar(rating?: number) {
78
+ const val = Math.round(((rating || 0) / 5) * 100)
79
+ const color = val >= 80 ? 'bg-green-500' : val >= 50 ? 'bg-yellow-500' : 'bg-gray-300'
80
+ return (
81
+ <div className="flex items-center gap-2">
82
+ <div className="w-20 h-1.5 bg-muted rounded-full overflow-hidden">
83
+ <div className={`h-full rounded-full ${color}`} style={{ width: `${val}%` }} />
84
+ </div>
85
+ <span className="text-xs font-medium">{val}</span>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ // ─── FUNNEL HEALTH ────────────────────────────────────────────────────────────
91
+
92
+ function FunnelHealth({ signals, pipelineSlug, stages, label }: {
93
+ signals: FunnelSignal[]
94
+ pipelineSlug: string
95
+ stages: string[]
96
+ label: string
97
+ }) {
98
+ const filtered = signals.filter(s => s.data?.classification?.pipeline_slug === pipelineSlug)
99
+ const counts = stages.map(stage => ({
100
+ stage,
101
+ count: filtered.filter(s => s.data?.classification?.stage === stage).length
102
+ }))
103
+
104
+ return (
105
+ <div className="border rounded-lg p-4 space-y-3">
106
+ <div className="flex items-center gap-2">
107
+ {pipelineSlug.includes('audit') ? <ScanSearch className="h-4 w-4 text-muted-foreground" /> : <Download className="h-4 w-4 text-muted-foreground" />}
108
+ <span className="font-medium text-sm">{label} Health</span>
109
+ </div>
110
+ <div className="flex items-center gap-1">
111
+ {counts.map((c, i) => (
112
+ <div key={c.stage} className="flex items-center gap-1">
113
+ <div className="text-center">
114
+ <div className="text-lg font-bold">{c.count}</div>
115
+ <div className="text-xs text-muted-foreground capitalize">{c.stage}</div>
116
+ </div>
117
+ {i < counts.length - 1 && <ChevronRight className="h-3 w-3 text-muted-foreground mx-1" />}
118
+ </div>
119
+ ))}
120
+ </div>
121
+ </div>
122
+ )
123
+ }
124
+
125
+ // ─── MAIN PAGE ────────────────────────────────────────────────────────────────
126
+
127
+ export default function CommandCenterPage() {
128
+ const navigate = useNavigate()
129
+ const [kpis, setKpis] = useState<KpiData>({ launchPipeline: 0, managedOpsArr: 0, paidAuditRequests: 0, claimedInstalls: 0, loading: true })
130
+ const [opportunities, setOpportunities] = useState<Opportunity[]>([])
131
+ const [accounts, setAccounts] = useState<any[]>([])
132
+ const [signals, setSignals] = useState<FunnelSignal[]>([])
133
+ const [loading, setLoading] = useState(true)
134
+
135
+ useEffect(() => {
136
+ Promise.all([
137
+ apiFetch('/api/admin-data?action=list&entity=items&type_slug=deal&limit=500').then(r => r.json()),
138
+ apiFetch('/api/admin-data?action=list&entity=items&type_slug=opportunity_queue&limit=100').then(r => r.json()),
139
+ apiFetch('/api/admin-data?action=list&entity=accounts&limit=500').then(r => r.json()),
140
+ apiFetch('/api/admin-data?action=list&entity=items&type_slug=funnel_signal&limit=500').then(r => r.json()),
141
+ ]).then(([dealsRes, oppsRes, accsRes, sigsRes]) => {
142
+ const deals: any[] = dealsRes?.data ?? dealsRes ?? []
143
+ const opps: Opportunity[] = oppsRes?.data ?? oppsRes ?? []
144
+ const accs: any[] = accsRes?.data ?? accsRes ?? []
145
+ const sigs: FunnelSignal[] = sigsRes?.data ?? sigsRes ?? []
146
+
147
+ // KPIs from deals
148
+ const launchPipeline = deals
149
+ .filter((d: any) => d.data?.stage === 'launch_sprint')
150
+ .reduce((s: number, d: any) => s + (d.data?.value || 0), 0)
151
+ const managedOpsArr = deals
152
+ .filter((d: any) => d.data?.stage === 'managed_ops')
153
+ .reduce((s: number, d: any) => s + (d.data?.value || 0), 0)
154
+ const paidAuditRequests = opps.filter(o => o.data?.recommendation?.opportunity_type === 'advanced_portal').length
155
+ const claimedInstalls = accs.filter((a: any) => a.data?.claim_status === 'claimed').length
156
+
157
+ setKpis({ launchPipeline, managedOpsArr, paidAuditRequests, claimedInstalls, loading: false })
158
+
159
+ // Enrich opportunities with account data
160
+ const accById = Object.fromEntries(accs.map((a: any) => [a.id, a]))
161
+ const enriched = opps.map(o => ({
162
+ ...o,
163
+ account: accById[o.data?.identity?.account_id || o.account_id || '']
164
+ }))
165
+ setOpportunities(enriched)
166
+ setAccounts(accs)
167
+ setSignals(sigs)
168
+ }).catch(() => {}).finally(() => setLoading(false))
169
+ }, [])
170
+
171
+ const hotOpps = opportunities
172
+ .filter(o => o.data?.review?.status === 'pending')
173
+ .sort((a, b) => (b.data?.trigger?.trigger_rating || 0) - (a.data?.trigger?.trigger_rating || 0))
174
+ .slice(0, 10)
175
+
176
+ const auditAccounts = accounts.filter(a => a.data?.ratings?.['funnel-ai-audit'])
177
+ const installAccounts = accounts.filter(a => a.data?.ratings?.['funnel-technical'] || a.data?.lifecycle_stage === 'installed')
178
+
179
+ return (
180
+ <div className="p-6 space-y-6">
181
+ {/* Header */}
182
+ <div className="flex items-center justify-between">
183
+ <div>
184
+ <h1 className="text-2xl font-bold">Opportunity Command Center</h1>
185
+ <p className="text-sm text-muted-foreground mt-0.5">Signal-driven revenue operations. Prioritize what's hot, act with confidence, close faster.</p>
186
+ </div>
187
+ <div className="flex gap-2">
188
+ <Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1.5" />Export Signals</Button>
189
+ <Button size="sm"><TrendingUp className="h-4 w-4 mr-1.5" />Create Opportunity</Button>
190
+ </div>
191
+ </div>
192
+
193
+ {/* KPI Strip */}
194
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
195
+ {[
196
+ { label: 'Launch Pipeline', value: kpis.loading ? '…' : `$${(kpis.launchPipeline / 1000).toFixed(0)}k`, icon: TrendingUp, delta: null },
197
+ { label: 'Managed Ops ARR', value: kpis.loading ? '…' : `$${(kpis.managedOpsArr / 1000).toFixed(0)}k`, icon: TrendingUp, delta: null },
198
+ { label: 'Paid Audit Requests', value: kpis.loading ? '…' : kpis.paidAuditRequests, icon: ScanSearch, delta: null },
199
+ { label: 'Claimed Active Installs', value: kpis.loading ? '…' : kpis.claimedInstalls, icon: Download, delta: null },
200
+ ].map(kpi => (
201
+ <div key={kpi.label} className="border rounded-lg p-4 space-y-2">
202
+ <div className="flex items-center justify-between">
203
+ <span className="text-xs text-muted-foreground">{kpi.label}</span>
204
+ <kpi.icon className="h-4 w-4 text-muted-foreground" />
205
+ </div>
206
+ {kpis.loading
207
+ ? <Skeleton className="h-7 w-20" />
208
+ : <div className="text-2xl font-bold">{kpi.value}</div>
209
+ }
210
+ </div>
211
+ ))}
212
+ </div>
213
+
214
+ {/* Hot Opportunities */}
215
+ <div className="border rounded-lg">
216
+ <div className="flex items-center justify-between px-4 py-3 border-b">
217
+ <div className="flex items-center gap-2">
218
+ <Flame className="h-4 w-4 text-orange-500" />
219
+ <span className="font-semibold">Hot Opportunities</span>
220
+ </div>
221
+ <Button variant="ghost" size="sm" className="text-xs gap-1">
222
+ View all <ArrowUpRight className="h-3 w-3" />
223
+ </Button>
224
+ </div>
225
+
226
+ {loading ? (
227
+ <div className="p-4 space-y-3">
228
+ {Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
229
+ </div>
230
+ ) : hotOpps.length === 0 ? (
231
+ <div className="p-8 text-center text-sm text-muted-foreground">
232
+ <Circle className="h-8 w-8 mx-auto mb-2 opacity-20" />
233
+ No pending opportunities yet. Signals will appear here once they score above threshold.
234
+ </div>
235
+ ) : (
236
+ <div className="divide-y">
237
+ {/* Table header */}
238
+ <div className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_2fr_1fr] gap-4 px-4 py-2 text-xs text-muted-foreground font-medium">
239
+ <span>Opportunity</span><span>Type</span><span>Stage</span>
240
+ <span>Signal Score</span><span>Last Signal</span>
241
+ <span>Next Action</span><span>Status</span>
242
+ </div>
243
+ {hotOpps.map(opp => {
244
+ const acc = opp.account
245
+ const temp = acc?.data?.temperature
246
+ const stage = opp.data?.trigger?.trigger_stage
247
+ const rating = opp.data?.trigger?.trigger_rating
248
+ const oppType = opp.data?.recommendation?.opportunity_type
249
+ return (
250
+ <div
251
+ key={opp.id}
252
+ className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_2fr_1fr] gap-4 px-4 py-3 items-center hover:bg-accent/50 cursor-pointer transition-colors text-sm"
253
+ onClick={() => acc?.id && navigate(`/cortex/crm/accounts/${acc.id}`)}
254
+ >
255
+ <div className="min-w-0">
256
+ <div className="font-medium truncate">{acc?.display_name || acc?.slug || opp.title}</div>
257
+ <div className="text-xs text-muted-foreground truncate">{acc?.slug || '—'}</div>
258
+ </div>
259
+ <div>{opportunityTypeBadge(oppType)}</div>
260
+ <div><Badge variant="secondary" className="capitalize">{stage || '—'}</Badge></div>
261
+ <div>{scoreBar(rating)}</div>
262
+ <div className="text-xs text-muted-foreground">{new Date(opp.created_at).toLocaleDateString()}</div>
263
+ <div className="text-xs">{nextAction(stage, temp)}</div>
264
+ <div>{temperatureBadge(temp)}</div>
265
+ </div>
266
+ )
267
+ })}
268
+ </div>
269
+ )}
270
+ </div>
271
+
272
+ {/* Bottom: Tabs + Health Panels */}
273
+ <div className="grid grid-cols-1 lg:grid-cols-[1fr_300px_300px] gap-6">
274
+ <div className="border rounded-lg">
275
+ <Tabs defaultValue="audit">
276
+ <div className="px-4 pt-3 border-b">
277
+ <TabsList className="h-8 bg-transparent p-0 gap-4">
278
+ {['audit', 'install', 'partners', 'forecast'].map(t => (
279
+ <TabsTrigger key={t} value={t}
280
+ className="capitalize rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-8 px-1 text-xs">
281
+ {t === 'audit' ? 'Audit Funnel' : t === 'install' ? 'Install Funnel' : t === 'partners' ? 'Partner Signals' : 'Revenue Forecast'}
282
+ </TabsTrigger>
283
+ ))}
284
+ </TabsList>
285
+ </div>
286
+
287
+ <TabsContent value="audit" className="mt-0 p-4">
288
+ <p className="text-xs text-muted-foreground mb-3 font-medium">Audit-Led Opportunities</p>
289
+ {auditAccounts.length === 0 ? (
290
+ <p className="text-sm text-muted-foreground text-center py-6">No audit funnel activity yet.</p>
291
+ ) : (
292
+ <div className="divide-y">
293
+ <div className="grid grid-cols-[2fr_1fr_2fr_1fr] gap-3 pb-2 text-xs text-muted-foreground font-medium">
294
+ <span>Account</span><span>Score</span><span>Recommended Offer</span><span>Status</span>
295
+ </div>
296
+ {auditAccounts.slice(0, 6).map(a => {
297
+ const r = a.data?.ratings?.['funnel-ai-audit']?.rating || 0
298
+ const offer = r >= 5 ? 'Paid Audit $5k' : r >= 4 ? 'Discovery Call' : r >= 3 ? 'Self-Audit' : 'Content Nurture'
299
+ const status = a.data?.temperature === 'hot' ? 'Hot' : a.data?.temperature === 'warm' ? 'Warm' : 'New'
300
+ return (
301
+ <div key={a.id}
302
+ className="grid grid-cols-[2fr_1fr_2fr_1fr] gap-3 py-2 items-center text-sm cursor-pointer hover:bg-accent/50"
303
+ onClick={() => navigate(`/cortex/crm/accounts/${a.id}`)}>
304
+ <span className="font-medium truncate">{a.display_name || a.slug}</span>
305
+ {scoreBar(r)}
306
+ <span className="text-xs text-muted-foreground">{offer}</span>
307
+ <Badge variant={status === 'Hot' ? 'destructive' : 'secondary'} className="text-xs">{status}</Badge>
308
+ </div>
309
+ )
310
+ })}
311
+ </div>
312
+ )}
313
+ <Button variant="ghost" size="sm" className="mt-2 text-xs" onClick={() => navigate('/cortex/ops/audit-funnel')}>
314
+ View all audit opportunities <ChevronRight className="h-3 w-3 ml-1" />
315
+ </Button>
316
+ </TabsContent>
317
+
318
+ <TabsContent value="install" className="mt-0 p-4">
319
+ <p className="text-xs text-muted-foreground mb-3 font-medium">Install-Led Opportunities</p>
320
+ {installAccounts.length === 0 ? (
321
+ <p className="text-sm text-muted-foreground text-center py-6">No install funnel activity yet.</p>
322
+ ) : (
323
+ <div className="divide-y">
324
+ <div className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-3 pb-2 text-xs text-muted-foreground font-medium">
325
+ <span>Account</span><span>Score</span><span>Stage</span><span>Claim</span>
326
+ </div>
327
+ {installAccounts.slice(0, 6).map(a => {
328
+ const r = a.data?.ratings?.['funnel-technical']?.rating || a.data?.ratings?.installed?.rating || 0
329
+ return (
330
+ <div key={a.id}
331
+ className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-3 py-2 items-center text-sm cursor-pointer hover:bg-accent/50"
332
+ onClick={() => navigate(`/cortex/crm/accounts/${a.id}`)}>
333
+ <span className="font-medium truncate">{a.display_name || a.slug}</span>
334
+ {scoreBar(r)}
335
+ <Badge variant="secondary" className="text-xs capitalize">{a.data?.lifecycle_stage || '—'}</Badge>
336
+ <Badge variant={a.data?.claim_status === 'claimed' ? 'default' : 'outline'} className="text-xs">
337
+ {a.data?.claim_status === 'claimed' ? 'Claimed' : 'Unclaimed'}
338
+ </Badge>
339
+ </div>
340
+ )
341
+ })}
342
+ </div>
343
+ )}
344
+ <Button variant="ghost" size="sm" className="mt-2 text-xs" onClick={() => navigate('/cortex/ops/install-funnel')}>
345
+ View install funnel <ChevronRight className="h-3 w-3 ml-1" />
346
+ </Button>
347
+ </TabsContent>
348
+
349
+ <TabsContent value="partners" className="mt-0 p-8 text-center text-sm text-muted-foreground">
350
+ <Users className="h-8 w-8 mx-auto mb-2 opacity-20" />
351
+ Partner signal tracking coming soon.
352
+ </TabsContent>
353
+
354
+ <TabsContent value="forecast" className="mt-0 p-8 text-center text-sm text-muted-foreground">
355
+ <TrendingUp className="h-8 w-8 mx-auto mb-2 opacity-20" />
356
+ Revenue forecast coming soon.
357
+ </TabsContent>
358
+ </Tabs>
359
+ </div>
360
+
361
+ {/* Funnel Health Panels */}
362
+ <FunnelHealth
363
+ signals={signals}
364
+ pipelineSlug="funnel-technical"
365
+ stages={['anonymous', 'identified', 'installed']}
366
+ label="Install Funnel"
367
+ />
368
+ <FunnelHealth
369
+ signals={signals}
370
+ pipelineSlug="funnel-ai-audit"
371
+ stages={['anonymous', 'identified', 'installed']}
372
+ label="Audit Funnel"
373
+ />
374
+ </div>
375
+ </div>
376
+ )
377
+ }
@@ -0,0 +1,226 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Badge } from '@core/components/ui/badge'
5
+ import { Button } from '@core/components/ui/button'
6
+ import { Input } from '@core/components/ui/input'
7
+ import { Skeleton } from '@core/components/ui/skeleton'
8
+ import { Download, Search, ChevronRight } from 'lucide-react'
9
+
10
+ type FilterTab = 'all' | 'unclaimed' | 'claimed' | 'hot'
11
+
12
+ interface InstallAccount {
13
+ id: string
14
+ slug: string
15
+ display_name?: string
16
+ created_at: string
17
+ data?: {
18
+ temperature?: 'cold' | 'warm' | 'hot'
19
+ lifecycle_stage?: string
20
+ lead_score?: number
21
+ last_signal_at?: string
22
+ claim_status?: string
23
+ instance_id?: string
24
+ ratings?: Record<string, { rating: number; raw_score: number; calculated_at?: string }>
25
+ }
26
+ }
27
+
28
+ function scoreBar(rating: number) {
29
+ const val = Math.round((rating / 5) * 100)
30
+ const color = val >= 80 ? 'bg-green-500' : val >= 50 ? 'bg-yellow-500' : 'bg-gray-300'
31
+ return (
32
+ <div className="flex items-center gap-2">
33
+ <div className="w-24 h-1.5 bg-muted rounded-full overflow-hidden">
34
+ <div className={`h-full rounded-full ${color}`} style={{ width: `${val}%` }} />
35
+ </div>
36
+ <span className="text-xs font-medium tabular-nums">{val}</span>
37
+ </div>
38
+ )
39
+ }
40
+
41
+ function claimBadge(status?: string) {
42
+ if (status === 'claimed') return <Badge className="text-xs bg-green-100 text-green-700 border-green-200">Claimed</Badge>
43
+ return <Badge variant="outline" className="text-xs">Unclaimed</Badge>
44
+ }
45
+
46
+ function stageBadge(stage?: string) {
47
+ return <Badge variant="secondary" className="text-xs capitalize">{stage || '—'}</Badge>
48
+ }
49
+
50
+ function maskInstanceId(id?: string): string {
51
+ if (!id) return '—'
52
+ if (id.length <= 12) return id
53
+ return id.slice(0, 10) + '…'
54
+ }
55
+
56
+ function daysSince(dateStr?: string): number | null {
57
+ if (!dateStr) return null
58
+ return Math.floor((Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60 * 24))
59
+ }
60
+
61
+ export default function InstallFunnelPage() {
62
+ const navigate = useNavigate()
63
+ const [accounts, setAccounts] = useState<InstallAccount[]>([])
64
+ const [loading, setLoading] = useState(true)
65
+ const [search, setSearch] = useState('')
66
+ const [tab, setTab] = useState<FilterTab>('all')
67
+
68
+ useEffect(() => {
69
+ apiFetch('/api/admin-data?action=list&entity=accounts&limit=500')
70
+ .then(r => r.json())
71
+ .then(json => {
72
+ const all: InstallAccount[] = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
73
+ // Keep accounts in the technical funnel
74
+ const techAccounts = all.filter(a =>
75
+ a.data?.ratings?.['funnel-technical'] ||
76
+ a.data?.ratings?.['funnel-technical-installed'] ||
77
+ a.data?.ratings?.['funnel-technical-claimed'] ||
78
+ a.data?.lifecycle_stage === 'installed'
79
+ )
80
+ // Sort by technical rating desc, then by first signal date
81
+ techAccounts.sort((a, b) => {
82
+ const ra = a.data?.ratings?.['funnel-technical']?.rating || a.data?.ratings?.installed?.rating || 0
83
+ const rb = b.data?.ratings?.['funnel-technical']?.rating || b.data?.ratings?.installed?.rating || 0
84
+ if (rb !== ra) return rb - ra
85
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
86
+ })
87
+ setAccounts(techAccounts)
88
+ })
89
+ .catch(() => setAccounts([]))
90
+ .finally(() => setLoading(false))
91
+ }, [])
92
+
93
+ const tabFiltered = accounts.filter(a => {
94
+ if (tab === 'unclaimed') return a.data?.claim_status !== 'claimed'
95
+ if (tab === 'claimed') return a.data?.claim_status === 'claimed'
96
+ if (tab === 'hot') return a.data?.temperature === 'hot'
97
+ return true
98
+ })
99
+
100
+ const filtered = tabFiltered.filter(a => {
101
+ const q = search.toLowerCase()
102
+ return !q || (a.display_name || a.slug || a.data?.instance_id || '').toLowerCase().includes(q)
103
+ })
104
+
105
+ const counts = {
106
+ all: accounts.length,
107
+ unclaimed: accounts.filter(a => a.data?.claim_status !== 'claimed').length,
108
+ claimed: accounts.filter(a => a.data?.claim_status === 'claimed').length,
109
+ hot: accounts.filter(a => a.data?.temperature === 'hot').length,
110
+ }
111
+
112
+ return (
113
+ <div className="p-6 space-y-6">
114
+ {/* Header */}
115
+ <div className="flex items-center justify-between">
116
+ <div>
117
+ <div className="flex items-center gap-2">
118
+ <Download className="h-5 w-5 text-muted-foreground" />
119
+ <h1 className="text-2xl font-bold">Install Funnel</h1>
120
+ </div>
121
+ <p className="text-sm text-muted-foreground mt-0.5">
122
+ Developer accounts progressing from npm install through claim — your technical pipeline.
123
+ </p>
124
+ </div>
125
+ <Button size="sm" onClick={() => navigate('/cortex/ops/command-center')}>
126
+ Command Center <ChevronRight className="h-3.5 w-3.5 ml-1" />
127
+ </Button>
128
+ </div>
129
+
130
+ {/* Summary stats */}
131
+ <div className="grid grid-cols-4 gap-4">
132
+ {[
133
+ { label: 'Total Installs', value: loading ? '…' : accounts.length },
134
+ { label: 'Claimed', value: loading ? '…' : counts.claimed },
135
+ { label: 'Unclaimed', value: loading ? '…' : counts.unclaimed },
136
+ { label: 'Hot', value: loading ? '…' : counts.hot },
137
+ ].map(s => (
138
+ <div key={s.label} className="border rounded-lg p-4">
139
+ <div className="text-xs text-muted-foreground mb-1">{s.label}</div>
140
+ {loading ? <Skeleton className="h-7 w-12" /> : <div className="text-2xl font-bold">{s.value}</div>}
141
+ </div>
142
+ ))}
143
+ </div>
144
+
145
+ {/* Table */}
146
+ <div className="border rounded-lg">
147
+ <div className="flex items-center justify-between px-4 py-3 border-b">
148
+ {/* Filter tabs */}
149
+ <div className="flex gap-0.5">
150
+ {(['all', 'unclaimed', 'claimed', 'hot'] as FilterTab[]).map(t => (
151
+ <button
152
+ key={t}
153
+ onClick={() => setTab(t)}
154
+ className={`px-3 py-1 text-xs rounded-md capitalize transition-colors ${
155
+ tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'
156
+ }`}
157
+ >
158
+ {t} ({counts[t]})
159
+ </button>
160
+ ))}
161
+ </div>
162
+ <div className="relative w-56">
163
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
164
+ <Input
165
+ className="h-7 pl-8 text-xs"
166
+ placeholder="Search installs…"
167
+ value={search}
168
+ onChange={e => setSearch(e.target.value)}
169
+ />
170
+ </div>
171
+ </div>
172
+
173
+ {loading ? (
174
+ <div className="p-4 space-y-3">
175
+ {Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
176
+ </div>
177
+ ) : filtered.length === 0 ? (
178
+ <div className="p-10 text-center text-sm text-muted-foreground">
179
+ <Download className="h-8 w-8 mx-auto mb-2 opacity-20" />
180
+ {search ? 'No installs match your search.' : 'No technical funnel activity yet. npm install signals will appear here.'}
181
+ </div>
182
+ ) : (
183
+ <>
184
+ <div className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-2 text-xs text-muted-foreground font-medium border-b bg-muted/30">
185
+ <span>Instance / Account</span>
186
+ <span>Signal Score</span>
187
+ <span>Stage</span>
188
+ <span>Last Action</span>
189
+ <span>Claim Status</span>
190
+ <span>Days Active</span>
191
+ </div>
192
+ <div className="divide-y">
193
+ {filtered.map(a => {
194
+ const rating = a.data?.ratings?.['funnel-technical']?.rating
195
+ || a.data?.ratings?.['funnel-technical-claimed']?.rating
196
+ || a.data?.ratings?.installed?.rating
197
+ || 0
198
+ const days = daysSince(a.created_at)
199
+ const instanceId = a.data?.instance_id || a.slug
200
+ return (
201
+ <div
202
+ key={a.id}
203
+ className="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] gap-4 px-4 py-3 items-center hover:bg-accent/50 cursor-pointer transition-colors text-sm"
204
+ onClick={() => navigate(`/cortex/crm/accounts/${a.id}`)}
205
+ >
206
+ <div className="min-w-0">
207
+ <div className="font-medium font-mono text-xs truncate">{maskInstanceId(instanceId)}</div>
208
+ {a.display_name && <div className="text-xs text-muted-foreground truncate">{a.display_name}</div>}
209
+ </div>
210
+ <div>{scoreBar(rating)}</div>
211
+ <div>{stageBadge(a.data?.lifecycle_stage)}</div>
212
+ <div className="text-xs text-muted-foreground">
213
+ {a.data?.last_signal_at ? new Date(a.data.last_signal_at).toLocaleDateString() : '—'}
214
+ </div>
215
+ <div>{claimBadge(a.data?.claim_status)}</div>
216
+ <div className="text-xs text-muted-foreground">{days !== null ? `${days}d` : '—'}</div>
217
+ </div>
218
+ )
219
+ })}
220
+ </div>
221
+ </>
222
+ )}
223
+ </div>
224
+ </div>
225
+ )
226
+ }
@@ -1,18 +1,17 @@
1
1
  [
2
2
  {
3
+ "slug": "case-analysis",
3
4
  "name": "Case Analysis Pipeline",
4
5
  "description": "Automatically analyzes resolved support tickets to extract insights and create tags",
5
- "steps": [
6
+ "stages": [
6
7
  {
7
- "name": "Analyze Ticket",
8
- "type": "function",
8
+ "stage_type": "agent_inference",
9
9
  "config": {
10
10
  "function": "case_analysis.analyze"
11
11
  }
12
12
  },
13
13
  {
14
- "name": "Generate Tags",
15
- "type": "function",
14
+ "stage_type": "agent_inference",
16
15
  "config": {
17
16
  "function": "tag_management.generate"
18
17
  }
@@ -26,5 +25,35 @@
26
25
  "ownership": "tenant",
27
26
  "is_system": false,
28
27
  "is_active": true
28
+ },
29
+ {
30
+ "slug": "funnel-general",
31
+ "name": "Funnel: General",
32
+ "description": "Default funnel pipeline — fires for all signals without a specific pipeline_slug. Add stages to notify, create deals, or trigger outreach.",
33
+ "stages": [],
34
+ "config": {},
35
+ "ownership": "tenant",
36
+ "is_system": false,
37
+ "is_active": true
38
+ },
39
+ {
40
+ "slug": "funnel-ai-audit",
41
+ "name": "Funnel: AI Audit",
42
+ "description": "Pipeline for the AI Audit funnel. Fires when a signal arrives with pipeline_slug='funnel-ai-audit'. Add stages to run your sales sequence.",
43
+ "stages": [],
44
+ "config": {},
45
+ "ownership": "tenant",
46
+ "is_system": false,
47
+ "is_active": true
48
+ },
49
+ {
50
+ "slug": "funnel-implementation",
51
+ "name": "Funnel: Implementation",
52
+ "description": "Pipeline for the Implementation Contract funnel. Fires when a signal arrives with pipeline_slug='funnel-implementation'.",
53
+ "stages": [],
54
+ "config": {},
55
+ "ownership": "tenant",
56
+ "is_system": false,
57
+ "is_active": true
29
58
  }
30
59
  ]