@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,367 @@
1
+ /**
2
+ * Pipeline Form Component
3
+ * Custom form for creating/editing pipelines with stages repeater
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import React, { useState, useEffect } from 'react'
9
+ import { useRouter } 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 { Switch } from '@nextsparkjs/core/components/ui/switch'
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '@nextsparkjs/core/components/ui/select'
22
+ import { StagesRepeater, DEFAULT_STAGES, type Stage } from './StagesRepeater'
23
+ import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
24
+ import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
25
+ import { ArrowLeft, Save, Loader2, GitBranch } from 'lucide-react'
26
+ import { cn } from '@nextsparkjs/core/lib/utils'
27
+
28
+ interface PipelineFormData {
29
+ name: string
30
+ description: string
31
+ type: string
32
+ isDefault: boolean
33
+ isActive: boolean
34
+ stages: Stage[]
35
+ dealRottenDays: number
36
+ }
37
+
38
+ interface PipelineFormProps {
39
+ mode: 'create' | 'edit'
40
+ pipelineId?: string
41
+ onSuccess?: (id?: string) => void
42
+ onCancel?: () => void
43
+ }
44
+
45
+ const PIPELINE_TYPES = [
46
+ { value: 'sales', label: 'Sales' },
47
+ { value: 'support', label: 'Support' },
48
+ { value: 'project', label: 'Project' },
49
+ { value: 'custom', label: 'Custom' },
50
+ ]
51
+
52
+ export function PipelineForm({ mode, pipelineId, onSuccess, onCancel }: PipelineFormProps) {
53
+ const router = useRouter()
54
+ const { currentTeam, isLoading: teamLoading } = useTeamContext()
55
+
56
+ const [formData, setFormData] = useState<PipelineFormData>({
57
+ name: '',
58
+ description: '',
59
+ type: 'sales',
60
+ isDefault: false,
61
+ isActive: true,
62
+ stages: DEFAULT_STAGES,
63
+ dealRottenDays: 30,
64
+ })
65
+
66
+ const [isLoading, setIsLoading] = useState(mode === 'edit')
67
+ const [isSaving, setIsSaving] = useState(false)
68
+ const [error, setError] = useState<string | null>(null)
69
+
70
+ // Load existing pipeline data for edit mode
71
+ useEffect(() => {
72
+ if (mode === 'edit' && pipelineId && !teamLoading && currentTeam) {
73
+ loadPipeline()
74
+ }
75
+ }, [mode, pipelineId, teamLoading, currentTeam])
76
+
77
+ const loadPipeline = async () => {
78
+ try {
79
+ const response = await fetchWithTeam(`/api/v1/pipelines/${pipelineId}`)
80
+ if (!response.ok) throw new Error('Failed to load pipeline')
81
+
82
+ const result = await response.json()
83
+ const data = result.data
84
+
85
+ setFormData({
86
+ name: data.name || '',
87
+ description: data.description || '',
88
+ type: data.type || 'sales',
89
+ isDefault: data.isDefault || false,
90
+ isActive: data.isActive !== false,
91
+ stages: data.stages || DEFAULT_STAGES,
92
+ dealRottenDays: data.dealRottenDays || 30,
93
+ })
94
+ } catch (err) {
95
+ console.error('Error loading pipeline:', err)
96
+ setError('Failed to load pipeline data')
97
+ } finally {
98
+ setIsLoading(false)
99
+ }
100
+ }
101
+
102
+ const handleSubmit = async (e: React.FormEvent) => {
103
+ e.preventDefault()
104
+ setError(null)
105
+
106
+ // Validation
107
+ if (!formData.name.trim()) {
108
+ setError('Pipeline name is required')
109
+ return
110
+ }
111
+
112
+ if (formData.stages.length === 0) {
113
+ setError('At least one stage is required')
114
+ return
115
+ }
116
+
117
+ const emptyStages = formData.stages.filter(s => !s.name.trim())
118
+ if (emptyStages.length > 0) {
119
+ setError('All stages must have a name')
120
+ return
121
+ }
122
+
123
+ setIsSaving(true)
124
+
125
+ try {
126
+ const url = mode === 'create'
127
+ ? '/api/v1/pipelines'
128
+ : `/api/v1/pipelines/${pipelineId}`
129
+
130
+ const method = mode === 'create' ? 'POST' : 'PATCH'
131
+
132
+ const response = await fetchWithTeam(url, {
133
+ method,
134
+ body: JSON.stringify(formData),
135
+ })
136
+
137
+ if (!response.ok) {
138
+ const errorData = await response.json().catch(() => ({}))
139
+ throw new Error(errorData.error || 'Failed to save pipeline')
140
+ }
141
+
142
+ const result = await response.json()
143
+ const savedId = result.data?.id || pipelineId
144
+
145
+ if (onSuccess) {
146
+ onSuccess(savedId)
147
+ } else {
148
+ router.push(`/dashboard/pipelines/${savedId}`)
149
+ }
150
+ } catch (err) {
151
+ console.error('Error saving pipeline:', err)
152
+ setError(err instanceof Error ? err.message : 'Failed to save pipeline')
153
+ } finally {
154
+ setIsSaving(false)
155
+ }
156
+ }
157
+
158
+ const handleCancel = () => {
159
+ if (onCancel) {
160
+ onCancel()
161
+ } else {
162
+ router.back()
163
+ }
164
+ }
165
+
166
+ if (isLoading) {
167
+ return (
168
+ <div className="flex items-center justify-center min-h-[400px]">
169
+ <div className="text-center">
170
+ <Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-3" />
171
+ <p className="text-sm text-muted-foreground">Loading pipeline...</p>
172
+ </div>
173
+ </div>
174
+ )
175
+ }
176
+
177
+ return (
178
+ <form onSubmit={handleSubmit} className="space-y-8 max-w-4xl mx-auto" data-cy="pipeline-form">
179
+ {/* Header */}
180
+ <div className="flex items-center gap-4">
181
+ <Button
182
+ type="button"
183
+ variant="ghost"
184
+ size="icon"
185
+ onClick={handleCancel}
186
+ className="shrink-0"
187
+ >
188
+ <ArrowLeft className="w-5 h-5" />
189
+ </Button>
190
+ <div className="flex items-center gap-3">
191
+ <div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
192
+ <GitBranch className="w-5 h-5 text-primary" />
193
+ </div>
194
+ <div>
195
+ <h1 className="text-xl font-semibold">
196
+ {mode === 'create' ? 'Create Pipeline' : 'Edit Pipeline'}
197
+ </h1>
198
+ <p className="text-sm text-muted-foreground">
199
+ {mode === 'create'
200
+ ? 'Set up a new sales pipeline with custom stages'
201
+ : 'Modify pipeline settings and stages'}
202
+ </p>
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ {/* Error message */}
208
+ {error && (
209
+ <div className="bg-destructive/10 border border-destructive/20 rounded-lg px-4 py-3 text-sm text-destructive">
210
+ {error}
211
+ </div>
212
+ )}
213
+
214
+ {/* Basic Info Section */}
215
+ <div className="bg-card border rounded-xl p-6 space-y-5">
216
+ <h2 className="font-semibold text-base border-b pb-3 mb-4">Basic Information</h2>
217
+
218
+ <div className="grid grid-cols-2 gap-5">
219
+ {/* Name */}
220
+ <div className="col-span-2 sm:col-span-1">
221
+ <Label htmlFor="name" className="text-sm font-medium">
222
+ Pipeline Name <span className="text-destructive">*</span>
223
+ </Label>
224
+ <Input
225
+ id="name"
226
+ value={formData.name}
227
+ onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
228
+ placeholder="e.g., Enterprise Sales"
229
+ className="mt-1.5"
230
+ required
231
+ data-cy="pipeline-form-name"
232
+ />
233
+ </div>
234
+
235
+ {/* Type */}
236
+ <div className="col-span-2 sm:col-span-1">
237
+ <Label htmlFor="type" className="text-sm font-medium">
238
+ Pipeline Type
239
+ </Label>
240
+ <Select
241
+ value={formData.type}
242
+ onValueChange={(value) => setFormData(prev => ({ ...prev, type: value }))}
243
+ >
244
+ <SelectTrigger className="mt-1.5">
245
+ <SelectValue placeholder="Select type" />
246
+ </SelectTrigger>
247
+ <SelectContent>
248
+ {PIPELINE_TYPES.map((type) => (
249
+ <SelectItem key={type.value} value={type.value}>
250
+ {type.label}
251
+ </SelectItem>
252
+ ))}
253
+ </SelectContent>
254
+ </Select>
255
+ </div>
256
+
257
+ {/* Description */}
258
+ <div className="col-span-2">
259
+ <Label htmlFor="description" className="text-sm font-medium">
260
+ Description
261
+ </Label>
262
+ <Textarea
263
+ id="description"
264
+ value={formData.description}
265
+ onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
266
+ placeholder="Describe the purpose of this pipeline..."
267
+ className="mt-1.5 min-h-[80px]"
268
+ data-cy="pipeline-form-description"
269
+ />
270
+ </div>
271
+
272
+ {/* Deal Rotten Days */}
273
+ <div className="col-span-2 sm:col-span-1">
274
+ <Label htmlFor="dealRottenDays" className="text-sm font-medium">
275
+ Deal Rotten Days
276
+ </Label>
277
+ <p className="text-xs text-muted-foreground mb-1.5">
278
+ Days without activity before a deal is marked as stale
279
+ </p>
280
+ <Input
281
+ id="dealRottenDays"
282
+ type="number"
283
+ min={1}
284
+ max={365}
285
+ value={formData.dealRottenDays}
286
+ onChange={(e) => setFormData(prev => ({ ...prev, dealRottenDays: parseInt(e.target.value) || 30 }))}
287
+ className="w-32"
288
+ />
289
+ </div>
290
+
291
+ {/* Toggles */}
292
+ <div className="col-span-2 sm:col-span-1 space-y-4">
293
+ <div className="flex items-center justify-between">
294
+ <div>
295
+ <Label htmlFor="isActive" className="text-sm font-medium">
296
+ Active
297
+ </Label>
298
+ <p className="text-xs text-muted-foreground">
299
+ Enable this pipeline for use
300
+ </p>
301
+ </div>
302
+ <Switch
303
+ id="isActive"
304
+ checked={formData.isActive}
305
+ onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isActive: checked }))}
306
+ />
307
+ </div>
308
+
309
+ <div className="flex items-center justify-between">
310
+ <div>
311
+ <Label htmlFor="isDefault" className="text-sm font-medium">
312
+ Default Pipeline
313
+ </Label>
314
+ <p className="text-xs text-muted-foreground">
315
+ Use as default for new deals
316
+ </p>
317
+ </div>
318
+ <Switch
319
+ id="isDefault"
320
+ checked={formData.isDefault}
321
+ onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isDefault: checked }))}
322
+ />
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </div>
327
+
328
+ {/* Stages Section */}
329
+ <div className="bg-card border rounded-xl p-6">
330
+ <h2 className="font-semibold text-base border-b pb-3 mb-4">Pipeline Stages</h2>
331
+ <StagesRepeater
332
+ value={formData.stages}
333
+ onChange={(stages) => setFormData(prev => ({ ...prev, stages }))}
334
+ disabled={isSaving}
335
+ />
336
+ </div>
337
+
338
+ {/* Actions */}
339
+ <div className="flex items-center justify-end gap-3 pt-4 border-t">
340
+ <Button
341
+ type="button"
342
+ variant="outline"
343
+ onClick={handleCancel}
344
+ disabled={isSaving}
345
+ data-cy="pipeline-form-cancel"
346
+ >
347
+ Cancel
348
+ </Button>
349
+ <Button type="submit" disabled={isSaving} className="gap-2" data-cy="pipeline-form-submit">
350
+ {isSaving ? (
351
+ <>
352
+ <Loader2 className="w-4 h-4 animate-spin" />
353
+ Saving...
354
+ </>
355
+ ) : (
356
+ <>
357
+ <Save className="w-4 h-4" />
358
+ {mode === 'create' ? 'Create Pipeline' : 'Save Changes'}
359
+ </>
360
+ )}
361
+ </Button>
362
+ </div>
363
+ </form>
364
+ )
365
+ }
366
+
367
+ export default PipelineForm
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Pipeline Kanban Component
3
+ * Professional Kanban board with stats and drag-drop
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import React, { useState, useCallback } from 'react'
9
+ import { StageColumn, type Stage } from './StageColumn'
10
+ import { type Deal } from './DealCard'
11
+ import { Button } from '@nextsparkjs/core/components/ui/button'
12
+ import {
13
+ Plus,
14
+ Target,
15
+ DollarSign,
16
+ TrendingUp,
17
+ BarChart3,
18
+ Loader2
19
+ } from 'lucide-react'
20
+
21
+ interface Pipeline {
22
+ id: string
23
+ name: string
24
+ stages: Stage[]
25
+ }
26
+
27
+ interface PipelineKanbanProps {
28
+ pipeline: Pipeline
29
+ deals: Deal[]
30
+ onDealClick?: (deal: Deal) => void
31
+ onDealMove?: (dealId: string, fromStageId: string, toStageId: string) => Promise<void>
32
+ onAddDeal?: (stageId?: string) => void
33
+ }
34
+
35
+ export function PipelineKanban({
36
+ pipeline,
37
+ deals: initialDeals,
38
+ onDealClick,
39
+ onDealMove,
40
+ onAddDeal,
41
+ }: PipelineKanbanProps) {
42
+ const [deals, setDeals] = useState(initialDeals)
43
+ const [isMoving, setIsMoving] = useState(false)
44
+
45
+ // Group deals by stage
46
+ const dealsByStage = pipeline.stages.reduce((acc, stage) => {
47
+ acc[stage.id] = deals.filter(deal => deal.stageId === stage.id)
48
+ return acc
49
+ }, {} as Record<string, Deal[]>)
50
+
51
+ // Calculate pipeline stats
52
+ const totalValue = deals.reduce((sum, d) => sum + d.amount, 0)
53
+ const weightedValue = deals.reduce((sum, d) => sum + (d.amount * d.probability / 100), 0)
54
+ const avgDealSize = deals.length > 0 ? totalValue / deals.length : 0
55
+ const currency = deals[0]?.currency || 'USD'
56
+
57
+ const formatCurrency = (value: number) => {
58
+ return new Intl.NumberFormat('en-US', {
59
+ style: 'currency',
60
+ currency: currency,
61
+ minimumFractionDigits: 0,
62
+ maximumFractionDigits: 0,
63
+ }).format(value)
64
+ }
65
+
66
+ const handleDrop = useCallback(async (dealId: string, targetStageId: string) => {
67
+ const deal = deals.find(d => d.id === dealId)
68
+ if (!deal || deal.stageId === targetStageId) return
69
+
70
+ const previousStageId = deal.stageId
71
+
72
+ // Optimistic update
73
+ setDeals(prev => prev.map(d =>
74
+ d.id === dealId ? { ...d, stageId: targetStageId } : d
75
+ ))
76
+
77
+ setIsMoving(true)
78
+
79
+ try {
80
+ await onDealMove?.(dealId, previousStageId, targetStageId)
81
+ } catch (error) {
82
+ // Revert on error
83
+ setDeals(prev => prev.map(d =>
84
+ d.id === dealId ? { ...d, stageId: previousStageId } : d
85
+ ))
86
+ console.error('Failed to move deal:', error)
87
+ } finally {
88
+ setIsMoving(false)
89
+ }
90
+ }, [deals, onDealMove])
91
+
92
+ return (
93
+ <div className="space-y-6" data-cy="pipeline-kanban">
94
+ {/* Header */}
95
+ <div className="flex items-start justify-between" data-cy="pipeline-kanban-header">
96
+ <div>
97
+ <h1 className="text-2xl font-bold text-foreground tracking-tight">
98
+ {pipeline.name}
99
+ </h1>
100
+ <p className="text-sm text-muted-foreground mt-1">
101
+ {pipeline.stages.length} stages • {deals.length} opportunities
102
+ </p>
103
+ </div>
104
+
105
+ <Button onClick={() => onAddDeal?.()} className="gap-2" data-cy="pipeline-kanban-add-deal-btn">
106
+ <Plus className="w-4 h-4" />
107
+ Add Deal
108
+ </Button>
109
+ </div>
110
+
111
+ {/* Stats cards */}
112
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4" data-cy="pipeline-kanban-stats">
113
+ <div className="bg-card border rounded-xl p-4">
114
+ <div className="flex items-center gap-3">
115
+ <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
116
+ <Target className="w-5 h-5 text-primary" />
117
+ </div>
118
+ <div>
119
+ <p className="text-2xl font-bold text-foreground">{deals.length}</p>
120
+ <p className="text-xs text-muted-foreground">Open Deals</p>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div className="bg-card border rounded-xl p-4">
126
+ <div className="flex items-center gap-3">
127
+ <div className="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center">
128
+ <DollarSign className="w-5 h-5 text-emerald-600" />
129
+ </div>
130
+ <div>
131
+ <p className="text-2xl font-bold text-foreground">{formatCurrency(totalValue)}</p>
132
+ <p className="text-xs text-muted-foreground">Total Value</p>
133
+ </div>
134
+ </div>
135
+ </div>
136
+
137
+ <div className="bg-card border rounded-xl p-4">
138
+ <div className="flex items-center gap-3">
139
+ <div className="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
140
+ <TrendingUp className="w-5 h-5 text-amber-600" />
141
+ </div>
142
+ <div>
143
+ <p className="text-2xl font-bold text-foreground">{formatCurrency(weightedValue)}</p>
144
+ <p className="text-xs text-muted-foreground">Weighted Value</p>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <div className="bg-card border rounded-xl p-4">
150
+ <div className="flex items-center gap-3">
151
+ <div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center">
152
+ <BarChart3 className="w-5 h-5 text-muted-foreground" />
153
+ </div>
154
+ <div>
155
+ <p className="text-2xl font-bold text-foreground">{formatCurrency(avgDealSize)}</p>
156
+ <p className="text-xs text-muted-foreground">Avg Deal Size</p>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ {/* Kanban Board */}
163
+ <div className="overflow-x-auto pb-4 -mx-6 px-6" data-cy="pipeline-kanban-board">
164
+ <div className="flex gap-4 min-w-max">
165
+ {pipeline.stages.map((stage, index) => (
166
+ <div
167
+ key={stage.id}
168
+ className="animate-in fade-in slide-in-from-bottom-3"
169
+ style={{ animationDelay: `${index * 50}ms`, animationFillMode: 'backwards' }}
170
+ >
171
+ <StageColumn
172
+ stage={stage}
173
+ deals={dealsByStage[stage.id] || []}
174
+ onDealClick={onDealClick}
175
+ onDrop={handleDrop}
176
+ onAddDeal={onAddDeal}
177
+ />
178
+ </div>
179
+ ))}
180
+ </div>
181
+ </div>
182
+
183
+ {/* Moving indicator */}
184
+ {isMoving && (
185
+ <div className="fixed bottom-6 right-6 bg-primary text-primary-foreground px-4 py-3 rounded-xl shadow-lg flex items-center gap-2 animate-in fade-in slide-in-from-bottom-2">
186
+ <Loader2 className="w-4 h-4 animate-spin" />
187
+ <span className="text-sm font-medium">Updating deal...</span>
188
+ </div>
189
+ )}
190
+ </div>
191
+ )
192
+ }
193
+
194
+ export default PipelineKanban
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Quick Filters Component
3
+ * Reusable filter pills for CRM views
4
+ */
5
+
6
+ import React from 'react'
7
+ import '@/contents/themes/crm/styles/crm-theme.css'
8
+
9
+ export interface FilterOption {
10
+ id: string
11
+ label: string
12
+ count?: number
13
+ }
14
+
15
+ interface QuickFiltersProps {
16
+ filters: FilterOption[]
17
+ activeFilter: string
18
+ onFilterChange: (filterId: string) => void
19
+ className?: string
20
+ }
21
+
22
+ export function QuickFilters({
23
+ filters,
24
+ activeFilter,
25
+ onFilterChange,
26
+ className = '',
27
+ }: QuickFiltersProps) {
28
+ return (
29
+ <div className={`crm-filters ${className}`} data-cy="quick-filters">
30
+ {filters.map((filter) => (
31
+ <button
32
+ key={filter.id}
33
+ className={`crm-filter-pill ${activeFilter === filter.id ? 'active' : ''}`}
34
+ onClick={() => onFilterChange(filter.id)}
35
+ data-cy={`quick-filter-${filter.id}`}
36
+ >
37
+ {filter.label}
38
+ {filter.count !== undefined && (
39
+ <span className="ml-1.5 opacity-60">({filter.count})</span>
40
+ )}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ )
45
+ }
46
+
47
+ export default QuickFilters