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,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
|
+
}
|