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.
- package/CHANGELOG.md +13 -0
- package/LICENSE.md +216 -6
- package/components/CortexSidebar.tsx +27 -0
- package/functions/custom_funnel-signal.ts +25 -8
- package/index.tsx +10 -0
- package/package.json +1 -1
- package/pages/crm/AccountDetailPage.tsx +192 -146
- package/pages/ops/AuditFunnelPage.tsx +191 -0
- package/pages/ops/CommandCenterPage.tsx +377 -0
- package/pages/ops/InstallFunnelPage.tsx +226 -0
- package/seed/pipelines.json +34 -5
- package/seed/triggers.json +44 -4
|
@@ -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
|
+
}
|
package/seed/pipelines.json
CHANGED
|
@@ -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
|
-
"
|
|
6
|
+
"stages": [
|
|
6
7
|
{
|
|
7
|
-
"
|
|
8
|
-
"type": "function",
|
|
8
|
+
"stage_type": "agent_inference",
|
|
9
9
|
"config": {
|
|
10
10
|
"function": "case_analysis.analyze"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
"
|
|
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
|
]
|