spine-framework-cortex 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/components/CortexSidebar.tsx +130 -0
  2. package/functions/custom_anonymous-sessions.ts +356 -0
  3. package/functions/custom_case_analysis.ts +507 -0
  4. package/functions/custom_community-escalation.ts +234 -0
  5. package/functions/custom_cortex-chunks.ts +52 -0
  6. package/functions/custom_cortex-handler.ts +35 -0
  7. package/functions/custom_funnel-scoring.ts +256 -0
  8. package/functions/custom_funnel-signal.ts +678 -0
  9. package/functions/custom_funnel-timers.ts +449 -0
  10. package/functions/custom_kb-chunker-test.ts +364 -0
  11. package/functions/custom_kb-chunker.ts +576 -0
  12. package/functions/custom_kb-embeddings.ts +481 -0
  13. package/functions/custom_kb-ingestion.ts +448 -0
  14. package/functions/custom_support-triage.ts +649 -0
  15. package/functions/custom_tag_management.ts +314 -0
  16. package/index.tsx +103 -0
  17. package/manifest.json +82 -0
  18. package/package.json +29 -0
  19. package/pages/CortexDashboard.tsx +97 -0
  20. package/pages/community/CommunityPage.tsx +159 -0
  21. package/pages/courses/CoursesPage.tsx +231 -0
  22. package/pages/crm/AccountDetailPage.tsx +393 -0
  23. package/pages/crm/AccountsPage.tsx +164 -0
  24. package/pages/crm/ActivityPage.tsx +82 -0
  25. package/pages/crm/ContactDetailPage.tsx +184 -0
  26. package/pages/crm/ContactsPage.tsx +87 -0
  27. package/pages/crm/DealDetailPage.tsx +191 -0
  28. package/pages/crm/DealsPage.tsx +169 -0
  29. package/pages/crm/HealthPage.tsx +109 -0
  30. package/pages/intelligence/IntelligencePage.tsx +314 -0
  31. package/pages/kb/KBEditorPage.tsx +328 -0
  32. package/pages/kb/KBIngestionPage.tsx +409 -0
  33. package/pages/kb/KBPage.tsx +258 -0
  34. package/pages/support/RedactionReview.tsx +562 -0
  35. package/pages/support/SupportPage.tsx +395 -0
  36. package/pages/support/TicketDetailPage.tsx +919 -0
  37. package/seed/accounts.json +9 -0
  38. package/seed/link-types.json +44 -0
  39. package/seed/triggers.json +80 -0
  40. package/seed/types.json +352 -0
