@nextsparkjs/theme-crm 0.1.0-beta.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/CRM_PLAN.md +343 -0
- package/about.md +122 -0
- package/config/app.config.ts +185 -0
- package/config/billing.config.ts +187 -0
- package/config/dashboard.config.ts +372 -0
- package/config/dev.config.ts +55 -0
- package/config/features.config.ts +336 -0
- package/config/flows.config.ts +511 -0
- package/config/permissions.config.ts +297 -0
- package/config/theme.config.ts +111 -0
- package/entities/activities/activities.config.ts +61 -0
- package/entities/activities/activities.fields.ts +362 -0
- package/entities/activities/activities.service.ts +503 -0
- package/entities/activities/activities.types.ts +117 -0
- package/entities/activities/messages/en.json +123 -0
- package/entities/activities/messages/es.json +123 -0
- package/entities/activities/migrations/020_activities_table.sql +123 -0
- package/entities/activities/migrations/021_activities_metas.sql +114 -0
- package/entities/activities/migrations/022_activities_sample_data.sql +420 -0
- package/entities/campaigns/campaigns.config.ts +61 -0
- package/entities/campaigns/campaigns.fields.ts +413 -0
- package/entities/campaigns/campaigns.service.ts +426 -0
- package/entities/campaigns/campaigns.types.ts +124 -0
- package/entities/campaigns/messages/en.json +145 -0
- package/entities/campaigns/messages/es.json +145 -0
- package/entities/campaigns/migrations/001_campaigns_table.sql +127 -0
- package/entities/campaigns/migrations/002_campaigns_metas.sql +114 -0
- package/entities/campaigns/migrations/003_campaigns_sample_data.sql +364 -0
- package/entities/companies/companies.config.ts +61 -0
- package/entities/companies/companies.fields.ts +429 -0
- package/entities/companies/companies.service.ts +566 -0
- package/entities/companies/companies.types.ts +125 -0
- package/entities/companies/messages/en.json +146 -0
- package/entities/companies/messages/es.json +146 -0
- package/entities/companies/migrations/001_companies_table.sql +150 -0
- package/entities/companies/migrations/002_companies_metas.sql +114 -0
- package/entities/companies/migrations/003_companies_sample_data.sql +246 -0
- package/entities/contacts/contacts.config.ts +61 -0
- package/entities/contacts/contacts.fields.ts +359 -0
- package/entities/contacts/contacts.service.ts +509 -0
- package/entities/contacts/contacts.types.ts +108 -0
- package/entities/contacts/messages/en.json +117 -0
- package/entities/contacts/messages/es.json +117 -0
- package/entities/contacts/migrations/001_contacts_table.sql +134 -0
- package/entities/contacts/migrations/002_contacts_metas.sql +114 -0
- package/entities/contacts/migrations/003_contacts_sample_data.sql +421 -0
- package/entities/leads/leads.config.ts +61 -0
- package/entities/leads/leads.fields.ts +336 -0
- package/entities/leads/leads.service.ts +496 -0
- package/entities/leads/leads.types.ts +114 -0
- package/entities/leads/messages/en.json +132 -0
- package/entities/leads/messages/es.json +132 -0
- package/entities/leads/migrations/001_leads_table.sql +150 -0
- package/entities/leads/migrations/002_leads_metas.sql +120 -0
- package/entities/leads/migrations/003_leads_sample_data.sql +242 -0
- package/entities/notes/messages/en.json +114 -0
- package/entities/notes/messages/es.json +114 -0
- package/entities/notes/migrations/020_notes_table.sql +118 -0
- package/entities/notes/migrations/021_notes_metas.sql +114 -0
- package/entities/notes/migrations/022_notes_sample_data.sql +275 -0
- package/entities/notes/notes.config.ts +61 -0
- package/entities/notes/notes.fields.ts +283 -0
- package/entities/notes/notes.service.ts +320 -0
- package/entities/notes/notes.types.ts +102 -0
- package/entities/opportunities/messages/en.json +107 -0
- package/entities/opportunities/messages/es.json +107 -0
- package/entities/opportunities/migrations/010_opportunities_table.sql +145 -0
- package/entities/opportunities/migrations/011_opportunities_metas.sql +114 -0
- package/entities/opportunities/migrations/012_opportunities_sample_data.sql +438 -0
- package/entities/opportunities/opportunities.config.ts +61 -0
- package/entities/opportunities/opportunities.fields.ts +416 -0
- package/entities/opportunities/opportunities.service.ts +525 -0
- package/entities/opportunities/opportunities.types.ts +135 -0
- package/entities/pipelines/messages/en.json +115 -0
- package/entities/pipelines/messages/es.json +115 -0
- package/entities/pipelines/migrations/001_pipelines_table.sql +106 -0
- package/entities/pipelines/migrations/002_pipelines_metas.sql +114 -0
- package/entities/pipelines/migrations/003_pipelines_sample_data.sql +91 -0
- package/entities/pipelines/pipelines.config.ts +62 -0
- package/entities/pipelines/pipelines.fields.ts +193 -0
- package/entities/pipelines/pipelines.service.ts +383 -0
- package/entities/pipelines/pipelines.types.ts +78 -0
- package/entities/products/messages/en.json +135 -0
- package/entities/products/messages/es.json +135 -0
- package/entities/products/migrations/001_products_table.sql +117 -0
- package/entities/products/migrations/002_products_metas.sql +114 -0
- package/entities/products/migrations/003_products_sample_data.sql +247 -0
- package/entities/products/products.config.ts +62 -0
- package/entities/products/products.fields.ts +361 -0
- package/entities/products/products.service.ts +437 -0
- package/entities/products/products.types.ts +125 -0
- package/lib/crm-constants.ts +77 -0
- package/lib/crm-utils.ts +185 -0
- package/lib/selectors.ts +333 -0
- package/messages/en.json +131 -0
- package/messages/es.json +131 -0
- package/migrations/999_theme_sample_data.sql +473 -0
- package/package.json +18 -0
- package/pendings.md +205 -0
- package/permissions-matrix.md +216 -0
- package/styles/components.css +414 -0
- package/styles/crm-theme.css +358 -0
- package/styles/globals.css +576 -0
- package/styles/variables.css +111 -0
- package/templates/dashboard/(main)/activities/components/ActivityCard.tsx +169 -0
- package/templates/dashboard/(main)/activities/components/ActivityTimeline.tsx +165 -0
- package/templates/dashboard/(main)/activities/page.tsx +297 -0
- package/templates/dashboard/(main)/campaigns/page.tsx +373 -0
- package/templates/dashboard/(main)/companies/page.tsx +296 -0
- package/templates/dashboard/(main)/contacts/page.tsx +347 -0
- package/templates/dashboard/(main)/layout.tsx +98 -0
- package/templates/dashboard/(main)/leads/page.tsx +335 -0
- package/templates/dashboard/(main)/opportunities/[id]/edit/page.tsx +95 -0
- package/templates/dashboard/(main)/opportunities/create/page.tsx +94 -0
- package/templates/dashboard/(main)/opportunities/page.tsx +350 -0
- package/templates/dashboard/(main)/pipelines/[id]/edit/page.tsx +95 -0
- package/templates/dashboard/(main)/pipelines/[id]/page.tsx +143 -0
- package/templates/dashboard/(main)/pipelines/create/page.tsx +94 -0
- package/templates/dashboard/(main)/pipelines/page.tsx +234 -0
- package/templates/dashboard/(main)/products/[id]/edit/page.tsx +97 -0
- package/templates/dashboard/(main)/products/[id]/page.tsx +509 -0
- package/templates/dashboard/(main)/products/create/page.tsx +96 -0
- package/templates/dashboard/(main)/products/page.tsx +308 -0
- package/templates/shared/ActionButtons.tsx +41 -0
- package/templates/shared/CRMDashboard.tsx +519 -0
- package/templates/shared/CRMDataTable.tsx +441 -0
- package/templates/shared/CRMMetricCard.tsx +76 -0
- package/templates/shared/CRMMobileNav.tsx +172 -0
- package/templates/shared/CRMSidebar.tsx +346 -0
- package/templates/shared/CRMTopBar.tsx +265 -0
- package/templates/shared/DealCard.tsx +123 -0
- package/templates/shared/EntityCard.tsx +58 -0
- package/templates/shared/OpportunityForm.tsx +649 -0
- package/templates/shared/PipelineForm.tsx +367 -0
- package/templates/shared/PipelineKanban.tsx +194 -0
- package/templates/shared/QuickFilters.tsx +47 -0
- package/templates/shared/StageColumn.tsx +175 -0
- package/templates/shared/StageSelect.tsx +177 -0
- package/templates/shared/StagesRepeater.tsx +317 -0
- package/templates/shared/index.ts +9 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leads Page
|
|
3
|
+
* Professional leads management with data table and bulk actions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { useRouter } from 'next/navigation'
|
|
9
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
10
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
11
|
+
import { CRMDataTable, type Column, type BulkAction } from '@/themes/crm/templates/shared/CRMDataTable'
|
|
12
|
+
import {
|
|
13
|
+
Plus,
|
|
14
|
+
UserPlus,
|
|
15
|
+
Flame,
|
|
16
|
+
ThermometerSun,
|
|
17
|
+
Snowflake,
|
|
18
|
+
Trash2,
|
|
19
|
+
Download,
|
|
20
|
+
ArrowRightCircle,
|
|
21
|
+
Mail,
|
|
22
|
+
Phone,
|
|
23
|
+
Building2,
|
|
24
|
+
Calendar
|
|
25
|
+
} from 'lucide-react'
|
|
26
|
+
import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
|
|
27
|
+
import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
|
|
28
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
29
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
30
|
+
|
|
31
|
+
interface Lead {
|
|
32
|
+
id: string
|
|
33
|
+
firstName: string
|
|
34
|
+
lastName: string
|
|
35
|
+
email: string
|
|
36
|
+
phone?: string
|
|
37
|
+
companyName?: string
|
|
38
|
+
title?: string
|
|
39
|
+
source?: string
|
|
40
|
+
status: 'new' | 'contacted' | 'qualified' | 'converted' | 'lost'
|
|
41
|
+
score?: number
|
|
42
|
+
createdAt: string
|
|
43
|
+
updatedAt: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Score badge component
|
|
47
|
+
function LeadScoreBadge({ score }: { score?: number }) {
|
|
48
|
+
if (!score) return <span className="text-muted-foreground">-</span>
|
|
49
|
+
|
|
50
|
+
let Icon = Snowflake
|
|
51
|
+
let bgClass = 'bg-muted'
|
|
52
|
+
let textClass = 'text-muted-foreground'
|
|
53
|
+
let label = 'Cold'
|
|
54
|
+
|
|
55
|
+
if (score >= 80) {
|
|
56
|
+
Icon = Flame
|
|
57
|
+
bgClass = 'bg-destructive/10'
|
|
58
|
+
textClass = 'text-destructive'
|
|
59
|
+
label = 'Hot'
|
|
60
|
+
} else if (score >= 50) {
|
|
61
|
+
Icon = ThermometerSun
|
|
62
|
+
bgClass = 'bg-amber-500/10'
|
|
63
|
+
textClass = 'text-amber-600'
|
|
64
|
+
label = 'Warm'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={cn('inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium', bgClass, textClass)}>
|
|
69
|
+
<Icon className="w-3 h-3" />
|
|
70
|
+
<span>{score}</span>
|
|
71
|
+
<span className="opacity-70">({label})</span>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Status badge component
|
|
77
|
+
function StatusBadge({ status }: { status: Lead['status'] }) {
|
|
78
|
+
const config = {
|
|
79
|
+
new: { label: 'New', className: 'bg-primary/10 text-primary' },
|
|
80
|
+
contacted: { label: 'Contacted', className: 'bg-amber-500/10 text-amber-600' },
|
|
81
|
+
qualified: { label: 'Qualified', className: 'bg-emerald-500/10 text-emerald-600' },
|
|
82
|
+
converted: { label: 'Converted', className: 'bg-violet-500/10 text-violet-600' },
|
|
83
|
+
lost: { label: 'Lost', className: 'bg-muted text-muted-foreground' },
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { label, className } = config[status] || config.new
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<span className={cn('px-2.5 py-1 rounded-md text-xs font-medium', className)}>
|
|
90
|
+
{label}
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function LeadsPage() {
|
|
96
|
+
const router = useRouter()
|
|
97
|
+
const { currentTeam, isLoading: teamLoading } = useTeamContext()
|
|
98
|
+
const [leads, setLeads] = useState<Lead[]>([])
|
|
99
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
100
|
+
|
|
101
|
+
// Permission checks for bulk actions
|
|
102
|
+
const canDeleteLeads = usePermission('leads.delete')
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (teamLoading || !currentTeam) return
|
|
106
|
+
|
|
107
|
+
async function fetchLeads() {
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetchWithTeam('/api/v1/leads')
|
|
110
|
+
if (!response.ok) throw new Error('Failed to fetch leads')
|
|
111
|
+
const result = await response.json()
|
|
112
|
+
setLeads(result.data || [])
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error('Error loading leads:', error)
|
|
115
|
+
} finally {
|
|
116
|
+
setIsLoading(false)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fetchLeads()
|
|
121
|
+
}, [teamLoading, currentTeam])
|
|
122
|
+
|
|
123
|
+
// Stats
|
|
124
|
+
const stats = useMemo(() => ({
|
|
125
|
+
total: leads.length,
|
|
126
|
+
new: leads.filter(l => l.status === 'new').length,
|
|
127
|
+
qualified: leads.filter(l => l.status === 'qualified').length,
|
|
128
|
+
hot: leads.filter(l => (l.score || 0) >= 80).length,
|
|
129
|
+
}), [leads])
|
|
130
|
+
|
|
131
|
+
// Column definitions
|
|
132
|
+
const columns: Column<Lead>[] = [
|
|
133
|
+
{
|
|
134
|
+
key: 'name',
|
|
135
|
+
header: 'Name',
|
|
136
|
+
sortable: true,
|
|
137
|
+
render: (_, lead) => (
|
|
138
|
+
<div className="flex items-center gap-3">
|
|
139
|
+
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
|
140
|
+
{lead.firstName?.[0]?.toUpperCase()}{lead.lastName?.[0]?.toUpperCase()}
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<p className="font-medium text-foreground">
|
|
144
|
+
{lead.firstName} {lead.lastName}
|
|
145
|
+
</p>
|
|
146
|
+
{lead.title && (
|
|
147
|
+
<p className="text-xs text-muted-foreground">{lead.title}</p>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
key: 'contact',
|
|
155
|
+
header: 'Contact',
|
|
156
|
+
render: (_, lead) => (
|
|
157
|
+
<div className="space-y-1">
|
|
158
|
+
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
159
|
+
<Mail className="w-3.5 h-3.5" />
|
|
160
|
+
<span className="truncate max-w-[180px]">{lead.email}</span>
|
|
161
|
+
</div>
|
|
162
|
+
{lead.phone && (
|
|
163
|
+
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
164
|
+
<Phone className="w-3.5 h-3.5" />
|
|
165
|
+
<span>{lead.phone}</span>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
key: 'companyName',
|
|
173
|
+
header: 'Company',
|
|
174
|
+
sortable: true,
|
|
175
|
+
render: (value) => value ? (
|
|
176
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
177
|
+
<Building2 className="w-3.5 h-3.5 text-muted-foreground" />
|
|
178
|
+
<span>{value}</span>
|
|
179
|
+
</div>
|
|
180
|
+
) : <span className="text-muted-foreground">-</span>,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
key: 'score',
|
|
184
|
+
header: 'Score',
|
|
185
|
+
sortable: true,
|
|
186
|
+
render: (value) => <LeadScoreBadge score={value} />,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
key: 'status',
|
|
190
|
+
header: 'Status',
|
|
191
|
+
sortable: true,
|
|
192
|
+
render: (value) => <StatusBadge status={value} />,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
key: 'createdAt',
|
|
196
|
+
header: 'Created',
|
|
197
|
+
sortable: true,
|
|
198
|
+
render: (value) => (
|
|
199
|
+
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
200
|
+
<Calendar className="w-3.5 h-3.5" />
|
|
201
|
+
<span>{new Date(value).toLocaleDateString()}</span>
|
|
202
|
+
</div>
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
// Bulk actions - filtered by permissions
|
|
208
|
+
const bulkActions: BulkAction[] = [
|
|
209
|
+
{
|
|
210
|
+
id: 'export',
|
|
211
|
+
label: 'Export',
|
|
212
|
+
icon: <Download className="w-4 h-4" />,
|
|
213
|
+
onClick: (ids) => {
|
|
214
|
+
console.log('Export leads:', ids)
|
|
215
|
+
// Implement export logic
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: 'convert',
|
|
220
|
+
label: 'Convert',
|
|
221
|
+
icon: <ArrowRightCircle className="w-4 h-4" />,
|
|
222
|
+
onClick: (ids) => {
|
|
223
|
+
console.log('Convert leads:', ids)
|
|
224
|
+
// Implement convert logic
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
// Only show delete action if user has permission
|
|
228
|
+
...(canDeleteLeads ? [{
|
|
229
|
+
id: 'delete',
|
|
230
|
+
label: 'Delete',
|
|
231
|
+
icon: <Trash2 className="w-4 h-4" />,
|
|
232
|
+
variant: 'destructive' as const,
|
|
233
|
+
onClick: async (ids: string[]) => {
|
|
234
|
+
if (confirm(`Delete ${ids.length} lead(s)?`)) {
|
|
235
|
+
console.log('Delete leads:', ids)
|
|
236
|
+
// Implement delete logic
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}] : []),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
const handleRowClick = (lead: Lead) => {
|
|
243
|
+
router.push(`/dashboard/leads/${lead.id}`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const handleAddLead = () => {
|
|
247
|
+
router.push('/dashboard/leads/create')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="p-6 space-y-6">
|
|
252
|
+
{/* Header */}
|
|
253
|
+
<div className="flex items-start justify-between">
|
|
254
|
+
<div>
|
|
255
|
+
<h1 className="text-2xl font-bold text-foreground tracking-tight">
|
|
256
|
+
Leads
|
|
257
|
+
</h1>
|
|
258
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
259
|
+
Manage and qualify your sales leads
|
|
260
|
+
</p>
|
|
261
|
+
</div>
|
|
262
|
+
<Button onClick={handleAddLead} className="gap-2" data-cy="leads-add">
|
|
263
|
+
<UserPlus className="w-4 h-4" />
|
|
264
|
+
Add Lead
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{/* Stats */}
|
|
269
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
270
|
+
<div className="bg-card border rounded-xl p-4">
|
|
271
|
+
<div className="flex items-center gap-3">
|
|
272
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
273
|
+
<UserPlus className="w-5 h-5 text-primary" />
|
|
274
|
+
</div>
|
|
275
|
+
<div>
|
|
276
|
+
<p className="text-2xl font-bold text-foreground">{stats.total}</p>
|
|
277
|
+
<p className="text-xs text-muted-foreground">Total Leads</p>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div className="bg-card border rounded-xl p-4">
|
|
283
|
+
<div className="flex items-center gap-3">
|
|
284
|
+
<div className="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
|
285
|
+
<Plus className="w-5 h-5 text-amber-600" />
|
|
286
|
+
</div>
|
|
287
|
+
<div>
|
|
288
|
+
<p className="text-2xl font-bold text-foreground">{stats.new}</p>
|
|
289
|
+
<p className="text-xs text-muted-foreground">New</p>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div className="bg-card border rounded-xl p-4">
|
|
295
|
+
<div className="flex items-center gap-3">
|
|
296
|
+
<div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
|
|
297
|
+
<ArrowRightCircle className="w-5 h-5 text-emerald-600" />
|
|
298
|
+
</div>
|
|
299
|
+
<div>
|
|
300
|
+
<p className="text-2xl font-bold text-foreground">{stats.qualified}</p>
|
|
301
|
+
<p className="text-xs text-muted-foreground">Qualified</p>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div className="bg-card border rounded-xl p-4">
|
|
307
|
+
<div className="flex items-center gap-3">
|
|
308
|
+
<div className="w-10 h-10 rounded-lg bg-destructive/10 flex items-center justify-center">
|
|
309
|
+
<Flame className="w-5 h-5 text-destructive" />
|
|
310
|
+
</div>
|
|
311
|
+
<div>
|
|
312
|
+
<p className="text-2xl font-bold text-foreground">{stats.hot}</p>
|
|
313
|
+
<p className="text-xs text-muted-foreground">Hot Leads</p>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{/* Data Table */}
|
|
320
|
+
<CRMDataTable
|
|
321
|
+
data={leads}
|
|
322
|
+
columns={columns}
|
|
323
|
+
bulkActions={bulkActions}
|
|
324
|
+
onRowClick={handleRowClick}
|
|
325
|
+
isLoading={isLoading}
|
|
326
|
+
searchPlaceholder="Search leads..."
|
|
327
|
+
searchFields={['firstName', 'lastName', 'email', 'companyName']}
|
|
328
|
+
pageSize={15}
|
|
329
|
+
emptyMessage="No leads yet"
|
|
330
|
+
emptyDescription="Start capturing leads to grow your sales pipeline."
|
|
331
|
+
entitySlug="leads"
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opportunity Edit Page
|
|
3
|
+
* Form for editing existing opportunities - Owner/Admin only
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { useRouter, useParams } from 'next/navigation'
|
|
9
|
+
import { useState, useEffect } from 'react'
|
|
10
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
11
|
+
import { OpportunityForm } from '@/themes/crm/templates/shared/OpportunityForm'
|
|
12
|
+
import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
|
|
13
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
14
|
+
import { ShieldAlert, ArrowLeft } from 'lucide-react'
|
|
15
|
+
|
|
16
|
+
// Access Denied component for when user doesn't have permission
|
|
17
|
+
function AccessDeniedView({
|
|
18
|
+
title = 'Access Denied',
|
|
19
|
+
message = "You don't have permission to perform this action",
|
|
20
|
+
backUrl = '/dashboard/opportunities'
|
|
21
|
+
}: {
|
|
22
|
+
title?: string
|
|
23
|
+
message?: string
|
|
24
|
+
backUrl?: string
|
|
25
|
+
}) {
|
|
26
|
+
const router = useRouter()
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4 p-6">
|
|
30
|
+
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
|
31
|
+
<ShieldAlert className="w-8 h-8 text-destructive" />
|
|
32
|
+
</div>
|
|
33
|
+
<div className="text-center space-y-2">
|
|
34
|
+
<h2 className="text-xl font-semibold">{title}</h2>
|
|
35
|
+
<p className="text-sm text-muted-foreground max-w-md">{message}</p>
|
|
36
|
+
</div>
|
|
37
|
+
<Button variant="outline" onClick={() => router.push(backUrl)}>
|
|
38
|
+
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
39
|
+
Back to Opportunities
|
|
40
|
+
</Button>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function OpportunityEditPage() {
|
|
46
|
+
const router = useRouter()
|
|
47
|
+
const params = useParams()
|
|
48
|
+
const opportunityId = params.id as string
|
|
49
|
+
|
|
50
|
+
const { currentTeam, isLoading: teamLoading } = useTeamContext()
|
|
51
|
+
const [permissionChecked, setPermissionChecked] = useState(false)
|
|
52
|
+
|
|
53
|
+
// Permission check - owner and admin can update opportunities
|
|
54
|
+
const canUpdate = usePermission('opportunities.update')
|
|
55
|
+
|
|
56
|
+
// Wait for team context to load before checking permissions
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!teamLoading && currentTeam) {
|
|
59
|
+
setPermissionChecked(true)
|
|
60
|
+
}
|
|
61
|
+
}, [teamLoading, currentTeam])
|
|
62
|
+
|
|
63
|
+
// Loading state while checking permissions
|
|
64
|
+
if (!permissionChecked) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
67
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Permission denied - show access denied page
|
|
73
|
+
if (!canUpdate) {
|
|
74
|
+
return (
|
|
75
|
+
<AccessDeniedView
|
|
76
|
+
title="Cannot Edit Opportunity"
|
|
77
|
+
message="You don't have permission to edit opportunities. Please contact your team owner or admin."
|
|
78
|
+
backUrl={`/dashboard/opportunities/${opportunityId}`}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Has permission - show the custom form
|
|
84
|
+
return (
|
|
85
|
+
<div className="p-6">
|
|
86
|
+
<OpportunityForm
|
|
87
|
+
mode="edit"
|
|
88
|
+
opportunityId={opportunityId}
|
|
89
|
+
onSuccess={() => {
|
|
90
|
+
router.push(`/dashboard/opportunities/${opportunityId}`)
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opportunity Create Page
|
|
3
|
+
* Form for creating new opportunities - Owner/Admin only
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { useRouter } from 'next/navigation'
|
|
9
|
+
import { useState, useEffect } from 'react'
|
|
10
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
11
|
+
import { OpportunityForm } from '@/themes/crm/templates/shared/OpportunityForm'
|
|
12
|
+
import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
|
|
13
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
14
|
+
import { ShieldAlert, ArrowLeft } from 'lucide-react'
|
|
15
|
+
|
|
16
|
+
// Access Denied component for when user doesn't have permission
|
|
17
|
+
function AccessDeniedView({
|
|
18
|
+
title = 'Access Denied',
|
|
19
|
+
message = "You don't have permission to perform this action",
|
|
20
|
+
backUrl = '/dashboard/opportunities'
|
|
21
|
+
}: {
|
|
22
|
+
title?: string
|
|
23
|
+
message?: string
|
|
24
|
+
backUrl?: string
|
|
25
|
+
}) {
|
|
26
|
+
const router = useRouter()
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4 p-6">
|
|
30
|
+
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
|
31
|
+
<ShieldAlert className="w-8 h-8 text-destructive" />
|
|
32
|
+
</div>
|
|
33
|
+
<div className="text-center space-y-2">
|
|
34
|
+
<h2 className="text-xl font-semibold">{title}</h2>
|
|
35
|
+
<p className="text-sm text-muted-foreground max-w-md">{message}</p>
|
|
36
|
+
</div>
|
|
37
|
+
<Button variant="outline" onClick={() => router.push(backUrl)}>
|
|
38
|
+
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
39
|
+
Back to Opportunities
|
|
40
|
+
</Button>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function OpportunityCreatePage() {
|
|
46
|
+
const router = useRouter()
|
|
47
|
+
const { currentTeam, isLoading: teamLoading } = useTeamContext()
|
|
48
|
+
const [permissionChecked, setPermissionChecked] = useState(false)
|
|
49
|
+
|
|
50
|
+
// Permission check - owner and admin can create opportunities
|
|
51
|
+
const canCreate = usePermission('opportunities.create')
|
|
52
|
+
|
|
53
|
+
// Wait for team context to load before checking permissions
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!teamLoading && currentTeam) {
|
|
56
|
+
setPermissionChecked(true)
|
|
57
|
+
}
|
|
58
|
+
}, [teamLoading, currentTeam])
|
|
59
|
+
|
|
60
|
+
// Loading state while checking permissions
|
|
61
|
+
if (!permissionChecked) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
64
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Permission denied - show access denied page
|
|
70
|
+
if (!canCreate) {
|
|
71
|
+
return (
|
|
72
|
+
<AccessDeniedView
|
|
73
|
+
title="Cannot Create Opportunity"
|
|
74
|
+
message="You don't have permission to create new opportunities. Please contact your team owner or admin."
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Has permission - show the custom form
|
|
80
|
+
return (
|
|
81
|
+
<div className="p-6">
|
|
82
|
+
<OpportunityForm
|
|
83
|
+
mode="create"
|
|
84
|
+
onSuccess={(createdId) => {
|
|
85
|
+
if (createdId) {
|
|
86
|
+
router.push(`/dashboard/opportunities/${createdId}`)
|
|
87
|
+
} else {
|
|
88
|
+
router.push('/dashboard/opportunities')
|
|
89
|
+
}
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|