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