@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,350 @@
1
+ /**
2
+ * Opportunities Page
3
+ * Professional opportunities 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
+ Target,
15
+ Trash2,
16
+ Download,
17
+ DollarSign,
18
+ Building2,
19
+ Calendar,
20
+ TrendingUp,
21
+ Clock,
22
+ CheckCircle2
23
+ } from 'lucide-react'
24
+ import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
25
+ import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
26
+ import { cn } from '@nextsparkjs/core/lib/utils'
27
+ import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
28
+
29
+ interface Opportunity {
30
+ id: string
31
+ name: string
32
+ amount: number
33
+ currency?: string
34
+ probability?: number
35
+ stage?: string
36
+ stageId?: string
37
+ pipelineId?: string
38
+ pipelineName?: string
39
+ companyId?: string
40
+ companyName?: string
41
+ expectedCloseDate?: string
42
+ status?: 'open' | 'won' | 'lost'
43
+ createdAt: string
44
+ updatedAt: string
45
+ }
46
+
47
+ // Stage badge component
48
+ function StageBadge({ stage }: { stage?: string }) {
49
+ if (!stage) return <span className="text-muted-foreground">-</span>
50
+
51
+ return (
52
+ <span className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary capitalize">
53
+ {stage}
54
+ </span>
55
+ )
56
+ }
57
+
58
+ // Status badge component
59
+ function StatusBadge({ status }: { status?: Opportunity['status'] }) {
60
+ const config = {
61
+ open: { label: 'Open', className: 'bg-amber-500/10 text-amber-600', icon: Clock },
62
+ won: { label: 'Won', className: 'bg-emerald-500/10 text-emerald-600', icon: CheckCircle2 },
63
+ lost: { label: 'Lost', className: 'bg-muted text-muted-foreground', icon: Target },
64
+ }
65
+
66
+ const statusConfig = config[status || 'open'] || config.open
67
+ const Icon = statusConfig.icon
68
+
69
+ return (
70
+ <div className={cn('inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium', statusConfig.className)}>
71
+ <Icon className="w-3 h-3" />
72
+ <span>{statusConfig.label}</span>
73
+ </div>
74
+ )
75
+ }
76
+
77
+ // Probability indicator
78
+ function ProbabilityIndicator({ probability }: { probability?: number }) {
79
+ const value = probability || 0
80
+
81
+ return (
82
+ <div className="flex items-center gap-2">
83
+ <div className="w-16 h-1.5 bg-muted rounded-full overflow-hidden">
84
+ <div
85
+ className={cn(
86
+ 'h-full rounded-full transition-all',
87
+ value >= 80 ? 'bg-emerald-500' :
88
+ value >= 50 ? 'bg-amber-500' : 'bg-muted-foreground'
89
+ )}
90
+ style={{ width: `${value}%` }}
91
+ />
92
+ </div>
93
+ <span className="text-sm text-muted-foreground">{value}%</span>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ export default function OpportunitiesPage() {
99
+ const router = useRouter()
100
+ const { currentTeam, isLoading: teamLoading } = useTeamContext()
101
+ const [opportunities, setOpportunities] = useState<Opportunity[]>([])
102
+ const [isLoading, setIsLoading] = useState(true)
103
+
104
+ // Permission checks for bulk actions
105
+ const canDeleteOpportunities = usePermission('opportunities.delete')
106
+
107
+ useEffect(() => {
108
+ if (teamLoading || !currentTeam) return
109
+
110
+ async function fetchOpportunities() {
111
+ try {
112
+ const response = await fetchWithTeam('/api/v1/opportunities')
113
+ if (!response.ok) throw new Error('Failed to fetch opportunities')
114
+ const result = await response.json()
115
+ setOpportunities(result.data || [])
116
+ } catch (error) {
117
+ console.error('Error loading opportunities:', error)
118
+ } finally {
119
+ setIsLoading(false)
120
+ }
121
+ }
122
+
123
+ fetchOpportunities()
124
+ }, [teamLoading, currentTeam])
125
+
126
+ // Stats
127
+ const stats = useMemo(() => {
128
+ const totalValue = opportunities.reduce((sum, o) => sum + (o.amount || 0), 0)
129
+ const openCount = opportunities.filter(o => o.status !== 'won' && o.status !== 'lost').length
130
+ const wonCount = opportunities.filter(o => o.status === 'won').length
131
+ const avgProbability = opportunities.length > 0
132
+ ? Math.round(opportunities.reduce((sum, o) => sum + (o.probability || 0), 0) / opportunities.length)
133
+ : 0
134
+
135
+ return {
136
+ total: opportunities.length,
137
+ totalValue,
138
+ openCount,
139
+ wonCount,
140
+ avgProbability,
141
+ }
142
+ }, [opportunities])
143
+
144
+ const formatCurrency = (value: number, currency = 'USD') => {
145
+ return new Intl.NumberFormat('en-US', {
146
+ style: 'currency',
147
+ currency,
148
+ minimumFractionDigits: 0,
149
+ maximumFractionDigits: 0,
150
+ }).format(value)
151
+ }
152
+
153
+ // Column definitions
154
+ const columns: Column<Opportunity>[] = [
155
+ {
156
+ key: 'name',
157
+ header: 'Opportunity',
158
+ sortable: true,
159
+ render: (_, opportunity) => (
160
+ <div className="flex items-center gap-3">
161
+ <div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
162
+ <Target className="w-4 h-4" />
163
+ </div>
164
+ <div>
165
+ <p className="font-medium text-foreground">{opportunity.name}</p>
166
+ {opportunity.pipelineName && (
167
+ <p className="text-xs text-muted-foreground">{opportunity.pipelineName}</p>
168
+ )}
169
+ </div>
170
+ </div>
171
+ ),
172
+ },
173
+ {
174
+ key: 'companyName',
175
+ header: 'Company',
176
+ sortable: true,
177
+ render: (value, opportunity) => value ? (
178
+ <button
179
+ onClick={(e) => {
180
+ e.stopPropagation()
181
+ if (opportunity.companyId) {
182
+ router.push(`/dashboard/companies/${opportunity.companyId}`)
183
+ }
184
+ }}
185
+ className="flex items-center gap-1.5 text-sm hover:text-primary transition-colors"
186
+ >
187
+ <Building2 className="w-3.5 h-3.5 text-muted-foreground" />
188
+ <span className={opportunity.companyId ? 'hover:underline' : ''}>{value}</span>
189
+ </button>
190
+ ) : <span className="text-muted-foreground">-</span>,
191
+ },
192
+ {
193
+ key: 'amount',
194
+ header: 'Value',
195
+ sortable: true,
196
+ render: (value, opportunity) => (
197
+ <span className="font-semibold text-primary">
198
+ {formatCurrency(value || 0, opportunity.currency)}
199
+ </span>
200
+ ),
201
+ },
202
+ {
203
+ key: 'stage',
204
+ header: 'Stage',
205
+ sortable: true,
206
+ render: (value) => <StageBadge stage={value} />,
207
+ },
208
+ {
209
+ key: 'probability',
210
+ header: 'Probability',
211
+ sortable: true,
212
+ render: (value) => <ProbabilityIndicator probability={value} />,
213
+ },
214
+ {
215
+ key: 'status',
216
+ header: 'Status',
217
+ sortable: true,
218
+ render: (value) => <StatusBadge status={value} />,
219
+ },
220
+ {
221
+ key: 'expectedCloseDate',
222
+ header: 'Expected Close',
223
+ sortable: true,
224
+ render: (value) => value ? (
225
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
226
+ <Calendar className="w-3.5 h-3.5" />
227
+ <span>{new Date(value).toLocaleDateString()}</span>
228
+ </div>
229
+ ) : <span className="text-muted-foreground">-</span>,
230
+ },
231
+ ]
232
+
233
+ // Bulk actions - filtered by permissions
234
+ const bulkActions: BulkAction[] = [
235
+ {
236
+ id: 'export',
237
+ label: 'Export',
238
+ icon: <Download className="w-4 h-4" />,
239
+ onClick: (ids) => {
240
+ console.log('Export opportunities:', ids)
241
+ },
242
+ },
243
+ // Only show delete action if user has permission
244
+ ...(canDeleteOpportunities ? [{
245
+ id: 'delete',
246
+ label: 'Delete',
247
+ icon: <Trash2 className="w-4 h-4" />,
248
+ variant: 'destructive' as const,
249
+ onClick: async (ids: string[]) => {
250
+ if (confirm(`Delete ${ids.length} opportunity(ies)?`)) {
251
+ console.log('Delete opportunities:', ids)
252
+ }
253
+ },
254
+ }] : []),
255
+ ]
256
+
257
+ const handleRowClick = (opportunity: Opportunity) => {
258
+ router.push(`/dashboard/opportunities/${opportunity.id}`)
259
+ }
260
+
261
+ const handleAddOpportunity = () => {
262
+ router.push('/dashboard/opportunities/create')
263
+ }
264
+
265
+ return (
266
+ <div className="p-6 space-y-6">
267
+ {/* Header */}
268
+ <div className="flex items-start justify-between">
269
+ <div>
270
+ <h1 className="text-2xl font-bold text-foreground tracking-tight">
271
+ Opportunities
272
+ </h1>
273
+ <p className="text-sm text-muted-foreground mt-1">
274
+ Track and manage your sales opportunities
275
+ </p>
276
+ </div>
277
+ <Button onClick={handleAddOpportunity} className="gap-2" data-cy="opportunities-add">
278
+ <Plus className="w-4 h-4" />
279
+ Add Opportunity
280
+ </Button>
281
+ </div>
282
+
283
+ {/* Stats */}
284
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
285
+ <div className="bg-card border rounded-xl p-4">
286
+ <div className="flex items-center gap-3">
287
+ <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
288
+ <Target className="w-5 h-5 text-primary" />
289
+ </div>
290
+ <div>
291
+ <p className="text-2xl font-bold text-foreground">{stats.total}</p>
292
+ <p className="text-xs text-muted-foreground">Total Opportunities</p>
293
+ </div>
294
+ </div>
295
+ </div>
296
+
297
+ <div className="bg-card border rounded-xl p-4">
298
+ <div className="flex items-center gap-3">
299
+ <div className="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
300
+ <DollarSign className="w-5 h-5 text-amber-600" />
301
+ </div>
302
+ <div>
303
+ <p className="text-2xl font-bold text-foreground">{formatCurrency(stats.totalValue)}</p>
304
+ <p className="text-xs text-muted-foreground">Pipeline Value</p>
305
+ </div>
306
+ </div>
307
+ </div>
308
+
309
+ <div className="bg-card border rounded-xl p-4">
310
+ <div className="flex items-center gap-3">
311
+ <div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
312
+ <CheckCircle2 className="w-5 h-5 text-emerald-600" />
313
+ </div>
314
+ <div>
315
+ <p className="text-2xl font-bold text-foreground">{stats.wonCount}</p>
316
+ <p className="text-xs text-muted-foreground">Won</p>
317
+ </div>
318
+ </div>
319
+ </div>
320
+
321
+ <div className="bg-card border rounded-xl p-4">
322
+ <div className="flex items-center gap-3">
323
+ <div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
324
+ <TrendingUp className="w-5 h-5 text-muted-foreground" />
325
+ </div>
326
+ <div>
327
+ <p className="text-2xl font-bold text-foreground">{stats.avgProbability}%</p>
328
+ <p className="text-xs text-muted-foreground">Avg. Probability</p>
329
+ </div>
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ {/* Data Table */}
335
+ <CRMDataTable
336
+ data={opportunities}
337
+ columns={columns}
338
+ bulkActions={bulkActions}
339
+ onRowClick={handleRowClick}
340
+ isLoading={isLoading}
341
+ searchPlaceholder="Search opportunities..."
342
+ searchFields={['name', 'companyName', 'stage']}
343
+ pageSize={15}
344
+ emptyMessage="No opportunities yet"
345
+ emptyDescription="Start adding opportunities to track your sales pipeline."
346
+ entitySlug="opportunities"
347
+ />
348
+ </div>
349
+ )
350
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Pipeline Edit Page
3
+ * Form for editing existing pipelines - Owner 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 { PipelineForm } from '@/themes/crm/templates/shared/PipelineForm'
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/pipelines'
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 Pipelines
40
+ </Button>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ export default function PipelineEditPage() {
46
+ const router = useRouter()
47
+ const params = useParams()
48
+ const pipelineId = params.id as string
49
+
50
+ const { currentTeam, isLoading: teamLoading } = useTeamContext()
51
+ const [permissionChecked, setPermissionChecked] = useState(false)
52
+
53
+ // Permission check - only owner can update pipelines
54
+ const canUpdate = usePermission('pipelines.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 Pipeline"
77
+ message="Only the team owner can edit sales pipelines. Please contact your team owner if you need to make changes."
78
+ backUrl={`/dashboard/pipelines/${pipelineId}`}
79
+ />
80
+ )
81
+ }
82
+
83
+ // Has permission - show the custom form
84
+ return (
85
+ <div className="p-6">
86
+ <PipelineForm
87
+ mode="edit"
88
+ pipelineId={pipelineId}
89
+ onSuccess={() => {
90
+ router.push(`/dashboard/pipelines/${pipelineId}`)
91
+ }}
92
+ />
93
+ </div>
94
+ )
95
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Pipeline Kanban Page
3
+ * Page template for viewing a pipeline in Kanban view
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import { useParams, useRouter } from 'next/navigation'
9
+ import { useEffect, useState } from 'react'
10
+ import { PipelineKanban } from '@/themes/crm/templates/shared/PipelineKanban'
11
+ import type { Stage } from '@/themes/crm/templates/shared/StageColumn'
12
+ import type { Deal } from '@/themes/crm/templates/shared/DealCard'
13
+ import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
14
+ import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
15
+
16
+ // This page will be rendered at /dashboard/pipelines/[id]
17
+ export default function PipelineKanbanPage() {
18
+ const params = useParams()
19
+ const router = useRouter()
20
+ const pipelineId = params.id as string
21
+ const { currentTeam, isLoading: teamLoading } = useTeamContext()
22
+
23
+ const [pipeline, setPipeline] = useState<any>(null)
24
+ const [deals, setDeals] = useState<Deal[]>([])
25
+ const [isLoading, setIsLoading] = useState(true)
26
+
27
+ useEffect(() => {
28
+ // Wait for team context to be ready
29
+ if (teamLoading || !currentTeam) {
30
+ return
31
+ }
32
+
33
+ async function fetchPipelineData() {
34
+ try {
35
+ // Fetch pipeline details
36
+ const pipelineRes = await fetchWithTeam(`/api/v1/pipelines/${pipelineId}`)
37
+ if (!pipelineRes.ok) throw new Error('Failed to fetch pipeline')
38
+ const pipelineResult = await pipelineRes.json()
39
+ const pipelineData = pipelineResult.data
40
+
41
+ // Fetch opportunities for this pipeline
42
+ const dealsRes = await fetchWithTeam(`/api/v1/opportunities?pipelineId=${pipelineId}`)
43
+ if (!dealsRes.ok) throw new Error('Failed to fetch opportunities')
44
+ const dealsResult = await dealsRes.json()
45
+ const dealsData = dealsResult.data || []
46
+
47
+ // Transform pipeline data
48
+ const transformedPipeline = {
49
+ id: pipelineData.id,
50
+ name: pipelineData.name,
51
+ stages: (pipelineData.stages as any[]).sort((a, b) => a.order - b.order),
52
+ }
53
+
54
+ // Transform deals data
55
+ const transformedDeals: Deal[] = dealsData.map((opp: any) => ({
56
+ id: opp.id,
57
+ name: opp.name,
58
+ companyId: opp.companyId,
59
+ companyName: opp.companyName || 'Unknown Company',
60
+ amount: opp.amount || 0,
61
+ currency: opp.currency || 'USD',
62
+ probability: opp.probability || 0,
63
+ assignedTo: opp.assignedTo,
64
+ assignedToName: opp.assignedToName,
65
+ updatedAt: opp.updatedAt,
66
+ stageId: opp.stageId,
67
+ }))
68
+
69
+ setPipeline(transformedPipeline)
70
+ setDeals(transformedDeals)
71
+ } catch (error) {
72
+ console.error('Error loading pipeline:', error)
73
+ } finally {
74
+ setIsLoading(false)
75
+ }
76
+ }
77
+
78
+ fetchPipelineData()
79
+ }, [pipelineId, teamLoading, currentTeam])
80
+
81
+ const handleDealClick = (deal: Deal) => {
82
+ router.push(`/dashboard/opportunities/${deal.id}`)
83
+ }
84
+
85
+ const handleDealMove = async (dealId: string, fromStageId: string, toStageId: string) => {
86
+ try {
87
+ const response = await fetchWithTeam(`/api/v1/opportunities/${dealId}`, {
88
+ method: 'PATCH',
89
+ body: JSON.stringify({ stageId: toStageId }),
90
+ })
91
+
92
+ if (!response.ok) {
93
+ throw new Error('Failed to update deal stage')
94
+ }
95
+
96
+ // Update local state
97
+ setDeals(prev => prev.map(d =>
98
+ d.id === dealId ? { ...d, stageId: toStageId } : d
99
+ ))
100
+ } catch (error) {
101
+ console.error('Error moving deal:', error)
102
+ throw error
103
+ }
104
+ }
105
+
106
+ const handleAddDeal = () => {
107
+ router.push(`/dashboard/opportunities/create?pipelineId=${pipelineId}`)
108
+ }
109
+
110
+ if (isLoading) {
111
+ return (
112
+ <div className="flex items-center justify-center h-screen">
113
+ <div className="text-center">
114
+ <div className="crm-skeleton w-64 h-8 mx-auto mb-4"></div>
115
+ <div className="crm-skeleton w-48 h-4 mx-auto"></div>
116
+ </div>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ if (!pipeline) {
122
+ return (
123
+ <div className="flex items-center justify-center h-screen">
124
+ <div className="text-center">
125
+ <h2 className="text-xl font-semibold text-gray-800">Pipeline not found</h2>
126
+ <p className="text-gray-600 mt-2">The requested pipeline could not be loaded.</p>
127
+ </div>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ return (
133
+ <div className="p-6">
134
+ <PipelineKanban
135
+ pipeline={pipeline}
136
+ deals={deals}
137
+ onDealClick={handleDealClick}
138
+ onDealMove={handleDealMove}
139
+ onAddDeal={handleAddDeal}
140
+ />
141
+ </div>
142
+ )
143
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pipeline Create Page
3
+ * Form for creating new pipelines - Owner 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 { PipelineForm } from '@/themes/crm/templates/shared/PipelineForm'
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/pipelines'
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 Pipelines
40
+ </Button>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ export default function PipelineCreatePage() {
46
+ const router = useRouter()
47
+ const { currentTeam, isLoading: teamLoading } = useTeamContext()
48
+ const [permissionChecked, setPermissionChecked] = useState(false)
49
+
50
+ // Permission check - only owner can create pipelines
51
+ const canCreate = usePermission('pipelines.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 Pipeline"
74
+ message="Only the team owner can create new sales pipelines. Please contact your team owner if you need to add a pipeline."
75
+ />
76
+ )
77
+ }
78
+
79
+ // Has permission - show the custom form
80
+ return (
81
+ <div className="p-6">
82
+ <PipelineForm
83
+ mode="create"
84
+ onSuccess={(createdId) => {
85
+ if (createdId) {
86
+ router.push(`/dashboard/pipelines/${createdId}`)
87
+ } else {
88
+ router.push('/dashboard/pipelines')
89
+ }
90
+ }}
91
+ />
92
+ </div>
93
+ )
94
+ }