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