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