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.
- package/components/CortexSidebar.tsx +130 -0
- package/functions/custom_anonymous-sessions.ts +356 -0
- package/functions/custom_case_analysis.ts +507 -0
- package/functions/custom_community-escalation.ts +234 -0
- package/functions/custom_cortex-chunks.ts +52 -0
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +678 -0
- package/functions/custom_funnel-timers.ts +449 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-chunker.ts +576 -0
- package/functions/custom_kb-embeddings.ts +481 -0
- package/functions/custom_kb-ingestion.ts +448 -0
- package/functions/custom_support-triage.ts +649 -0
- package/functions/custom_tag_management.ts +314 -0
- package/index.tsx +103 -0
- package/manifest.json +82 -0
- package/package.json +29 -0
- package/pages/CortexDashboard.tsx +97 -0
- package/pages/community/CommunityPage.tsx +159 -0
- package/pages/courses/CoursesPage.tsx +231 -0
- package/pages/crm/AccountDetailPage.tsx +393 -0
- package/pages/crm/AccountsPage.tsx +164 -0
- package/pages/crm/ActivityPage.tsx +82 -0
- package/pages/crm/ContactDetailPage.tsx +184 -0
- package/pages/crm/ContactsPage.tsx +87 -0
- package/pages/crm/DealDetailPage.tsx +191 -0
- package/pages/crm/DealsPage.tsx +169 -0
- package/pages/crm/HealthPage.tsx +109 -0
- package/pages/intelligence/IntelligencePage.tsx +314 -0
- package/pages/kb/KBEditorPage.tsx +328 -0
- package/pages/kb/KBIngestionPage.tsx +409 -0
- package/pages/kb/KBPage.tsx +258 -0
- package/pages/support/RedactionReview.tsx +562 -0
- package/pages/support/SupportPage.tsx +395 -0
- package/pages/support/TicketDetailPage.tsx +919 -0
- package/seed/accounts.json +9 -0
- package/seed/link-types.json +44 -0
- package/seed/triggers.json +80 -0
- 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
|
+
}
|