@@ -0,0 +1,184 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useParams, useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Badge } from '@core/components/ui/badge'
5
+ import { Skeleton } from '@core/components/ui/skeleton'
6
+ import { Button } from '@core/components/ui/button'
7
+ import { Separator } from '@core/components/ui/separator'
8
+ import { ArrowLeft, User, Mail, Phone, Building2, Calendar } from 'lucide-react'
9
+
10
+ interface Person {
11
+ id: string
12
+ email?: string
13
+ full_name?: string
14
+ phone?: string
15
+ status?: string
16
+ avatar_url?: string
17
+ account_id?: string
18
+ data?: Record<string, any>
19
+ is_active?: boolean
20
+ created_at: string
21
+ updated_at?: string
22
+ }
23
+
24
+ interface Account {
25
+ id: string
26
+ slug?: string
27
+ display_name?: string
28
+ }
29
+
30
+ export default function ContactDetailPage() {
31
+ const { id } = useParams<{ id: string }>()
32
+ const navigate = useNavigate()
33
+ const [person, setPerson] = useState<Person | null>(null)
34
+ const [account, setAccount] = useState<Account | null>(null)
35
+ const [loading, setLoading] = useState(true)
36
+ const [error, setError] = useState<string | null>(null)
37
+
38
+ useEffect(() => {
39
+ if (!id) return
40
+ setLoading(true)
41
+ apiFetch(`/api/admin-data?action=get&entity=people&id=${id}`)
42
+ .then(r => r.json())
43
+ .then(json => {
44
+ const p = json?.data || json
45
+ if (!p?.id) throw new Error('Contact not found')
46
+ setPerson(p)
47
+ if (p.account_id) {
48
+ return apiFetch(`/api/admin-data?action=get&entity=accounts&id=${p.account_id}`)
49
+ .then(r2 => r2.json())
50
+ .then(aj => setAccount(aj?.data || aj || null))
51
+ }
52
+ })
53
+ .catch(err => setError(err.message || 'Failed to load contact'))
54
+ .finally(() => setLoading(false))
55
+ }, [id])
56
+
57
+ if (loading) {
58
+ return (
59
+ <div className="p-6 space-y-4">
60
+ <Skeleton className="h-8 w-48" />
61
+ <Skeleton className="h-64 w-full" />
62
+ </div>
63
+ )
64
+ }
65
+
66
+ if (error || !person) {
67
+ return (
68
+ <div className="p-6">
69
+ <Button variant="ghost" size="sm" onClick={() => navigate('/cortex/crm/contacts')}>
70
+ <ArrowLeft className="h-4 w-4 mr-2" /> Back to Contacts
71
+ </Button>
72
+ <div className="mt-8 text-center text-slate-400">{error || 'Contact not found'}</div>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ const initials = (person.full_name || '?')
78
+ .split(' ')
79
+ .map(w => w[0])
80
+ .join('')
81
+ .toUpperCase()
82
+ .slice(0, 2)
83
+
84
+ return (
85
+ <div className="p-6 space-y-6 max-w-4xl">
86
+ {/* Header */}
87
+ <div className="flex items-center gap-3">
88
+ <Button variant="ghost" size="icon" onClick={() => navigate('/cortex/crm/contacts')}>
89
+ <ArrowLeft className="h-4 w-4" />
90
+ </Button>
91
+ <div className="h-12 w-12 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-lg font-semibold shrink-0">
92
+ {initials}
93
+ </div>
94
+ <div className="flex-1 min-w-0">
95
+ <h1 className="text-2xl font-bold text-slate-900 truncate">{person.full_name || 'Unnamed Contact'}</h1>
96
+ <p className="text-sm text-slate-500">{person.email || '—'}</p>
97
+ </div>
98
+ <div className="flex items-center gap-2">
99
+ {person.status && (
100
+ <Badge variant={person.status === 'active' ? 'default' : 'secondary'}>{person.status}</Badge>
101
+ )}
102
+ {person.is_active === false && (
103
+ <Badge variant="destructive">Inactive</Badge>
104
+ )}
105
+ </div>
106
+ </div>
107
+
108
+ <Separator />
109
+
110
+ {/* Details grid */}
111
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
112
+ {/* Profile card */}
113
+ <div className="bg-white rounded-lg border border-slate-200 p-5 space-y-4">
114
+ <h2 className="text-sm font-semibold text-slate-700 uppercase tracking-wider">Profile</h2>
115
+
116
+ <div className="space-y-3">
117
+ <div className="flex items-center gap-3 text-sm">
118
+ <User className="h-4 w-4 text-slate-400 shrink-0" />
119
+ <span className="text-slate-500 w-20">Name</span>
120
+ <span className="text-slate-900 font-medium">{person.full_name || '—'}</span>
121
+ </div>
122
+
123
+ <div className="flex items-center gap-3 text-sm">
124
+ <Mail className="h-4 w-4 text-slate-400 shrink-0" />
125
+ <span className="text-slate-500 w-20">Email</span>
126
+ <span className="text-slate-900">{person.email || '—'}</span>
127
+ </div>
128
+
129
+ <div className="flex items-center gap-3 text-sm">
130
+ <Phone className="h-4 w-4 text-slate-400 shrink-0" />
131
+ <span className="text-slate-500 w-20">Phone</span>
132
+ <span className="text-slate-900">{person.phone || '—'}</span>
133
+ </div>
134
+
135
+ <div className="flex items-center gap-3 text-sm">
136
+ <Calendar className="h-4 w-4 text-slate-400 shrink-0" />
137
+ <span className="text-slate-500 w-20">Joined</span>
138
+ <span className="text-slate-900">{new Date(person.created_at).toLocaleDateString()}</span>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ {/* Account card */}
144
+ <div className="bg-white rounded-lg border border-slate-200 p-5 space-y-4">
145
+ <h2 className="text-sm font-semibold text-slate-700 uppercase tracking-wider">Account</h2>
146
+
147
+ {account ? (
148
+ <div
149
+ className="flex items-center gap-3 p-3 rounded-md hover:bg-slate-50 cursor-pointer transition-colors"
150
+ onClick={() => navigate(`/cortex/crm/accounts/${account.id}`)}
151
+ >
152
+ <div className="h-9 w-9 rounded-lg bg-slate-100 flex items-center justify-center shrink-0">
153
+ <Building2 className="h-4 w-4 text-slate-500" />
154
+ </div>
155
+ <div>
156
+ <p className="text-sm font-medium text-slate-900">{account.display_name || account.slug || '—'}</p>
157
+ {account.slug && <p className="text-xs text-slate-400">{account.slug}</p>}
158
+ </div>
159
+ </div>
160
+ ) : (
161
+ <p className="text-sm text-slate-400">No account linked</p>
162
+ )}
163
+ </div>
164
+ </div>
165
+
166
+ {/* Data section */}
167
+ {person.data && Object.keys(person.data).length > 0 && (
168
+ <div className="bg-white rounded-lg border border-slate-200 p-5 space-y-3">
169
+ <h2 className="text-sm font-semibold text-slate-700 uppercase tracking-wider">Additional Data</h2>
170
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
171
+ {Object.entries(person.data).map(([key, value]) => (
172
+ <div key={key} className="text-sm">
173
+ <span className="text-slate-500">{key.replace(/_/g, ' ')}: </span>
174
+ <span className="text-slate-900 font-medium">
175
+ {typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
176
+ </span>
177
+ </div>
178
+ ))}
179
+ </div>
180
+ </div>
181
+ )}
182
+ </div>
183
+ )
184
+ }
@@ -0,0 +1,87 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+
5
+ interface Person {
6
+ id: string
7
+ email: string
8
+ full_name?: string
9
+ account_id?: string
10
+ created_at: string
11
+ }
12
+
13
+ export default function ContactsPage() {
14
+ const navigate = useNavigate()
15
+ const [people, setPeople] = useState<Person[]>([])
16
+ const [loading, setLoading] = useState(true)
17
+ const [search, setSearch] = useState('')
18
+
19
+ useEffect(() => {
20
+ apiFetch('/api/admin-data?action=list&entity=people&limit=200')
21
+ .then(r => r.json())
22
+ .then(json => setPeople(Array.isArray(json?.data) ? json.data : json || []))
23
+ .catch(() => setPeople([]))
24
+ .finally(() => setLoading(false))
25
+ }, [])
26
+
27
+ const filtered = people.filter(p => {
28
+ const q = search.toLowerCase()
29
+ return !q || (p.email || '').toLowerCase().includes(q) ||
30
+ (p.full_name || '').toLowerCase().includes(q)
31
+ })
32
+
33
+ return (
34
+ <div className="p-6 space-y-5">
35
+ <div className="flex items-center justify-between">
36
+ <div>
37
+ <h1 className="text-2xl font-bold text-slate-900">Contacts</h1>
38
+ <p className="text-slate-500 text-sm mt-1">{people.length} contacts</p>
39
+ </div>
40
+ </div>
41
+
42
+ <div className="flex items-center gap-3">
43
+ <input
44
+ value={search}
45
+ onChange={e => setSearch(e.target.value)}
46
+ placeholder="Search contacts…"
47
+ className="w-72 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
48
+ />
49
+ </div>
50
+
51
+ <div className="bg-white rounded-lg border border-slate-200">
52
+ {loading ? (
53
+ <div className="p-8 text-center text-slate-400">Loading contacts…</div>
54
+ ) : (
55
+ <table className="w-full text-sm">
56
+ <thead>
57
+ <tr className="text-xs text-slate-500 uppercase tracking-wider border-b border-slate-100">
58
+ <th className="text-left px-5 py-3 font-medium">Name</th>
59
+ <th className="text-left px-5 py-3 font-medium">Email</th>
60
+ <th className="text-right px-5 py-3 font-medium">Joined</th>
61
+ </tr>
62
+ </thead>
63
+ <tbody className="divide-y divide-slate-100">
64
+ {filtered.length === 0 ? (
65
+ <tr>
66
+ <td colSpan={3} className="px-5 py-12 text-center text-slate-400">
67
+ {search ? 'No contacts match your search.' : 'No contacts yet.'}
68
+ </td>
69
+ </tr>
70
+ ) : filtered.map(person => (
71
+ <tr key={person.id} className="hover:bg-slate-50 cursor-pointer" onClick={() => navigate(`/cortex/crm/contacts/${person.id}`)}>
72
+ <td className="px-5 py-3 font-medium text-slate-900">
73
+ {person.full_name || '—'}
74
+ </td>
75
+ <td className="px-5 py-3 text-slate-600">{person.email}</td>
76
+ <td className="px-5 py-3 text-right text-slate-400 text-xs">
77
+ {new Date(person.created_at).toLocaleDateString()}
78
+ </td>
79
+ </tr>
80
+ ))}
81
+ </tbody>
82
+ </table>
83
+ )}
84
+ </div>
85
+ </div>
86
+ )
87
+ }
@@ -0,0 +1,191 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate, useParams } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+
5
+ const STAGES = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost']
6
+ const SOURCES = ['inbound', 'outbound', 'referral', 'event', 'paid', 'organic']
7
+
8
+ interface DealForm {
9
+ title: string
10
+ stage: string
11
+ value: string
12
+ close_date: string
13
+ probability: string
14
+ source: string
15
+ }
16
+
17
+ const EMPTY: DealForm = { title: '', stage: 'prospecting', value: '', close_date: '', probability: '', source: '' }
18
+
19
+ export default function DealDetailPage() {
20
+ const { id } = useParams<{ id: string }>()
21
+ const navigate = useNavigate()
22
+ const isNew = !id || id === 'new'
23
+
24
+ const [form, setForm] = useState<DealForm>(EMPTY)
25
+ const [loading, setLoading] = useState(!isNew)
26
+ const [saving, setSaving] = useState(false)
27
+ const [error, setError] = useState<string | null>(null)
28
+
29
+ useEffect(() => {
30
+ if (isNew) return
31
+ apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`)
32
+ .then(r => r.json())
33
+ .then(raw => {
34
+ const json = raw?.data ?? raw
35
+ if (json) {
36
+ setForm({
37
+ title: json.title || '',
38
+ stage: json.data?.stage || 'prospecting',
39
+ value: json.data?.value?.toString() || '',
40
+ close_date: json.data?.close_date || '',
41
+ probability: json.data?.probability?.toString() || '',
42
+ source: json.data?.source || '',
43
+ })
44
+ }
45
+ })
46
+ .catch(() => setError('Failed to load deal'))
47
+ .finally(() => setLoading(false))
48
+ }, [id, isNew])
49
+
50
+ const handleSave = async () => {
51
+ if (!form.title.trim()) { setError('Title is required'); return }
52
+ setSaving(true)
53
+ setError(null)
54
+ try {
55
+ const payload = {
56
+ title: form.title.trim(),
57
+ type_slug: 'deal',
58
+ data: {
59
+ stage: form.stage,
60
+ value: form.value ? Number(form.value) : null,
61
+ close_date: form.close_date || null,
62
+ probability: form.probability ? Number(form.probability) : null,
63
+ source: form.source || null,
64
+ },
65
+ }
66
+ let res: Response
67
+ if (isNew) {
68
+ res = await apiFetch('/api/admin-data?action=create&entity=items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
69
+ } else {
70
+ res = await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
71
+ }
72
+ if (!res.ok) { const e = await res.json(); throw new Error(e?.error || 'Save failed') }
73
+ const saved = await res.json()
74
+ navigate(`/crm/deals/${isNew ? saved.id : id}`)
75
+ } catch (e: any) {
76
+ setError(e.message)
77
+ } finally {
78
+ setSaving(false)
79
+ }
80
+ }
81
+
82
+ const handleDelete = async () => {
83
+ if (!confirm('Delete this deal?')) return
84
+ await apiFetch(`/api/admin-data?action=delete&entity=items&id=${id}`, { method: 'POST' })
85
+ navigate('/cortex/crm/deals')
86
+ }
87
+
88
+ const set = (field: keyof DealForm) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
89
+ setForm(f => ({ ...f, [field]: e.target.value }))
90
+
91
+ if (loading) return <div className="p-8 text-center text-slate-400">Loading…</div>
92
+
93
+ return (
94
+ <div className="p-6 max-w-2xl">
95
+ <div className="flex items-center gap-3 mb-6">
96
+ <button onClick={() => navigate('/cortex/crm/deals')} className="text-slate-400 hover:text-slate-700 text-sm">
97
+ ← Deals
98
+ </button>
99
+ <h1 className="text-xl font-bold text-slate-900">{isNew ? 'New Deal' : 'Edit Deal'}</h1>
100
+ </div>
101
+
102
+ {error && <div className="mb-4 px-4 py-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">{error}</div>}
103
+
104
+ <div className="bg-white rounded-lg border border-slate-200 divide-y divide-slate-100">
105
+ <div className="p-5 space-y-4">
106
+ <h2 className="font-semibold text-slate-700 text-sm">Deal Info</h2>
107
+ <div>
108
+ <label className="block text-xs font-medium text-slate-600 mb-1">Title *</label>
109
+ <input
110
+ value={form.title}
111
+ onChange={set('title')}
112
+ placeholder="Deal name"
113
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
114
+ />
115
+ </div>
116
+ <div className="grid grid-cols-2 gap-4">
117
+ <div>
118
+ <label className="block text-xs font-medium text-slate-600 mb-1">Stage</label>
119
+ <select value={form.stage} onChange={set('stage')} className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
120
+ {STAGES.map(s => <option key={s} value={s}>{s.replace('_', ' ')}</option>)}
121
+ </select>
122
+ </div>
123
+ <div>
124
+ <label className="block text-xs font-medium text-slate-600 mb-1">Source</label>
125
+ <select value={form.source} onChange={set('source')} className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
126
+ <option value="">— Select source —</option>
127
+ {SOURCES.map(s => <option key={s} value={s}>{s}</option>)}
128
+ </select>
129
+ </div>
130
+ </div>
131
+ </div>
132
+
133
+ <div className="p-5 space-y-4">
134
+ <h2 className="font-semibold text-slate-700 text-sm">Financials</h2>
135
+ <div className="grid grid-cols-2 gap-4">
136
+ <div>
137
+ <label className="block text-xs font-medium text-slate-600 mb-1">Deal Value (USD)</label>
138
+ <input
139
+ type="number"
140
+ value={form.value}
141
+ onChange={set('value')}
142
+ placeholder="0"
143
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
144
+ />
145
+ </div>
146
+ <div>
147
+ <label className="block text-xs font-medium text-slate-600 mb-1">Probability %</label>
148
+ <input
149
+ type="number"
150
+ value={form.probability}
151
+ onChange={set('probability')}
152
+ placeholder="0–100"
153
+ min="0" max="100"
154
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
155
+ />
156
+ </div>
157
+ </div>
158
+ <div>
159
+ <label className="block text-xs font-medium text-slate-600 mb-1">Expected Close Date</label>
160
+ <input
161
+ type="date"
162
+ value={form.close_date}
163
+ onChange={set('close_date')}
164
+ className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
165
+ />
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <div className="flex items-center justify-between mt-6">
171
+ {!isNew ? (
172
+ <button onClick={handleDelete} className="text-sm text-red-600 hover:text-red-700 hover:underline">
173
+ Delete deal
174
+ </button>
175
+ ) : <div />}
176
+ <div className="flex gap-3">
177
+ <button onClick={() => navigate('/cortex/crm/deals')} className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50">
178
+ Cancel
179
+ </button>
180
+ <button
181
+ onClick={handleSave}
182
+ disabled={saving}
183
+ className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
184
+ >
185
+ {saving ? 'Saving…' : isNew ? 'Create Deal' : 'Save Changes'}
186
+ </button>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ )
191
+ }
@@ -0,0 +1,169 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { apiFetch } from '@core/lib/api'
4
+
5
+ interface Deal {
6
+ id: string
7
+ title: string
8
+ data: { stage?: string; value?: number; close_date?: string; probability?: number; source?: string }
9
+ created_at: string
10
+ }
11
+
12
+ const STAGES = ['prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost']
13
+
14
+ const STAGE_STYLE: Record<string, { bg: string; header: string; badge: string }> = {
15
+ prospecting: { bg: 'bg-slate-50', header: 'bg-slate-100', badge: 'bg-slate-200 text-slate-700' },
16
+ qualification: { bg: 'bg-blue-50', header: 'bg-blue-100', badge: 'bg-blue-200 text-blue-700' },
17
+ proposal: { bg: 'bg-yellow-50', header: 'bg-yellow-100', badge: 'bg-yellow-200 text-yellow-700' },
18
+ negotiation: { bg: 'bg-orange-50', header: 'bg-orange-100', badge: 'bg-orange-200 text-orange-700' },
19
+ closed_won: { bg: 'bg-green-50', header: 'bg-green-100', badge: 'bg-green-200 text-green-700' },
20
+ closed_lost: { bg: 'bg-red-50', header: 'bg-red-100', badge: 'bg-red-200 text-red-700' },
21
+ }
22
+
23
+ function DealCard({ deal, onClick }: { deal: Deal; onClick: () => void }) {
24
+ return (
25
+ <div
26
+ onClick={onClick}
27
+ className="bg-white border border-slate-200 rounded-lg p-3 cursor-pointer hover:shadow-sm hover:border-blue-300 transition-all"
28
+ >
29
+ <div className="font-medium text-slate-900 text-sm mb-1 truncate">{deal.title}</div>
30
+ {deal.data?.value != null && (
31
+ <div className="text-green-700 font-semibold text-sm">${deal.data.value.toLocaleString()}</div>
32
+ )}
33
+ <div className="flex items-center justify-between mt-2 text-xs text-slate-400">
34
+ <span>{deal.data?.source || '—'}</span>
35
+ <span>{deal.data?.close_date || ''}</span>
36
+ </div>
37
+ </div>
38
+ )
39
+ }
40
+
41
+ export default function DealsPage() {
42
+ const navigate = useNavigate()
43
+ const [deals, setDeals] = useState<Deal[]>([])
44
+ const [loading, setLoading] = useState(true)
45
+ const [view, setView] = useState<'kanban' | 'list'>('kanban')
46
+
47
+ useEffect(() => {
48
+ apiFetch('/api/admin-data?action=list&entity=items&type_slug=deal&limit=200')
49
+ .then(r => r.json())
50
+ .then(json => setDeals(Array.isArray(json?.data) ? json.data : json || []))
51
+ .catch(() => setDeals([]))
52
+ .finally(() => setLoading(false))
53
+ }, [])
54
+
55
+ const byStage = (stage: string) => deals.filter(d => (d.data?.stage || 'prospecting') === stage)
56
+
57
+ return (
58
+ <div className="p-6 space-y-5">
59
+ <div className="flex items-center justify-between">
60
+ <div>
61
+ <h1 className="text-2xl font-bold text-slate-900">Deals</h1>
62
+ <p className="text-slate-500 text-sm mt-1">{deals.length} deals across {STAGES.length} stages</p>
63
+ </div>
64
+ <div className="flex items-center gap-3">
65
+ <div className="flex bg-slate-100 rounded-lg p-1 text-sm">
66
+ <button
67
+ onClick={() => setView('kanban')}
68
+ className={`px-3 py-1 rounded-md font-medium transition-colors ${view === 'kanban' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
69
+ >
70
+ Kanban
71
+ </button>
72
+ <button
73
+ onClick={() => setView('list')}
74
+ className={`px-3 py-1 rounded-md font-medium transition-colors ${view === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
75
+ >
76
+ List
77
+ </button>
78
+ </div>
79
+ <button
80
+ onClick={() => navigate('/cortex/crm/deals/new')}
81
+ className="bg-blue-600 text-white text-sm font-medium px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
82
+ >
83
+ + New Deal
84
+ </button>
85
+ </div>
86
+ </div>
87
+
88
+ {loading ? (
89
+ <div className="text-center py-16 text-slate-400">Loading deals…</div>
90
+ ) : view === 'kanban' ? (
91
+ <div className="flex gap-3 overflow-x-auto pb-4">
92
+ {STAGES.map(stage => {
93
+ const s = STAGE_STYLE[stage]
94
+ const stageDeals = byStage(stage)
95
+ const stageValue = stageDeals.reduce((sum, d) => sum + (d.data?.value || 0), 0)
96
+ return (
97
+ <div key={stage} className={`flex-shrink-0 w-56 rounded-xl ${s.bg} flex flex-col`}>
98
+ <div className={`${s.header} rounded-t-xl px-3 py-2`}>
99
+ <div className="font-semibold text-slate-800 text-xs uppercase tracking-wide">
100
+ {stage.replace('_', ' ')}
101
+ </div>
102
+ <div className="text-xs text-slate-500 mt-0.5">
103
+ {stageDeals.length} · {stageValue ? `$${(stageValue / 1000).toFixed(0)}k` : '$0'}
104
+ </div>
105
+ </div>
106
+ <div className="flex-1 p-2 space-y-2 min-h-16">
107
+ {stageDeals.map(deal => (
108
+ <DealCard
109
+ key={deal.id}
110
+ deal={deal}
111
+ onClick={() => navigate(`/crm/deals/${deal.id}`)}
112
+ />
113
+ ))}
114
+ </div>
115
+ </div>
116
+ )
117
+ })}
118
+ </div>
119
+ ) : (
120
+ <div className="bg-white rounded-lg border border-slate-200">
121
+ <table className="w-full text-sm">
122
+ <thead>
123
+ <tr className="text-xs text-slate-500 uppercase tracking-wider border-b border-slate-100">
124
+ <th className="text-left px-5 py-3 font-medium">Deal</th>
125
+ <th className="text-left px-5 py-3 font-medium">Stage</th>
126
+ <th className="text-right px-5 py-3 font-medium">Value</th>
127
+ <th className="text-left px-5 py-3 font-medium">Source</th>
128
+ <th className="text-right px-5 py-3 font-medium">Close Date</th>
129
+ </tr>
130
+ </thead>
131
+ <tbody className="divide-y divide-slate-100">
132
+ {deals.length === 0 ? (
133
+ <tr>
134
+ <td colSpan={5} className="px-5 py-12 text-center text-slate-400">
135
+ No deals yet.{' '}
136
+ <button onClick={() => navigate('/cortex/crm/deals/new')} className="text-blue-600 hover:underline">
137
+ Create one →
138
+ </button>
139
+ </td>
140
+ </tr>
141
+ ) : deals.map(deal => {
142
+ const s = STAGE_STYLE[deal.data?.stage || 'prospecting']
143
+ return (
144
+ <tr
145
+ key={deal.id}
146
+ onClick={() => navigate(`/crm/deals/${deal.id}`)}
147
+ className="hover:bg-slate-50 cursor-pointer"
148
+ >
149
+ <td className="px-5 py-3 font-medium text-slate-900">{deal.title}</td>
150
+ <td className="px-5 py-3">
151
+ <span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${s.badge}`}>
152
+ {(deal.data?.stage || 'prospecting').replace('_', ' ')}
153
+ </span>
154
+ </td>
155
+ <td className="px-5 py-3 text-right text-slate-700">
156
+ {deal.data?.value ? `$${deal.data.value.toLocaleString()}` : '—'}
157
+ </td>
158
+ <td className="px-5 py-3 text-slate-500">{deal.data?.source || '—'}</td>
159
+ <td className="px-5 py-3 text-right text-slate-500">{deal.data?.close_date || '—'}</td>
160
+ </tr>
161
+ )
162
+ })}
163
+ </tbody>
164
+ </table>
165
+ </div>
166
+ )}
167
+ </div>
168
+ )
169
+ }