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