@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,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opportunity Form Component
|
|
3
|
+
* Custom form for creating/editing opportunities with dynamic stage selection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect } from 'react'
|
|
9
|
+
import { useRouter, useSearchParams } from 'next/navigation'
|
|
10
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
11
|
+
import { Input } from '@nextsparkjs/core/components/ui/input'
|
|
12
|
+
import { Label } from '@nextsparkjs/core/components/ui/label'
|
|
13
|
+
import { Textarea } from '@nextsparkjs/core/components/ui/textarea'
|
|
14
|
+
import {
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from '@nextsparkjs/core/components/ui/select'
|
|
21
|
+
import { SimpleRelationSelect } from '@nextsparkjs/core/components/ui/simple-relation-select'
|
|
22
|
+
import { UserSelect } from '@nextsparkjs/core/components/ui/user-select'
|
|
23
|
+
import { StageSelect } from './StageSelect'
|
|
24
|
+
import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
|
|
25
|
+
import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
|
|
26
|
+
import { ArrowLeft, Save, Loader2, TrendingUp } from 'lucide-react'
|
|
27
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
28
|
+
|
|
29
|
+
interface OpportunityFormData {
|
|
30
|
+
name: string
|
|
31
|
+
companyId: string
|
|
32
|
+
contactId: string
|
|
33
|
+
pipelineId: string
|
|
34
|
+
stageId: string
|
|
35
|
+
amount: number
|
|
36
|
+
currency: string
|
|
37
|
+
probability: number
|
|
38
|
+
closeDate: string
|
|
39
|
+
type: string
|
|
40
|
+
source: string
|
|
41
|
+
competitor: string
|
|
42
|
+
status: string
|
|
43
|
+
lostReason: string
|
|
44
|
+
assignedTo: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface OpportunityFormProps {
|
|
48
|
+
mode: 'create' | 'edit'
|
|
49
|
+
opportunityId?: string
|
|
50
|
+
onSuccess?: (id?: string) => void
|
|
51
|
+
onCancel?: () => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CURRENCIES = [
|
|
55
|
+
{ value: 'USD', label: 'US Dollar' },
|
|
56
|
+
{ value: 'EUR', label: 'Euro' },
|
|
57
|
+
{ value: 'GBP', label: 'British Pound' },
|
|
58
|
+
{ value: 'MXN', label: 'Mexican Peso' },
|
|
59
|
+
{ value: 'CAD', label: 'Canadian Dollar' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const OPPORTUNITY_TYPES = [
|
|
63
|
+
{ value: 'new_business', label: 'New Business' },
|
|
64
|
+
{ value: 'existing_business', label: 'Existing Business' },
|
|
65
|
+
{ value: 'renewal', label: 'Renewal' },
|
|
66
|
+
{ value: 'upgrade', label: 'Upgrade' },
|
|
67
|
+
{ value: 'downgrade', label: 'Downgrade' },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
const SOURCES = [
|
|
71
|
+
{ value: 'web', label: 'Website' },
|
|
72
|
+
{ value: 'referral', label: 'Referral' },
|
|
73
|
+
{ value: 'cold_call', label: 'Cold Call' },
|
|
74
|
+
{ value: 'trade_show', label: 'Trade Show' },
|
|
75
|
+
{ value: 'social_media', label: 'Social Media' },
|
|
76
|
+
{ value: 'email', label: 'Email' },
|
|
77
|
+
{ value: 'advertising', label: 'Advertising' },
|
|
78
|
+
{ value: 'partner', label: 'Partner' },
|
|
79
|
+
{ value: 'other', label: 'Other' },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const STATUSES = [
|
|
83
|
+
{ value: 'open', label: 'Open' },
|
|
84
|
+
{ value: 'won', label: 'Won' },
|
|
85
|
+
{ value: 'lost', label: 'Lost' },
|
|
86
|
+
{ value: 'abandoned', label: 'Abandoned' },
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
export function OpportunityForm({ mode, opportunityId, onSuccess, onCancel }: OpportunityFormProps) {
|
|
90
|
+
const router = useRouter()
|
|
91
|
+
const searchParams = useSearchParams()
|
|
92
|
+
const { currentTeam, isLoading: teamLoading } = useTeamContext()
|
|
93
|
+
|
|
94
|
+
// Get pipelineId from URL params if present (when coming from pipeline kanban)
|
|
95
|
+
const initialPipelineId = searchParams.get('pipelineId') || ''
|
|
96
|
+
|
|
97
|
+
const [formData, setFormData] = useState<OpportunityFormData>({
|
|
98
|
+
name: '',
|
|
99
|
+
companyId: '',
|
|
100
|
+
contactId: '',
|
|
101
|
+
pipelineId: initialPipelineId,
|
|
102
|
+
stageId: '',
|
|
103
|
+
amount: 0,
|
|
104
|
+
currency: 'USD',
|
|
105
|
+
probability: 0,
|
|
106
|
+
closeDate: new Date().toISOString().split('T')[0],
|
|
107
|
+
type: 'new_business',
|
|
108
|
+
source: '',
|
|
109
|
+
competitor: '',
|
|
110
|
+
status: 'open',
|
|
111
|
+
lostReason: '',
|
|
112
|
+
assignedTo: '',
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const [isLoading, setIsLoading] = useState(mode === 'edit')
|
|
116
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
117
|
+
const [error, setError] = useState<string | null>(null)
|
|
118
|
+
|
|
119
|
+
// Load existing opportunity data for edit mode
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (mode === 'edit' && opportunityId && !teamLoading && currentTeam) {
|
|
122
|
+
loadOpportunity()
|
|
123
|
+
}
|
|
124
|
+
}, [mode, opportunityId, teamLoading, currentTeam])
|
|
125
|
+
|
|
126
|
+
const loadOpportunity = async () => {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetchWithTeam(`/api/v1/opportunities/${opportunityId}`)
|
|
129
|
+
if (!response.ok) throw new Error('Failed to load opportunity')
|
|
130
|
+
|
|
131
|
+
const result = await response.json()
|
|
132
|
+
const data = result.data
|
|
133
|
+
|
|
134
|
+
setFormData({
|
|
135
|
+
name: data.name || '',
|
|
136
|
+
companyId: data.companyId || '',
|
|
137
|
+
contactId: data.contactId || '',
|
|
138
|
+
pipelineId: data.pipelineId || '',
|
|
139
|
+
stageId: data.stageId || '',
|
|
140
|
+
amount: data.amount || 0,
|
|
141
|
+
currency: data.currency || 'USD',
|
|
142
|
+
probability: data.probability || 0,
|
|
143
|
+
closeDate: data.closeDate ? new Date(data.closeDate).toISOString().split('T')[0] : '',
|
|
144
|
+
type: data.type || 'new_business',
|
|
145
|
+
source: data.source || '',
|
|
146
|
+
competitor: data.competitor || '',
|
|
147
|
+
status: data.status || 'open',
|
|
148
|
+
lostReason: data.lostReason || '',
|
|
149
|
+
assignedTo: data.assignedTo || '',
|
|
150
|
+
})
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error('Error loading opportunity:', err)
|
|
153
|
+
setError('Failed to load opportunity data')
|
|
154
|
+
} finally {
|
|
155
|
+
setIsLoading(false)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
160
|
+
e.preventDefault()
|
|
161
|
+
setError(null)
|
|
162
|
+
|
|
163
|
+
// Validation
|
|
164
|
+
if (!formData.name.trim()) {
|
|
165
|
+
setError('Opportunity name is required')
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!formData.companyId) {
|
|
170
|
+
setError('Company is required')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!formData.pipelineId) {
|
|
175
|
+
setError('Pipeline is required')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!formData.stageId) {
|
|
180
|
+
setError('Stage is required')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!formData.closeDate) {
|
|
185
|
+
setError('Expected close date is required')
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setIsSaving(true)
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const url = mode === 'create'
|
|
193
|
+
? '/api/v1/opportunities'
|
|
194
|
+
: `/api/v1/opportunities/${opportunityId}`
|
|
195
|
+
|
|
196
|
+
const method = mode === 'create' ? 'POST' : 'PATCH'
|
|
197
|
+
|
|
198
|
+
// Prepare data - only send non-empty values
|
|
199
|
+
const submitData: Record<string, unknown> = {
|
|
200
|
+
name: formData.name,
|
|
201
|
+
companyId: formData.companyId,
|
|
202
|
+
pipelineId: formData.pipelineId,
|
|
203
|
+
stageId: formData.stageId,
|
|
204
|
+
amount: formData.amount,
|
|
205
|
+
currency: formData.currency,
|
|
206
|
+
probability: formData.probability,
|
|
207
|
+
closeDate: formData.closeDate,
|
|
208
|
+
status: formData.status,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Add optional fields only if they have values
|
|
212
|
+
if (formData.contactId) submitData.contactId = formData.contactId
|
|
213
|
+
if (formData.type) submitData.type = formData.type
|
|
214
|
+
if (formData.source) submitData.source = formData.source
|
|
215
|
+
if (formData.competitor) submitData.competitor = formData.competitor
|
|
216
|
+
if (formData.lostReason) submitData.lostReason = formData.lostReason
|
|
217
|
+
if (formData.assignedTo) submitData.assignedTo = formData.assignedTo
|
|
218
|
+
|
|
219
|
+
const response = await fetchWithTeam(url, {
|
|
220
|
+
method,
|
|
221
|
+
body: JSON.stringify(submitData),
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
const errorData = await response.json().catch(() => ({}))
|
|
226
|
+
throw new Error(errorData.error || 'Failed to save opportunity')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const result = await response.json()
|
|
230
|
+
const savedId = result.data?.id || opportunityId
|
|
231
|
+
|
|
232
|
+
if (onSuccess) {
|
|
233
|
+
onSuccess(savedId)
|
|
234
|
+
} else {
|
|
235
|
+
router.push(`/dashboard/opportunities/${savedId}`)
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error('Error saving opportunity:', err)
|
|
239
|
+
setError(err instanceof Error ? err.message : 'Failed to save opportunity')
|
|
240
|
+
} finally {
|
|
241
|
+
setIsSaving(false)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const handleCancel = () => {
|
|
246
|
+
if (onCancel) {
|
|
247
|
+
onCancel()
|
|
248
|
+
} else {
|
|
249
|
+
router.back()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Handle stage selection - auto-set probability from stage
|
|
254
|
+
const handleStageChange = (stageId: string, stage?: { probability: number }) => {
|
|
255
|
+
setFormData(prev => ({
|
|
256
|
+
...prev,
|
|
257
|
+
stageId,
|
|
258
|
+
// Auto-set probability from stage if available
|
|
259
|
+
probability: stage?.probability ?? prev.probability,
|
|
260
|
+
}))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Handle pipeline change - clear stage when pipeline changes
|
|
264
|
+
const handlePipelineChange = (pipelineId: string | string[] | null) => {
|
|
265
|
+
const newPipelineId = Array.isArray(pipelineId) ? pipelineId[0] : (pipelineId || '')
|
|
266
|
+
setFormData(prev => ({
|
|
267
|
+
...prev,
|
|
268
|
+
pipelineId: newPipelineId,
|
|
269
|
+
stageId: '', // Clear stage when pipeline changes
|
|
270
|
+
}))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (isLoading) {
|
|
274
|
+
return (
|
|
275
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
276
|
+
<div className="text-center">
|
|
277
|
+
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-3" />
|
|
278
|
+
<p className="text-sm text-muted-foreground">Loading opportunity...</p>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<form onSubmit={handleSubmit} className="space-y-8 max-w-4xl mx-auto" data-cy="opportunities-form">
|
|
286
|
+
{/* Header */}
|
|
287
|
+
<div className="flex items-center gap-4">
|
|
288
|
+
<Button
|
|
289
|
+
type="button"
|
|
290
|
+
variant="ghost"
|
|
291
|
+
size="icon"
|
|
292
|
+
onClick={handleCancel}
|
|
293
|
+
className="shrink-0"
|
|
294
|
+
>
|
|
295
|
+
<ArrowLeft className="w-5 h-5" />
|
|
296
|
+
</Button>
|
|
297
|
+
<div className="flex items-center gap-3">
|
|
298
|
+
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
|
299
|
+
<TrendingUp className="w-5 h-5 text-primary" />
|
|
300
|
+
</div>
|
|
301
|
+
<div>
|
|
302
|
+
<h1 className="text-xl font-semibold">
|
|
303
|
+
{mode === 'create' ? 'Create Opportunity' : 'Edit Opportunity'}
|
|
304
|
+
</h1>
|
|
305
|
+
<p className="text-sm text-muted-foreground">
|
|
306
|
+
{mode === 'create'
|
|
307
|
+
? 'Add a new sales opportunity to your pipeline'
|
|
308
|
+
: 'Update opportunity details'}
|
|
309
|
+
</p>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{/* Error message */}
|
|
315
|
+
{error && (
|
|
316
|
+
<div className="bg-destructive/10 border border-destructive/20 rounded-lg px-4 py-3 text-sm text-destructive">
|
|
317
|
+
{error}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{/* Basic Info Section */}
|
|
322
|
+
<div className="bg-card border rounded-xl p-6 space-y-5">
|
|
323
|
+
<h2 className="font-semibold text-base border-b pb-3 mb-4">Basic Information</h2>
|
|
324
|
+
|
|
325
|
+
<div className="grid grid-cols-12 gap-5">
|
|
326
|
+
{/* Name */}
|
|
327
|
+
<div className="col-span-12 sm:col-span-6" data-cy="opportunities-field-name">
|
|
328
|
+
<Label htmlFor="name">
|
|
329
|
+
Opportunity Name <span className="text-destructive">*</span>
|
|
330
|
+
</Label>
|
|
331
|
+
<Input
|
|
332
|
+
id="name"
|
|
333
|
+
value={formData.name}
|
|
334
|
+
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
335
|
+
placeholder="e.g., Enterprise License Deal"
|
|
336
|
+
className="mt-1.5"
|
|
337
|
+
required
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
{/* Company */}
|
|
342
|
+
<div className="col-span-12 sm:col-span-6">
|
|
343
|
+
<Label>
|
|
344
|
+
Company <span className="text-destructive">*</span>
|
|
345
|
+
</Label>
|
|
346
|
+
<div className="mt-1.5">
|
|
347
|
+
<SimpleRelationSelect
|
|
348
|
+
value={formData.companyId}
|
|
349
|
+
onChange={(val) => setFormData(prev => ({
|
|
350
|
+
...prev,
|
|
351
|
+
companyId: (Array.isArray(val) ? val[0] : val) || ''
|
|
352
|
+
}))}
|
|
353
|
+
entityType="companies"
|
|
354
|
+
titleField="name"
|
|
355
|
+
placeholder="Select company..."
|
|
356
|
+
disabled={isSaving}
|
|
357
|
+
teamId={currentTeam?.id}
|
|
358
|
+
/>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Contact */}
|
|
363
|
+
<div className="col-span-12 sm:col-span-6">
|
|
364
|
+
<Label>Primary Contact</Label>
|
|
365
|
+
<div className="mt-1.5">
|
|
366
|
+
<SimpleRelationSelect
|
|
367
|
+
value={formData.contactId}
|
|
368
|
+
onChange={(val) => setFormData(prev => ({
|
|
369
|
+
...prev,
|
|
370
|
+
contactId: (Array.isArray(val) ? val[0] : val) || ''
|
|
371
|
+
}))}
|
|
372
|
+
entityType="contacts"
|
|
373
|
+
titleField="firstName"
|
|
374
|
+
placeholder="Select contact..."
|
|
375
|
+
disabled={isSaving}
|
|
376
|
+
teamId={currentTeam?.id}
|
|
377
|
+
/>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
{/* Assigned To */}
|
|
382
|
+
<div className="col-span-12 sm:col-span-6">
|
|
383
|
+
<Label>Assigned To</Label>
|
|
384
|
+
<div className="mt-1.5">
|
|
385
|
+
<UserSelect
|
|
386
|
+
value={formData.assignedTo ? [{ id: formData.assignedTo, firstName: '', email: '' }] : []}
|
|
387
|
+
onChange={(users) => {
|
|
388
|
+
const userId = Array.isArray(users) && users.length > 0
|
|
389
|
+
? String(typeof users[0] === 'object' ? users[0].id : users[0])
|
|
390
|
+
: ''
|
|
391
|
+
setFormData(prev => ({ ...prev, assignedTo: userId }))
|
|
392
|
+
}}
|
|
393
|
+
disabled={isSaving}
|
|
394
|
+
multiple={false}
|
|
395
|
+
placeholder="Select user..."
|
|
396
|
+
teamId={currentTeam?.id}
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
{/* Pipeline & Stage Section */}
|
|
404
|
+
<div className="bg-card border rounded-xl p-6 space-y-5">
|
|
405
|
+
<h2 className="font-semibold text-base border-b pb-3 mb-4">Pipeline & Stage</h2>
|
|
406
|
+
|
|
407
|
+
<div className="grid grid-cols-12 gap-5">
|
|
408
|
+
{/* Pipeline */}
|
|
409
|
+
<div className="col-span-12 sm:col-span-6">
|
|
410
|
+
<Label>
|
|
411
|
+
Pipeline <span className="text-destructive">*</span>
|
|
412
|
+
</Label>
|
|
413
|
+
<div className="mt-1.5">
|
|
414
|
+
<SimpleRelationSelect
|
|
415
|
+
value={formData.pipelineId}
|
|
416
|
+
onChange={handlePipelineChange}
|
|
417
|
+
entityType="pipelines"
|
|
418
|
+
titleField="name"
|
|
419
|
+
placeholder="Select pipeline..."
|
|
420
|
+
disabled={isSaving}
|
|
421
|
+
teamId={currentTeam?.id}
|
|
422
|
+
/>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
{/* Stage */}
|
|
427
|
+
<div className="col-span-12 sm:col-span-6">
|
|
428
|
+
<StageSelect
|
|
429
|
+
label="Stage"
|
|
430
|
+
required
|
|
431
|
+
value={formData.stageId}
|
|
432
|
+
onChange={handleStageChange}
|
|
433
|
+
pipelineId={formData.pipelineId}
|
|
434
|
+
disabled={isSaving}
|
|
435
|
+
placeholder="Select stage..."
|
|
436
|
+
dataCy="opportunities-field-stage"
|
|
437
|
+
/>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
{/* Status */}
|
|
441
|
+
<div className="col-span-12 sm:col-span-4">
|
|
442
|
+
<Label>Status</Label>
|
|
443
|
+
<Select
|
|
444
|
+
value={formData.status}
|
|
445
|
+
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
|
|
446
|
+
disabled={isSaving}
|
|
447
|
+
>
|
|
448
|
+
<SelectTrigger className="mt-1.5">
|
|
449
|
+
<SelectValue placeholder="Select status" />
|
|
450
|
+
</SelectTrigger>
|
|
451
|
+
<SelectContent>
|
|
452
|
+
{STATUSES.map((status) => (
|
|
453
|
+
<SelectItem key={status.value} value={status.value}>
|
|
454
|
+
{status.label}
|
|
455
|
+
</SelectItem>
|
|
456
|
+
))}
|
|
457
|
+
</SelectContent>
|
|
458
|
+
</Select>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* Lost Reason (shown only if status is lost) */}
|
|
462
|
+
{formData.status === 'lost' && (
|
|
463
|
+
<div className="col-span-12 sm:col-span-8">
|
|
464
|
+
<Label>Lost Reason</Label>
|
|
465
|
+
<Input
|
|
466
|
+
value={formData.lostReason}
|
|
467
|
+
onChange={(e) => setFormData(prev => ({ ...prev, lostReason: e.target.value }))}
|
|
468
|
+
placeholder="Why was this opportunity lost?"
|
|
469
|
+
className="mt-1.5"
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
{/* Deal Value Section */}
|
|
477
|
+
<div className="bg-card border rounded-xl p-6 space-y-5">
|
|
478
|
+
<h2 className="font-semibold text-base border-b pb-3 mb-4">Deal Value</h2>
|
|
479
|
+
|
|
480
|
+
<div className="grid grid-cols-12 gap-5">
|
|
481
|
+
{/* Amount */}
|
|
482
|
+
<div className="col-span-12 sm:col-span-4" data-cy="opportunities-field-value">
|
|
483
|
+
<Label htmlFor="amount">
|
|
484
|
+
Deal Amount <span className="text-destructive">*</span>
|
|
485
|
+
</Label>
|
|
486
|
+
<Input
|
|
487
|
+
id="amount"
|
|
488
|
+
type="number"
|
|
489
|
+
min={0}
|
|
490
|
+
step={0.01}
|
|
491
|
+
value={formData.amount}
|
|
492
|
+
onChange={(e) => setFormData(prev => ({ ...prev, amount: parseFloat(e.target.value) || 0 }))}
|
|
493
|
+
placeholder="0.00"
|
|
494
|
+
className="mt-1.5"
|
|
495
|
+
required
|
|
496
|
+
/>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
{/* Currency */}
|
|
500
|
+
<div className="col-span-12 sm:col-span-4">
|
|
501
|
+
<Label>Currency</Label>
|
|
502
|
+
<Select
|
|
503
|
+
value={formData.currency}
|
|
504
|
+
onValueChange={(value) => setFormData(prev => ({ ...prev, currency: value }))}
|
|
505
|
+
disabled={isSaving}
|
|
506
|
+
>
|
|
507
|
+
<SelectTrigger className="mt-1.5">
|
|
508
|
+
<SelectValue placeholder="Select currency" />
|
|
509
|
+
</SelectTrigger>
|
|
510
|
+
<SelectContent>
|
|
511
|
+
{CURRENCIES.map((currency) => (
|
|
512
|
+
<SelectItem key={currency.value} value={currency.value}>
|
|
513
|
+
{currency.label}
|
|
514
|
+
</SelectItem>
|
|
515
|
+
))}
|
|
516
|
+
</SelectContent>
|
|
517
|
+
</Select>
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
{/* Probability */}
|
|
521
|
+
<div className="col-span-12 sm:col-span-4" data-cy="opportunities-field-probability">
|
|
522
|
+
<Label htmlFor="probability">Win Probability (%)</Label>
|
|
523
|
+
<div className="relative mt-1.5">
|
|
524
|
+
<Input
|
|
525
|
+
id="probability"
|
|
526
|
+
type="number"
|
|
527
|
+
min={0}
|
|
528
|
+
max={100}
|
|
529
|
+
value={formData.probability}
|
|
530
|
+
onChange={(e) => setFormData(prev => ({ ...prev, probability: parseInt(e.target.value) || 0 }))}
|
|
531
|
+
className="pr-8"
|
|
532
|
+
/>
|
|
533
|
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
|
534
|
+
%
|
|
535
|
+
</span>
|
|
536
|
+
</div>
|
|
537
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
538
|
+
Auto-set from stage, can be adjusted
|
|
539
|
+
</p>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
{/* Close Date */}
|
|
543
|
+
<div className="col-span-12 sm:col-span-4">
|
|
544
|
+
<Label htmlFor="closeDate">
|
|
545
|
+
Expected Close Date <span className="text-destructive">*</span>
|
|
546
|
+
</Label>
|
|
547
|
+
<Input
|
|
548
|
+
id="closeDate"
|
|
549
|
+
type="date"
|
|
550
|
+
value={formData.closeDate}
|
|
551
|
+
onChange={(e) => setFormData(prev => ({ ...prev, closeDate: e.target.value }))}
|
|
552
|
+
className="mt-1.5"
|
|
553
|
+
required
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
{/* Additional Info Section */}
|
|
560
|
+
<div className="bg-card border rounded-xl p-6 space-y-5">
|
|
561
|
+
<h2 className="font-semibold text-base border-b pb-3 mb-4">Additional Information</h2>
|
|
562
|
+
|
|
563
|
+
<div className="grid grid-cols-12 gap-5">
|
|
564
|
+
{/* Type */}
|
|
565
|
+
<div className="col-span-12 sm:col-span-4">
|
|
566
|
+
<Label>Opportunity Type</Label>
|
|
567
|
+
<Select
|
|
568
|
+
value={formData.type}
|
|
569
|
+
onValueChange={(value) => setFormData(prev => ({ ...prev, type: value }))}
|
|
570
|
+
disabled={isSaving}
|
|
571
|
+
>
|
|
572
|
+
<SelectTrigger className="mt-1.5">
|
|
573
|
+
<SelectValue placeholder="Select type" />
|
|
574
|
+
</SelectTrigger>
|
|
575
|
+
<SelectContent>
|
|
576
|
+
{OPPORTUNITY_TYPES.map((type) => (
|
|
577
|
+
<SelectItem key={type.value} value={type.value}>
|
|
578
|
+
{type.label}
|
|
579
|
+
</SelectItem>
|
|
580
|
+
))}
|
|
581
|
+
</SelectContent>
|
|
582
|
+
</Select>
|
|
583
|
+
</div>
|
|
584
|
+
|
|
585
|
+
{/* Source */}
|
|
586
|
+
<div className="col-span-12 sm:col-span-4">
|
|
587
|
+
<Label>Source</Label>
|
|
588
|
+
<Select
|
|
589
|
+
value={formData.source}
|
|
590
|
+
onValueChange={(value) => setFormData(prev => ({ ...prev, source: value }))}
|
|
591
|
+
disabled={isSaving}
|
|
592
|
+
>
|
|
593
|
+
<SelectTrigger className="mt-1.5">
|
|
594
|
+
<SelectValue placeholder="Select source" />
|
|
595
|
+
</SelectTrigger>
|
|
596
|
+
<SelectContent>
|
|
597
|
+
{SOURCES.map((source) => (
|
|
598
|
+
<SelectItem key={source.value} value={source.value}>
|
|
599
|
+
{source.label}
|
|
600
|
+
</SelectItem>
|
|
601
|
+
))}
|
|
602
|
+
</SelectContent>
|
|
603
|
+
</Select>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
{/* Competitor */}
|
|
607
|
+
<div className="col-span-12 sm:col-span-4">
|
|
608
|
+
<Label htmlFor="competitor">Main Competitor</Label>
|
|
609
|
+
<Input
|
|
610
|
+
id="competitor"
|
|
611
|
+
value={formData.competitor}
|
|
612
|
+
onChange={(e) => setFormData(prev => ({ ...prev, competitor: e.target.value }))}
|
|
613
|
+
placeholder="Enter competitor name"
|
|
614
|
+
className="mt-1.5"
|
|
615
|
+
/>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
{/* Actions */}
|
|
621
|
+
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
|
622
|
+
<Button
|
|
623
|
+
type="button"
|
|
624
|
+
variant="outline"
|
|
625
|
+
onClick={handleCancel}
|
|
626
|
+
disabled={isSaving}
|
|
627
|
+
data-cy="opportunities-form-cancel"
|
|
628
|
+
>
|
|
629
|
+
Cancel
|
|
630
|
+
</Button>
|
|
631
|
+
<Button type="submit" disabled={isSaving} className="gap-2" data-cy="opportunities-form-submit">
|
|
632
|
+
{isSaving ? (
|
|
633
|
+
<>
|
|
634
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
635
|
+
Saving...
|
|
636
|
+
</>
|
|
637
|
+
) : (
|
|
638
|
+
<>
|
|
639
|
+
<Save className="w-4 h-4" />
|
|
640
|
+
{mode === 'create' ? 'Create Opportunity' : 'Save Changes'}
|
|
641
|
+
</>
|
|
642
|
+
)}
|
|
643
|
+
</Button>
|
|
644
|
+
</div>
|
|
645
|
+
</form>
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export default OpportunityForm
|