spine-framework-cortex 0.1.18 → 0.2.0
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/{functions/custom_cortex-handler.ts → api/cortex-handler.ts} +9 -9
- package/components/CliInstancesCard.tsx +144 -0
- package/components/CortexSidebar.tsx +27 -53
- package/hooks/useTypeRegistry.ts +74 -0
- package/index.tsx +13 -24
- package/manifest.json +1 -13
- package/package.json +11 -20
- package/pages/courses/CoursesPage.tsx +14 -4
- package/pages/crm/AccountDetailPage.tsx +149 -194
- package/pages/crm/ContactsPage.tsx +7 -7
- package/pages/intelligence/IntelligencePage.tsx +24 -31
- package/pages/kb/KBEditorPage.tsx +9 -2
- package/pages/operations/AuditFunnelPage.tsx +378 -0
- package/pages/operations/InstallFunnelPage.tsx +410 -0
- package/pages/operations/OperationsDashboard.tsx +275 -0
- package/pages/support/RedactionReview.tsx +11 -2
- package/seed/link-types.json +8 -42
- package/seed/package.json +27 -0
- package/seed/roles.json +1 -1
- package/seed/types.json +2711 -596
- package/CHANGELOG.md +0 -42
- package/LICENSE.md +0 -223
- package/README.md +0 -69
- package/functions/custom_anonymous-sessions.ts +0 -356
- package/functions/custom_case_analysis.ts +0 -507
- package/functions/custom_community-escalation.ts +0 -234
- package/functions/custom_cortex-chunks.ts +0 -52
- package/functions/custom_funnel-scoring.ts +0 -256
- package/functions/custom_funnel-signal.ts +0 -430
- package/functions/custom_funnel-timers.ts +0 -449
- package/functions/custom_kb-chunker-test.ts +0 -364
- package/functions/custom_kb-chunker.ts +0 -576
- package/functions/custom_kb-embeddings.ts +0 -481
- package/functions/custom_kb-ingestion.ts +0 -448
- package/functions/custom_support-triage.ts +0 -649
- package/functions/custom_tag_management.ts +0 -314
- package/functions/webhook-handlers.ts +0 -29
- package/lib/resolveTypeId.ts +0 -16
- package/pages/crm/ContactDetailPage.tsx +0 -184
- package/pages/ops/AuditFunnelPage.tsx +0 -191
- package/pages/ops/CommandCenterPage.tsx +0 -377
- package/pages/ops/InstallFunnelPage.tsx +0 -226
- package/seed/accounts.json +0 -9
- package/seed/integrations.json +0 -24
- package/seed/pipelines.json +0 -59
- package/seed/triggers.json +0 -125
|
@@ -1,191 +0,0 @@
|
|
|
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 { ScanSearch, ChevronRight, Search } from 'lucide-react'
|
|
9
|
-
|
|
10
|
-
interface AuditAccount {
|
|
11
|
-
id: string
|
|
12
|
-
slug: string
|
|
13
|
-
display_name?: string
|
|
14
|
-
created_at: string
|
|
15
|
-
data?: {
|
|
16
|
-
temperature?: 'cold' | 'warm' | 'hot'
|
|
17
|
-
lifecycle_stage?: string
|
|
18
|
-
lead_score?: number
|
|
19
|
-
last_signal_at?: string
|
|
20
|
-
ratings?: Record<string, { rating: number; raw_score: number; calculated_at?: string }>
|
|
21
|
-
claim_status?: string
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function scoreBar(rating: number) {
|
|
26
|
-
const val = Math.round((rating / 5) * 100)
|
|
27
|
-
const color = val >= 80 ? 'bg-green-500' : val >= 50 ? 'bg-yellow-500' : 'bg-gray-300'
|
|
28
|
-
return (
|
|
29
|
-
<div className="flex items-center gap-2">
|
|
30
|
-
<div className="w-24 h-1.5 bg-muted rounded-full overflow-hidden">
|
|
31
|
-
<div className={`h-full rounded-full ${color}`} style={{ width: `${val}%` }} />
|
|
32
|
-
</div>
|
|
33
|
-
<span className="text-xs font-medium tabular-nums">{val}</span>
|
|
34
|
-
</div>
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function recommendedOffer(rating: number): string {
|
|
39
|
-
if (rating >= 5) return 'Paid Audit $5k'
|
|
40
|
-
if (rating >= 4) return 'Discovery Call'
|
|
41
|
-
if (rating >= 3) return 'Self-Audit'
|
|
42
|
-
return 'Content Nurture'
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function stageBadge(stage?: string) {
|
|
46
|
-
const map: Record<string, string> = {
|
|
47
|
-
anonymous: 'Anonymous',
|
|
48
|
-
identified: 'Identified',
|
|
49
|
-
installed: 'Installed',
|
|
50
|
-
}
|
|
51
|
-
return <Badge variant="secondary" className="text-xs capitalize">{map[stage || ''] || stage || '—'}</Badge>
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function temperatureBadge(temp?: string) {
|
|
55
|
-
if (temp === 'hot') return <Badge className="bg-red-100 text-red-700 border-red-200 text-xs">Hot</Badge>
|
|
56
|
-
if (temp === 'warm') return <Badge className="bg-yellow-100 text-yellow-700 border-yellow-200 text-xs">Warm</Badge>
|
|
57
|
-
return <Badge variant="outline" className="text-xs">Cold</Badge>
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export default function AuditFunnelPage() {
|
|
61
|
-
const navigate = useNavigate()
|
|
62
|
-
const [accounts, setAccounts] = useState<AuditAccount[]>([])
|
|
63
|
-
const [loading, setLoading] = useState(true)
|
|
64
|
-
const [search, setSearch] = useState('')
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
apiFetch('/api/admin-data?action=list&entity=accounts&limit=500')
|
|
68
|
-
.then(r => r.json())
|
|
69
|
-
.then(json => {
|
|
70
|
-
const all: AuditAccount[] = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []
|
|
71
|
-
// Keep accounts that have any audit funnel signal rating
|
|
72
|
-
const auditAccounts = all.filter(a =>
|
|
73
|
-
a.data?.ratings?.['funnel-ai-audit'] ||
|
|
74
|
-
a.data?.ratings?.['funnel-ai-audit-portal-signup'] ||
|
|
75
|
-
a.data?.ratings?.['funnel-ai-audit-hot']
|
|
76
|
-
)
|
|
77
|
-
// Sort by audit rating desc
|
|
78
|
-
auditAccounts.sort((a, b) => {
|
|
79
|
-
const ra = a.data?.ratings?.['funnel-ai-audit']?.rating || 0
|
|
80
|
-
const rb = b.data?.ratings?.['funnel-ai-audit']?.rating || 0
|
|
81
|
-
return rb - ra
|
|
82
|
-
})
|
|
83
|
-
setAccounts(auditAccounts)
|
|
84
|
-
})
|
|
85
|
-
.catch(() => setAccounts([]))
|
|
86
|
-
.finally(() => setLoading(false))
|
|
87
|
-
}, [])
|
|
88
|
-
|
|
89
|
-
const filtered = accounts.filter(a => {
|
|
90
|
-
const q = search.toLowerCase()
|
|
91
|
-
return !q || (a.display_name || a.slug || '').toLowerCase().includes(q)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
return (
|
|
95
|
-
<div className="p-6 space-y-6">
|
|
96
|
-
{/* Header */}
|
|
97
|
-
<div className="flex items-center justify-between">
|
|
98
|
-
<div>
|
|
99
|
-
<div className="flex items-center gap-2">
|
|
100
|
-
<ScanSearch className="h-5 w-5 text-muted-foreground" />
|
|
101
|
-
<h1 className="text-2xl font-bold">Audit Funnel</h1>
|
|
102
|
-
</div>
|
|
103
|
-
<p className="text-sm text-muted-foreground mt-0.5">
|
|
104
|
-
Accounts progressing through the AI readiness audit — from marketing visit to paid audit.
|
|
105
|
-
</p>
|
|
106
|
-
</div>
|
|
107
|
-
<Button size="sm" onClick={() => navigate('/cortex/ops/command-center')}>
|
|
108
|
-
Command Center <ChevronRight className="h-3.5 w-3.5 ml-1" />
|
|
109
|
-
</Button>
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
|
-
{/* Summary stats */}
|
|
113
|
-
<div className="grid grid-cols-4 gap-4">
|
|
114
|
-
{[
|
|
115
|
-
{ label: 'In Funnel', value: loading ? '…' : accounts.length },
|
|
116
|
-
{ label: 'Hot (score ≥4)', value: loading ? '…' : accounts.filter(a => (a.data?.ratings?.['funnel-ai-audit']?.rating || 0) >= 4).length },
|
|
117
|
-
{ label: 'Warm (score 3)', value: loading ? '…' : accounts.filter(a => (a.data?.ratings?.['funnel-ai-audit']?.rating || 0) === 3).length },
|
|
118
|
-
{ label: 'Identified', value: loading ? '…' : accounts.filter(a => a.data?.lifecycle_stage === 'identified').length },
|
|
119
|
-
].map(s => (
|
|
120
|
-
<div key={s.label} className="border rounded-lg p-4">
|
|
121
|
-
<div className="text-xs text-muted-foreground mb-1">{s.label}</div>
|
|
122
|
-
{loading ? <Skeleton className="h-7 w-12" /> : <div className="text-2xl font-bold">{s.value}</div>}
|
|
123
|
-
</div>
|
|
124
|
-
))}
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
{/* Table */}
|
|
128
|
-
<div className="border rounded-lg">
|
|
129
|
-
<div className="flex items-center justify-between px-4 py-3 border-b">
|
|
130
|
-
<span className="font-medium text-sm">Audit Prospects</span>
|
|
131
|
-
<div className="relative w-56">
|
|
132
|
-
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
133
|
-
<Input
|
|
134
|
-
className="h-7 pl-8 text-xs"
|
|
135
|
-
placeholder="Search accounts…"
|
|
136
|
-
value={search}
|
|
137
|
-
onChange={e => setSearch(e.target.value)}
|
|
138
|
-
/>
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
|
|
142
|
-
{loading ? (
|
|
143
|
-
<div className="p-4 space-y-3">
|
|
144
|
-
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
|
145
|
-
</div>
|
|
146
|
-
) : filtered.length === 0 ? (
|
|
147
|
-
<div className="p-10 text-center text-sm text-muted-foreground">
|
|
148
|
-
<ScanSearch className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
|
149
|
-
{search ? 'No accounts match your search.' : 'No audit funnel activity yet. Send signals with pipeline_slug: funnel-ai-audit to populate this view.'}
|
|
150
|
-
</div>
|
|
151
|
-
) : (
|
|
152
|
-
<>
|
|
153
|
-
{/* Header row */}
|
|
154
|
-
<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">
|
|
155
|
-
<span>Account</span>
|
|
156
|
-
<span>Signal Score</span>
|
|
157
|
-
<span>Stage</span>
|
|
158
|
-
<span>Last Signal</span>
|
|
159
|
-
<span>Recommended Offer</span>
|
|
160
|
-
<span>Status</span>
|
|
161
|
-
</div>
|
|
162
|
-
<div className="divide-y">
|
|
163
|
-
{filtered.map(a => {
|
|
164
|
-
const rating = a.data?.ratings?.['funnel-ai-audit']?.rating || 0
|
|
165
|
-
return (
|
|
166
|
-
<div
|
|
167
|
-
key={a.id}
|
|
168
|
-
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"
|
|
169
|
-
onClick={() => navigate(`/cortex/crm/accounts/${a.id}`)}
|
|
170
|
-
>
|
|
171
|
-
<div className="min-w-0">
|
|
172
|
-
<div className="font-medium truncate">{a.display_name || a.slug}</div>
|
|
173
|
-
<div className="text-xs text-muted-foreground truncate">{a.slug}</div>
|
|
174
|
-
</div>
|
|
175
|
-
<div>{scoreBar(rating)}</div>
|
|
176
|
-
<div>{stageBadge(a.data?.lifecycle_stage)}</div>
|
|
177
|
-
<div className="text-xs text-muted-foreground">
|
|
178
|
-
{a.data?.last_signal_at ? new Date(a.data.last_signal_at).toLocaleDateString() : '—'}
|
|
179
|
-
</div>
|
|
180
|
-
<div className="text-xs">{recommendedOffer(rating)}</div>
|
|
181
|
-
<div>{temperatureBadge(a.data?.temperature)}</div>
|
|
182
|
-
</div>
|
|
183
|
-
)
|
|
184
|
-
})}
|
|
185
|
-
</div>
|
|
186
|
-
</>
|
|
187
|
-
)}
|
|
188
|
-
</div>
|
|
189
|
-
</div>
|
|
190
|
-
)
|
|
191
|
-
}
|
|
@@ -1,377 +0,0 @@
|
|
|
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
|
-
}
|