@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.
Files changed (140) hide show
  1. package/CRM_PLAN.md +343 -0
  2. package/about.md +122 -0
  3. package/config/app.config.ts +185 -0
  4. package/config/billing.config.ts +187 -0
  5. package/config/dashboard.config.ts +372 -0
  6. package/config/dev.config.ts +55 -0
  7. package/config/features.config.ts +336 -0
  8. package/config/flows.config.ts +511 -0
  9. package/config/permissions.config.ts +297 -0
  10. package/config/theme.config.ts +111 -0
  11. package/entities/activities/activities.config.ts +61 -0
  12. package/entities/activities/activities.fields.ts +362 -0
  13. package/entities/activities/activities.service.ts +503 -0
  14. package/entities/activities/activities.types.ts +117 -0
  15. package/entities/activities/messages/en.json +123 -0
  16. package/entities/activities/messages/es.json +123 -0
  17. package/entities/activities/migrations/020_activities_table.sql +123 -0
  18. package/entities/activities/migrations/021_activities_metas.sql +114 -0
  19. package/entities/activities/migrations/022_activities_sample_data.sql +420 -0
  20. package/entities/campaigns/campaigns.config.ts +61 -0
  21. package/entities/campaigns/campaigns.fields.ts +413 -0
  22. package/entities/campaigns/campaigns.service.ts +426 -0
  23. package/entities/campaigns/campaigns.types.ts +124 -0
  24. package/entities/campaigns/messages/en.json +145 -0
  25. package/entities/campaigns/messages/es.json +145 -0
  26. package/entities/campaigns/migrations/001_campaigns_table.sql +127 -0
  27. package/entities/campaigns/migrations/002_campaigns_metas.sql +114 -0
  28. package/entities/campaigns/migrations/003_campaigns_sample_data.sql +364 -0
  29. package/entities/companies/companies.config.ts +61 -0
  30. package/entities/companies/companies.fields.ts +429 -0
  31. package/entities/companies/companies.service.ts +566 -0
  32. package/entities/companies/companies.types.ts +125 -0
  33. package/entities/companies/messages/en.json +146 -0
  34. package/entities/companies/messages/es.json +146 -0
  35. package/entities/companies/migrations/001_companies_table.sql +150 -0
  36. package/entities/companies/migrations/002_companies_metas.sql +114 -0
  37. package/entities/companies/migrations/003_companies_sample_data.sql +246 -0
  38. package/entities/contacts/contacts.config.ts +61 -0
  39. package/entities/contacts/contacts.fields.ts +359 -0
  40. package/entities/contacts/contacts.service.ts +509 -0
  41. package/entities/contacts/contacts.types.ts +108 -0
  42. package/entities/contacts/messages/en.json +117 -0
  43. package/entities/contacts/messages/es.json +117 -0
  44. package/entities/contacts/migrations/001_contacts_table.sql +134 -0
  45. package/entities/contacts/migrations/002_contacts_metas.sql +114 -0
  46. package/entities/contacts/migrations/003_contacts_sample_data.sql +421 -0
  47. package/entities/leads/leads.config.ts +61 -0
  48. package/entities/leads/leads.fields.ts +336 -0
  49. package/entities/leads/leads.service.ts +496 -0
  50. package/entities/leads/leads.types.ts +114 -0
  51. package/entities/leads/messages/en.json +132 -0
  52. package/entities/leads/messages/es.json +132 -0
  53. package/entities/leads/migrations/001_leads_table.sql +150 -0
  54. package/entities/leads/migrations/002_leads_metas.sql +120 -0
  55. package/entities/leads/migrations/003_leads_sample_data.sql +242 -0
  56. package/entities/notes/messages/en.json +114 -0
  57. package/entities/notes/messages/es.json +114 -0
  58. package/entities/notes/migrations/020_notes_table.sql +118 -0
  59. package/entities/notes/migrations/021_notes_metas.sql +114 -0
  60. package/entities/notes/migrations/022_notes_sample_data.sql +275 -0
  61. package/entities/notes/notes.config.ts +61 -0
  62. package/entities/notes/notes.fields.ts +283 -0
  63. package/entities/notes/notes.service.ts +320 -0
  64. package/entities/notes/notes.types.ts +102 -0
  65. package/entities/opportunities/messages/en.json +107 -0
  66. package/entities/opportunities/messages/es.json +107 -0
  67. package/entities/opportunities/migrations/010_opportunities_table.sql +145 -0
  68. package/entities/opportunities/migrations/011_opportunities_metas.sql +114 -0
  69. package/entities/opportunities/migrations/012_opportunities_sample_data.sql +438 -0
  70. package/entities/opportunities/opportunities.config.ts +61 -0
  71. package/entities/opportunities/opportunities.fields.ts +416 -0
  72. package/entities/opportunities/opportunities.service.ts +525 -0
  73. package/entities/opportunities/opportunities.types.ts +135 -0
  74. package/entities/pipelines/messages/en.json +115 -0
  75. package/entities/pipelines/messages/es.json +115 -0
  76. package/entities/pipelines/migrations/001_pipelines_table.sql +106 -0
  77. package/entities/pipelines/migrations/002_pipelines_metas.sql +114 -0
  78. package/entities/pipelines/migrations/003_pipelines_sample_data.sql +91 -0
  79. package/entities/pipelines/pipelines.config.ts +62 -0
  80. package/entities/pipelines/pipelines.fields.ts +193 -0
  81. package/entities/pipelines/pipelines.service.ts +383 -0
  82. package/entities/pipelines/pipelines.types.ts +78 -0
  83. package/entities/products/messages/en.json +135 -0
  84. package/entities/products/messages/es.json +135 -0
  85. package/entities/products/migrations/001_products_table.sql +117 -0
  86. package/entities/products/migrations/002_products_metas.sql +114 -0
  87. package/entities/products/migrations/003_products_sample_data.sql +247 -0
  88. package/entities/products/products.config.ts +62 -0
  89. package/entities/products/products.fields.ts +361 -0
  90. package/entities/products/products.service.ts +437 -0
  91. package/entities/products/products.types.ts +125 -0
  92. package/lib/crm-constants.ts +77 -0
  93. package/lib/crm-utils.ts +185 -0
  94. package/lib/selectors.ts +333 -0
  95. package/messages/en.json +131 -0
  96. package/messages/es.json +131 -0
  97. package/migrations/999_theme_sample_data.sql +473 -0
  98. package/package.json +18 -0
  99. package/pendings.md +205 -0
  100. package/permissions-matrix.md +216 -0
  101. package/styles/components.css +414 -0
  102. package/styles/crm-theme.css +358 -0
  103. package/styles/globals.css +576 -0
  104. package/styles/variables.css +111 -0
  105. package/templates/dashboard/(main)/activities/components/ActivityCard.tsx +169 -0
  106. package/templates/dashboard/(main)/activities/components/ActivityTimeline.tsx +165 -0
  107. package/templates/dashboard/(main)/activities/page.tsx +297 -0
  108. package/templates/dashboard/(main)/campaigns/page.tsx +373 -0
  109. package/templates/dashboard/(main)/companies/page.tsx +296 -0
  110. package/templates/dashboard/(main)/contacts/page.tsx +347 -0
  111. package/templates/dashboard/(main)/layout.tsx +98 -0
  112. package/templates/dashboard/(main)/leads/page.tsx +335 -0
  113. package/templates/dashboard/(main)/opportunities/[id]/edit/page.tsx +95 -0
  114. package/templates/dashboard/(main)/opportunities/create/page.tsx +94 -0
  115. package/templates/dashboard/(main)/opportunities/page.tsx +350 -0
  116. package/templates/dashboard/(main)/pipelines/[id]/edit/page.tsx +95 -0
  117. package/templates/dashboard/(main)/pipelines/[id]/page.tsx +143 -0
  118. package/templates/dashboard/(main)/pipelines/create/page.tsx +94 -0
  119. package/templates/dashboard/(main)/pipelines/page.tsx +234 -0
  120. package/templates/dashboard/(main)/products/[id]/edit/page.tsx +97 -0
  121. package/templates/dashboard/(main)/products/[id]/page.tsx +509 -0
  122. package/templates/dashboard/(main)/products/create/page.tsx +96 -0
  123. package/templates/dashboard/(main)/products/page.tsx +308 -0
  124. package/templates/shared/ActionButtons.tsx +41 -0
  125. package/templates/shared/CRMDashboard.tsx +519 -0
  126. package/templates/shared/CRMDataTable.tsx +441 -0
  127. package/templates/shared/CRMMetricCard.tsx +76 -0
  128. package/templates/shared/CRMMobileNav.tsx +172 -0
  129. package/templates/shared/CRMSidebar.tsx +346 -0
  130. package/templates/shared/CRMTopBar.tsx +265 -0
  131. package/templates/shared/DealCard.tsx +123 -0
  132. package/templates/shared/EntityCard.tsx +58 -0
  133. package/templates/shared/OpportunityForm.tsx +649 -0
  134. package/templates/shared/PipelineForm.tsx +367 -0
  135. package/templates/shared/PipelineKanban.tsx +194 -0
  136. package/templates/shared/QuickFilters.tsx +47 -0
  137. package/templates/shared/StageColumn.tsx +175 -0
  138. package/templates/shared/StageSelect.tsx +177 -0
  139. package/templates/shared/StagesRepeater.tsx +317 -0
  140. 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
+ }