@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,175 @@
1
+ /**
2
+ * Stage Column Component
3
+ * Professional Kanban column with drag-drop support
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import React, { useState } from 'react'
9
+ import { DealCard, type Deal } from './DealCard'
10
+ import { formatCurrency } from '@/themes/crm/lib/crm-utils'
11
+ import { Inbox, Plus } from 'lucide-react'
12
+ import { cn } from '@nextsparkjs/core/lib/utils'
13
+
14
+ export interface Stage {
15
+ id: string
16
+ name: string
17
+ order: number
18
+ probability: number
19
+ color: string
20
+ }
21
+
22
+ interface StageColumnProps {
23
+ stage: Stage
24
+ deals: Deal[]
25
+ onDealClick?: (deal: Deal) => void
26
+ onDrop?: (dealId: string, stageId: string) => void
27
+ onAddDeal?: (stageId: string) => void
28
+ }
29
+
30
+ export function StageColumn({ stage, deals, onDealClick, onDrop, onAddDeal }: StageColumnProps) {
31
+ const [isDragOver, setIsDragOver] = useState(false)
32
+ const totalAmount = deals.reduce((sum, deal) => sum + deal.amount, 0)
33
+ const avgCurrency = deals[0]?.currency || 'USD'
34
+ const weightedValue = deals.reduce((sum, deal) => sum + (deal.amount * deal.probability / 100), 0)
35
+
36
+ const handleDragOver = (e: React.DragEvent) => {
37
+ e.preventDefault()
38
+ setIsDragOver(true)
39
+ }
40
+
41
+ const handleDragLeave = () => {
42
+ setIsDragOver(false)
43
+ }
44
+
45
+ const handleDrop = (e: React.DragEvent) => {
46
+ e.preventDefault()
47
+ setIsDragOver(false)
48
+ const dealId = e.dataTransfer.getData('dealId')
49
+ if (dealId && onDrop) {
50
+ onDrop(dealId, stage.id)
51
+ }
52
+ }
53
+
54
+ return (
55
+ <div
56
+ className={cn(
57
+ 'flex flex-col w-[320px] shrink-0 rounded-xl transition-all duration-200 border border-border/60 bg-muted/40 shadow-sm',
58
+ isDragOver && 'ring-2 ring-primary ring-offset-2 bg-primary/5'
59
+ )}
60
+ onDragOver={handleDragOver}
61
+ onDragLeave={handleDragLeave}
62
+ onDrop={handleDrop}
63
+ data-cy={`stage-column-${stage.id}`}
64
+ >
65
+ {/* Header */}
66
+ <div className="bg-card border-b border-border/60 rounded-t-xl p-4" data-cy={`stage-column-header-${stage.id}`}>
67
+ <div className="flex items-start justify-between mb-3">
68
+ <div className="flex items-center gap-2">
69
+ <div
70
+ className="w-3 h-3 rounded-full"
71
+ style={{ backgroundColor: stage.color }}
72
+ />
73
+ <h3 className="font-semibold text-sm text-foreground">
74
+ {stage.name}
75
+ </h3>
76
+ </div>
77
+ <span className="px-2 py-0.5 bg-muted rounded-md text-xs font-medium text-muted-foreground">
78
+ {deals.length}
79
+ </span>
80
+ </div>
81
+
82
+ <div className="grid grid-cols-2 gap-2 text-xs">
83
+ <div>
84
+ <p className="text-muted-foreground mb-0.5">Total Value</p>
85
+ <p className="font-semibold text-foreground">
86
+ {formatCurrency(totalAmount, avgCurrency)}
87
+ </p>
88
+ </div>
89
+ <div>
90
+ <p className="text-muted-foreground mb-0.5">Weighted</p>
91
+ <p className="font-semibold text-primary">
92
+ {formatCurrency(weightedValue, avgCurrency)}
93
+ </p>
94
+ </div>
95
+ </div>
96
+
97
+ {/* Progress bar showing probability */}
98
+ <div className="mt-3 h-1 bg-muted rounded-full overflow-hidden">
99
+ <div
100
+ className="h-full rounded-full transition-all duration-300"
101
+ style={{
102
+ width: `${stage.probability}%`,
103
+ backgroundColor: stage.color
104
+ }}
105
+ />
106
+ </div>
107
+ </div>
108
+
109
+ {/* Deals list */}
110
+ <div
111
+ className={cn(
112
+ 'flex-1 bg-muted/50 rounded-b-xl p-3 space-y-2 min-h-[300px] max-h-[calc(100vh-320px)] overflow-y-auto',
113
+ isDragOver && 'bg-primary/10'
114
+ )}
115
+ data-cy={`stage-column-deals-${stage.id}`}
116
+ >
117
+ {deals.map((deal, index) => (
118
+ <div
119
+ key={deal.id}
120
+ draggable
121
+ className="animate-in fade-in slide-in-from-top-2"
122
+ style={{ animationDelay: `${index * 30}ms`, animationFillMode: 'backwards' }}
123
+ onDragStart={(e) => {
124
+ e.dataTransfer.setData('dealId', deal.id)
125
+ e.currentTarget.style.opacity = '0.5'
126
+ }}
127
+ onDragEnd={(e) => {
128
+ e.currentTarget.style.opacity = '1'
129
+ }}
130
+ >
131
+ <DealCard
132
+ deal={deal}
133
+ onClick={() => onDealClick?.(deal)}
134
+ />
135
+ </div>
136
+ ))}
137
+
138
+ {deals.length === 0 && (
139
+ <div className="flex flex-col items-center justify-center py-12 text-center" data-cy={`stage-column-empty-${stage.id}`}>
140
+ <div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center mb-3">
141
+ <Inbox className="w-6 h-6 text-muted-foreground" />
142
+ </div>
143
+ <p className="text-sm text-muted-foreground mb-3">
144
+ No deals in this stage
145
+ </p>
146
+ {onAddDeal && (
147
+ <button
148
+ onClick={() => onAddDeal(stage.id)}
149
+ className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline"
150
+ data-cy={`stage-column-add-deal-${stage.id}`}
151
+ >
152
+ <Plus className="w-3.5 h-3.5" />
153
+ Add deal
154
+ </button>
155
+ )}
156
+ </div>
157
+ )}
158
+
159
+ {/* Add deal button at bottom */}
160
+ {deals.length > 0 && onAddDeal && (
161
+ <button
162
+ onClick={() => onAddDeal(stage.id)}
163
+ className="w-full py-3 border-2 border-dashed border-border rounded-xl text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors flex items-center justify-center gap-2"
164
+ data-cy={`stage-column-add-deal-${stage.id}`}
165
+ >
166
+ <Plus className="w-4 h-4" />
167
+ Add deal
168
+ </button>
169
+ )}
170
+ </div>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ export default StageColumn
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Stage Select Component
3
+ * Dynamic select for pipeline stages based on selected pipeline
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import React, { useState, useEffect, useCallback } from 'react'
9
+ import { fetchWithTeam } from '@nextsparkjs/core/lib/api/entities'
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from '@nextsparkjs/core/components/ui/select'
17
+ import { Label } from '@nextsparkjs/core/components/ui/label'
18
+ import { Loader2 } from 'lucide-react'
19
+ import { cn } from '@nextsparkjs/core/lib/utils'
20
+
21
+ interface Stage {
22
+ id: string
23
+ name: string
24
+ order: number
25
+ probability: number
26
+ color: string
27
+ }
28
+
29
+ interface StageSelectProps {
30
+ value?: string
31
+ onChange: (stageId: string, stage?: Stage) => void
32
+ pipelineId?: string
33
+ disabled?: boolean
34
+ placeholder?: string
35
+ label?: string
36
+ required?: boolean
37
+ className?: string
38
+ dataCy?: string
39
+ }
40
+
41
+ export function StageSelect({
42
+ value,
43
+ onChange,
44
+ pipelineId,
45
+ disabled = false,
46
+ placeholder = 'Select stage...',
47
+ label,
48
+ required = false,
49
+ className,
50
+ dataCy,
51
+ }: StageSelectProps) {
52
+ const [stages, setStages] = useState<Stage[]>([])
53
+ const [isLoading, setIsLoading] = useState(false)
54
+ const [error, setError] = useState<string | null>(null)
55
+
56
+ // Fetch stages when pipelineId changes
57
+ const fetchStages = useCallback(async (id: string) => {
58
+ setIsLoading(true)
59
+ setError(null)
60
+
61
+ try {
62
+ const response = await fetchWithTeam(`/api/v1/pipelines/${id}`)
63
+ if (!response.ok) {
64
+ throw new Error('Failed to fetch pipeline')
65
+ }
66
+
67
+ const result = await response.json()
68
+ const pipeline = result.data
69
+
70
+ if (pipeline?.stages && Array.isArray(pipeline.stages)) {
71
+ // Sort stages by order
72
+ const sortedStages = [...pipeline.stages].sort((a, b) => a.order - b.order)
73
+ setStages(sortedStages)
74
+ } else {
75
+ setStages([])
76
+ }
77
+ } catch (err) {
78
+ console.error('Error fetching pipeline stages:', err)
79
+ setError('Failed to load stages')
80
+ setStages([])
81
+ } finally {
82
+ setIsLoading(false)
83
+ }
84
+ }, [])
85
+
86
+ // Watch for pipelineId changes
87
+ useEffect(() => {
88
+ if (pipelineId) {
89
+ fetchStages(pipelineId)
90
+ } else {
91
+ setStages([])
92
+ // Clear value when pipeline is deselected
93
+ if (value) {
94
+ onChange('')
95
+ }
96
+ }
97
+ }, [pipelineId, fetchStages])
98
+
99
+ // When stages change, check if current value is still valid
100
+ useEffect(() => {
101
+ if (value && stages.length > 0) {
102
+ const stageExists = stages.some(s => s.id === value)
103
+ if (!stageExists) {
104
+ // Current value is not in the new stages list, clear it
105
+ onChange('')
106
+ }
107
+ }
108
+ }, [stages, value, onChange])
109
+
110
+ const handleChange = (stageId: string) => {
111
+ const selectedStage = stages.find(s => s.id === stageId)
112
+ onChange(stageId, selectedStage)
113
+ }
114
+
115
+ const isDisabled = disabled || !pipelineId || isLoading
116
+
117
+ return (
118
+ <div className={cn('space-y-1.5', className)} data-cy={dataCy}>
119
+ {label && (
120
+ <Label className="text-sm font-medium">
121
+ {label} {required && <span className="text-destructive">*</span>}
122
+ </Label>
123
+ )}
124
+
125
+ <Select
126
+ value={value || ''}
127
+ onValueChange={handleChange}
128
+ disabled={isDisabled}
129
+ >
130
+ <SelectTrigger className={cn(isLoading && 'opacity-70')}>
131
+ {isLoading ? (
132
+ <div className="flex items-center gap-2">
133
+ <Loader2 className="w-4 h-4 animate-spin" />
134
+ <span className="text-muted-foreground">Loading stages...</span>
135
+ </div>
136
+ ) : (
137
+ <SelectValue placeholder={
138
+ !pipelineId
139
+ ? 'Select pipeline first'
140
+ : error
141
+ ? 'Error loading stages'
142
+ : placeholder
143
+ } />
144
+ )}
145
+ </SelectTrigger>
146
+ <SelectContent>
147
+ {stages.length === 0 ? (
148
+ <div className="p-2 text-sm text-muted-foreground text-center">
149
+ {error || 'No stages available'}
150
+ </div>
151
+ ) : (
152
+ stages.map((stage) => (
153
+ <SelectItem key={stage.id} value={stage.id}>
154
+ <div className="flex items-center gap-2">
155
+ <div
156
+ className="w-2.5 h-2.5 rounded-full shrink-0"
157
+ style={{ backgroundColor: stage.color }}
158
+ />
159
+ <span>{stage.name}</span>
160
+ <span className="text-xs text-muted-foreground ml-auto">
161
+ {stage.probability}%
162
+ </span>
163
+ </div>
164
+ </SelectItem>
165
+ ))
166
+ )}
167
+ </SelectContent>
168
+ </Select>
169
+
170
+ {error && (
171
+ <p className="text-xs text-destructive">{error}</p>
172
+ )}
173
+ </div>
174
+ )
175
+ }
176
+
177
+ export default StageSelect
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Stages Repeater Component
3
+ * Custom repeater field for managing pipeline stages
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import React, { useState, useCallback } from 'react'
9
+ import { Button } from '@nextsparkjs/core/components/ui/button'
10
+ import { Input } from '@nextsparkjs/core/components/ui/input'
11
+ import { Label } from '@nextsparkjs/core/components/ui/label'
12
+ import {
13
+ Plus,
14
+ Trash2,
15
+ GripVertical,
16
+ ChevronUp,
17
+ ChevronDown,
18
+ Palette
19
+ } from 'lucide-react'
20
+ import { cn } from '@nextsparkjs/core/lib/utils'
21
+
22
+ export interface Stage {
23
+ id: string
24
+ name: string
25
+ order: number
26
+ probability: number
27
+ color: string
28
+ }
29
+
30
+ interface StagesRepeaterProps {
31
+ value: Stage[]
32
+ onChange: (stages: Stage[]) => void
33
+ disabled?: boolean
34
+ }
35
+
36
+ // Predefined colors for stages
37
+ const STAGE_COLORS = [
38
+ { value: '#6366f1', label: 'Indigo' },
39
+ { value: '#8b5cf6', label: 'Violet' },
40
+ { value: '#a855f7', label: 'Purple' },
41
+ { value: '#ec4899', label: 'Pink' },
42
+ { value: '#f43f5e', label: 'Rose' },
43
+ { value: '#ef4444', label: 'Red' },
44
+ { value: '#f97316', label: 'Orange' },
45
+ { value: '#f59e0b', label: 'Amber' },
46
+ { value: '#eab308', label: 'Yellow' },
47
+ { value: '#84cc16', label: 'Lime' },
48
+ { value: '#22c55e', label: 'Green' },
49
+ { value: '#10b981', label: 'Emerald' },
50
+ { value: '#14b8a6', label: 'Teal' },
51
+ { value: '#06b6d4', label: 'Cyan' },
52
+ { value: '#0ea5e9', label: 'Sky' },
53
+ { value: '#3b82f6', label: 'Blue' },
54
+ ]
55
+
56
+ // Default stages for new pipelines
57
+ export const DEFAULT_STAGES: Stage[] = [
58
+ { id: crypto.randomUUID(), name: 'Lead', order: 0, probability: 10, color: '#6366f1' },
59
+ { id: crypto.randomUUID(), name: 'Qualified', order: 1, probability: 25, color: '#8b5cf6' },
60
+ { id: crypto.randomUUID(), name: 'Proposal', order: 2, probability: 50, color: '#f59e0b' },
61
+ { id: crypto.randomUUID(), name: 'Negotiation', order: 3, probability: 75, color: '#f97316' },
62
+ { id: crypto.randomUUID(), name: 'Closed Won', order: 4, probability: 100, color: '#22c55e' },
63
+ ]
64
+
65
+ export function StagesRepeater({ value, onChange, disabled = false }: StagesRepeaterProps) {
66
+ const [expandedColorPicker, setExpandedColorPicker] = useState<string | null>(null)
67
+
68
+ const addStage = useCallback(() => {
69
+ const newStage: Stage = {
70
+ id: crypto.randomUUID(),
71
+ name: '',
72
+ order: value.length,
73
+ probability: 50,
74
+ color: STAGE_COLORS[value.length % STAGE_COLORS.length].value,
75
+ }
76
+ onChange([...value, newStage])
77
+ }, [value, onChange])
78
+
79
+ const removeStage = useCallback((id: string) => {
80
+ const filtered = value.filter(s => s.id !== id)
81
+ // Recalculate order
82
+ const reordered = filtered.map((s, idx) => ({ ...s, order: idx }))
83
+ onChange(reordered)
84
+ }, [value, onChange])
85
+
86
+ const updateStage = useCallback((id: string, updates: Partial<Stage>) => {
87
+ onChange(value.map(s => s.id === id ? { ...s, ...updates } : s))
88
+ }, [value, onChange])
89
+
90
+ const moveStage = useCallback((id: string, direction: 'up' | 'down') => {
91
+ const index = value.findIndex(s => s.id === id)
92
+ if (index === -1) return
93
+ if (direction === 'up' && index === 0) return
94
+ if (direction === 'down' && index === value.length - 1) return
95
+
96
+ const newStages = [...value]
97
+ const swapIndex = direction === 'up' ? index - 1 : index + 1
98
+
99
+ // Swap items
100
+ ;[newStages[index], newStages[swapIndex]] = [newStages[swapIndex], newStages[index]]
101
+
102
+ // Update order values
103
+ const reordered = newStages.map((s, idx) => ({ ...s, order: idx }))
104
+ onChange(reordered)
105
+ }, [value, onChange])
106
+
107
+ return (
108
+ <div className="space-y-4" data-cy="stages-repeater">
109
+ <div className="flex items-center justify-between">
110
+ <Label className="text-sm font-medium">Pipeline Stages</Label>
111
+ <span className="text-xs text-muted-foreground" data-cy="stages-repeater-count">
112
+ {value.length} stage{value.length !== 1 ? 's' : ''}
113
+ </span>
114
+ </div>
115
+
116
+ {/* Stages List */}
117
+ <div className="space-y-2" data-cy="stages-repeater-list">
118
+ {value.map((stage, index) => (
119
+ <div
120
+ key={stage.id}
121
+ data-cy={`stages-repeater-item-${stage.id}`}
122
+ className={cn(
123
+ 'group relative bg-card border rounded-xl p-4 transition-all',
124
+ 'hover:border-primary/30 hover:shadow-sm',
125
+ disabled && 'opacity-60 pointer-events-none'
126
+ )}
127
+ >
128
+ {/* Color indicator */}
129
+ <div
130
+ className="absolute left-0 top-0 bottom-0 w-1 rounded-l-xl"
131
+ style={{ backgroundColor: stage.color }}
132
+ />
133
+
134
+ <div className="flex items-start gap-3 pl-2">
135
+ {/* Drag handle / Order controls */}
136
+ <div className="flex flex-col items-center gap-0.5 pt-1">
137
+ <button
138
+ type="button"
139
+ onClick={() => moveStage(stage.id, 'up')}
140
+ disabled={index === 0 || disabled}
141
+ className={cn(
142
+ 'p-0.5 rounded hover:bg-muted transition-colors',
143
+ index === 0 && 'opacity-30 cursor-not-allowed'
144
+ )}
145
+ >
146
+ <ChevronUp className="w-4 h-4 text-muted-foreground" />
147
+ </button>
148
+ <GripVertical className="w-4 h-4 text-muted-foreground/50" />
149
+ <button
150
+ type="button"
151
+ onClick={() => moveStage(stage.id, 'down')}
152
+ disabled={index === value.length - 1 || disabled}
153
+ className={cn(
154
+ 'p-0.5 rounded hover:bg-muted transition-colors',
155
+ index === value.length - 1 && 'opacity-30 cursor-not-allowed'
156
+ )}
157
+ >
158
+ <ChevronDown className="w-4 h-4 text-muted-foreground" />
159
+ </button>
160
+ </div>
161
+
162
+ {/* Stage fields */}
163
+ <div className="flex-1 grid grid-cols-12 gap-3">
164
+ {/* Order badge */}
165
+ <div className="col-span-1 flex items-center justify-center">
166
+ <span className="w-7 h-7 rounded-full bg-muted flex items-center justify-center text-xs font-semibold text-muted-foreground">
167
+ {index + 1}
168
+ </span>
169
+ </div>
170
+
171
+ {/* Name */}
172
+ <div className="col-span-5">
173
+ <Label className="text-xs text-muted-foreground mb-1 block">
174
+ Stage Name
175
+ </Label>
176
+ <Input
177
+ value={stage.name}
178
+ onChange={(e) => updateStage(stage.id, { name: e.target.value })}
179
+ placeholder="e.g., Qualified Lead"
180
+ disabled={disabled}
181
+ className="h-9"
182
+ data-cy={`stages-repeater-name-${stage.id}`}
183
+ />
184
+ </div>
185
+
186
+ {/* Probability */}
187
+ <div className="col-span-3">
188
+ <Label className="text-xs text-muted-foreground mb-1 block">
189
+ Win Probability
190
+ </Label>
191
+ <div className="relative">
192
+ <Input
193
+ type="number"
194
+ min={0}
195
+ max={100}
196
+ value={stage.probability}
197
+ onChange={(e) => updateStage(stage.id, { probability: parseInt(e.target.value) || 0 })}
198
+ disabled={disabled}
199
+ className="h-9 pr-8"
200
+ data-cy={`stages-repeater-probability-${stage.id}`}
201
+ />
202
+ <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
203
+ %
204
+ </span>
205
+ </div>
206
+ </div>
207
+
208
+ {/* Color picker */}
209
+ <div className="col-span-2">
210
+ <Label className="text-xs text-muted-foreground mb-1 block">
211
+ Color
212
+ </Label>
213
+ <div className="relative">
214
+ <button
215
+ type="button"
216
+ onClick={() => setExpandedColorPicker(
217
+ expandedColorPicker === stage.id ? null : stage.id
218
+ )}
219
+ disabled={disabled}
220
+ className={cn(
221
+ 'w-full h-9 rounded-md border flex items-center gap-2 px-3 transition-colors',
222
+ 'hover:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/20'
223
+ )}
224
+ >
225
+ <div
226
+ className="w-4 h-4 rounded-full border"
227
+ style={{ backgroundColor: stage.color }}
228
+ />
229
+ <Palette className="w-3.5 h-3.5 text-muted-foreground" />
230
+ </button>
231
+
232
+ {/* Color picker dropdown */}
233
+ {expandedColorPicker === stage.id && (
234
+ <div className="absolute top-full left-0 mt-1 p-2 bg-popover border rounded-lg shadow-lg z-50 w-[200px]">
235
+ <div className="grid grid-cols-4 gap-1.5">
236
+ {STAGE_COLORS.map((color) => (
237
+ <button
238
+ key={color.value}
239
+ type="button"
240
+ onClick={() => {
241
+ updateStage(stage.id, { color: color.value })
242
+ setExpandedColorPicker(null)
243
+ }}
244
+ className={cn(
245
+ 'w-8 h-8 rounded-md border-2 transition-all hover:scale-110',
246
+ stage.color === color.value
247
+ ? 'border-foreground scale-110'
248
+ : 'border-transparent'
249
+ )}
250
+ style={{ backgroundColor: color.value }}
251
+ title={color.label}
252
+ />
253
+ ))}
254
+ </div>
255
+ </div>
256
+ )}
257
+ </div>
258
+ </div>
259
+
260
+ {/* Delete button */}
261
+ <div className="col-span-1 flex items-end justify-end">
262
+ <Button
263
+ type="button"
264
+ variant="ghost"
265
+ size="sm"
266
+ onClick={() => removeStage(stage.id)}
267
+ disabled={disabled || value.length <= 1}
268
+ className={cn(
269
+ 'h-9 w-9 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10',
270
+ value.length <= 1 && 'opacity-30 cursor-not-allowed'
271
+ )}
272
+ data-cy={`stages-repeater-delete-${stage.id}`}
273
+ >
274
+ <Trash2 className="w-4 h-4" />
275
+ </Button>
276
+ </div>
277
+ </div>
278
+ </div>
279
+
280
+ {/* Probability bar preview */}
281
+ <div className="mt-3 pl-10">
282
+ <div className="h-1.5 bg-muted rounded-full overflow-hidden">
283
+ <div
284
+ className="h-full rounded-full transition-all duration-300"
285
+ style={{
286
+ width: `${stage.probability}%`,
287
+ backgroundColor: stage.color
288
+ }}
289
+ />
290
+ </div>
291
+ </div>
292
+ </div>
293
+ ))}
294
+ </div>
295
+
296
+ {/* Add stage button */}
297
+ <Button
298
+ type="button"
299
+ variant="outline"
300
+ onClick={addStage}
301
+ disabled={disabled}
302
+ className="w-full border-dashed gap-2"
303
+ data-cy="stages-repeater-add-btn"
304
+ >
305
+ <Plus className="w-4 h-4" />
306
+ Add Stage
307
+ </Button>
308
+
309
+ {/* Help text */}
310
+ <p className="text-xs text-muted-foreground">
311
+ Stages represent the steps in your sales process. Set probability to reflect the likelihood of closing a deal at each stage.
312
+ </p>
313
+ </div>
314
+ )
315
+ }
316
+
317
+ export default StagesRepeater
@@ -0,0 +1,9 @@
1
+ /**
2
+ * CRM Shared Components
3
+ * Export all shared components from a single file
4
+ */
5
+
6
+ export { CRMMetricCard } from './CRMMetricCard'
7
+ export { EntityCard } from './EntityCard'
8
+ export { QuickFilters, type FilterOption } from './QuickFilters'
9
+ export { ActionButtons, type ActionButton } from './ActionButtons'