@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,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leads Service
|
|
3
|
+
*
|
|
4
|
+
* Provides data access methods for leads.
|
|
5
|
+
* Leads is a private entity - users only see leads in their team.
|
|
6
|
+
*
|
|
7
|
+
* All methods require authentication (use RLS with userId filter).
|
|
8
|
+
*
|
|
9
|
+
* @module LeadsService
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
|
|
13
|
+
|
|
14
|
+
// Lead source type
|
|
15
|
+
export type LeadSource = 'website' | 'referral' | 'social' | 'email' | 'event' | 'cold_call' | 'other'
|
|
16
|
+
|
|
17
|
+
// Lead status type
|
|
18
|
+
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost'
|
|
19
|
+
|
|
20
|
+
// Company size type
|
|
21
|
+
export type CompanySize = 'startup' | 'small' | 'medium' | 'enterprise'
|
|
22
|
+
|
|
23
|
+
// Lead interface
|
|
24
|
+
export interface Lead {
|
|
25
|
+
id: string
|
|
26
|
+
companyName: string
|
|
27
|
+
contactName: string
|
|
28
|
+
email: string
|
|
29
|
+
phone?: string
|
|
30
|
+
website?: string
|
|
31
|
+
source: LeadSource
|
|
32
|
+
status: LeadStatus
|
|
33
|
+
score?: number
|
|
34
|
+
industry?: string
|
|
35
|
+
companySize?: CompanySize
|
|
36
|
+
budget?: number
|
|
37
|
+
assignedTo?: string
|
|
38
|
+
notes?: string
|
|
39
|
+
createdAt: string
|
|
40
|
+
updatedAt: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// List options
|
|
44
|
+
export interface LeadListOptions {
|
|
45
|
+
limit?: number
|
|
46
|
+
offset?: number
|
|
47
|
+
source?: LeadSource
|
|
48
|
+
status?: LeadStatus
|
|
49
|
+
companySize?: CompanySize
|
|
50
|
+
minScore?: number
|
|
51
|
+
assignedTo?: string
|
|
52
|
+
orderBy?: 'companyName' | 'score' | 'createdAt' | 'updatedAt'
|
|
53
|
+
orderDir?: 'asc' | 'desc'
|
|
54
|
+
teamId?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// List result
|
|
58
|
+
export interface LeadListResult {
|
|
59
|
+
leads: Lead[]
|
|
60
|
+
total: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Create data
|
|
64
|
+
export interface LeadCreateData {
|
|
65
|
+
companyName: string
|
|
66
|
+
contactName: string
|
|
67
|
+
email: string
|
|
68
|
+
phone?: string
|
|
69
|
+
website?: string
|
|
70
|
+
source: LeadSource
|
|
71
|
+
status?: LeadStatus
|
|
72
|
+
score?: number
|
|
73
|
+
industry?: string
|
|
74
|
+
companySize?: CompanySize
|
|
75
|
+
budget?: number
|
|
76
|
+
assignedTo?: string
|
|
77
|
+
notes?: string
|
|
78
|
+
teamId: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Update data
|
|
82
|
+
export interface LeadUpdateData {
|
|
83
|
+
companyName?: string
|
|
84
|
+
contactName?: string
|
|
85
|
+
email?: string
|
|
86
|
+
phone?: string
|
|
87
|
+
website?: string
|
|
88
|
+
source?: LeadSource
|
|
89
|
+
status?: LeadStatus
|
|
90
|
+
score?: number
|
|
91
|
+
industry?: string
|
|
92
|
+
companySize?: CompanySize
|
|
93
|
+
budget?: number
|
|
94
|
+
assignedTo?: string
|
|
95
|
+
notes?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Database row type
|
|
99
|
+
interface DbLead {
|
|
100
|
+
id: string
|
|
101
|
+
companyName: string
|
|
102
|
+
contactName: string
|
|
103
|
+
email: string
|
|
104
|
+
phone: string | null
|
|
105
|
+
website: string | null
|
|
106
|
+
source: LeadSource
|
|
107
|
+
status: LeadStatus
|
|
108
|
+
score: number | null
|
|
109
|
+
industry: string | null
|
|
110
|
+
companySize: CompanySize | null
|
|
111
|
+
budget: number | null
|
|
112
|
+
assignedTo: string | null
|
|
113
|
+
notes: string | null
|
|
114
|
+
createdAt: string
|
|
115
|
+
updatedAt: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class LeadsService {
|
|
119
|
+
// ============================================
|
|
120
|
+
// READ METHODS
|
|
121
|
+
// ============================================
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get a lead by ID
|
|
125
|
+
*/
|
|
126
|
+
static async getById(id: string, userId: string): Promise<Lead | null> {
|
|
127
|
+
try {
|
|
128
|
+
if (!id?.trim()) throw new Error('Lead ID is required')
|
|
129
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
130
|
+
|
|
131
|
+
const lead = await queryOneWithRLS<DbLead>(
|
|
132
|
+
`SELECT id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"
|
|
133
|
+
FROM leads WHERE id = $1`,
|
|
134
|
+
[id],
|
|
135
|
+
userId
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if (!lead) return null
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id: lead.id,
|
|
142
|
+
companyName: lead.companyName,
|
|
143
|
+
contactName: lead.contactName,
|
|
144
|
+
email: lead.email,
|
|
145
|
+
phone: lead.phone ?? undefined,
|
|
146
|
+
website: lead.website ?? undefined,
|
|
147
|
+
source: lead.source,
|
|
148
|
+
status: lead.status,
|
|
149
|
+
score: lead.score ?? undefined,
|
|
150
|
+
industry: lead.industry ?? undefined,
|
|
151
|
+
companySize: lead.companySize ?? undefined,
|
|
152
|
+
budget: lead.budget ?? undefined,
|
|
153
|
+
assignedTo: lead.assignedTo ?? undefined,
|
|
154
|
+
notes: lead.notes ?? undefined,
|
|
155
|
+
createdAt: lead.createdAt,
|
|
156
|
+
updatedAt: lead.updatedAt,
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error('LeadsService.getById error:', error)
|
|
160
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to fetch lead')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* List leads with pagination and filtering
|
|
166
|
+
*/
|
|
167
|
+
static async list(userId: string, options: LeadListOptions = {}): Promise<LeadListResult> {
|
|
168
|
+
try {
|
|
169
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
170
|
+
|
|
171
|
+
const {
|
|
172
|
+
limit = 10,
|
|
173
|
+
offset = 0,
|
|
174
|
+
source,
|
|
175
|
+
status,
|
|
176
|
+
companySize,
|
|
177
|
+
minScore,
|
|
178
|
+
assignedTo,
|
|
179
|
+
orderBy = 'createdAt',
|
|
180
|
+
orderDir = 'desc',
|
|
181
|
+
teamId,
|
|
182
|
+
} = options
|
|
183
|
+
|
|
184
|
+
// Build WHERE clause
|
|
185
|
+
const conditions: string[] = []
|
|
186
|
+
const params: unknown[] = []
|
|
187
|
+
let paramIndex = 1
|
|
188
|
+
|
|
189
|
+
if (source) {
|
|
190
|
+
conditions.push(`source = $${paramIndex++}`)
|
|
191
|
+
params.push(source)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (status) {
|
|
195
|
+
conditions.push(`status = $${paramIndex++}`)
|
|
196
|
+
params.push(status)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (companySize) {
|
|
200
|
+
conditions.push(`"companySize" = $${paramIndex++}`)
|
|
201
|
+
params.push(companySize)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (minScore !== undefined) {
|
|
205
|
+
conditions.push(`score >= $${paramIndex++}`)
|
|
206
|
+
params.push(minScore)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (assignedTo) {
|
|
210
|
+
conditions.push(`"assignedTo" = $${paramIndex++}`)
|
|
211
|
+
params.push(assignedTo)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (teamId) {
|
|
215
|
+
conditions.push(`"teamId" = $${paramIndex++}`)
|
|
216
|
+
params.push(teamId)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
220
|
+
|
|
221
|
+
// Validate orderBy
|
|
222
|
+
const validOrderBy = ['companyName', 'score', 'createdAt', 'updatedAt'].includes(orderBy) ? orderBy : 'createdAt'
|
|
223
|
+
const validOrderDir = orderDir === 'asc' ? 'ASC' : 'DESC'
|
|
224
|
+
const orderColumnMap: Record<string, string> = {
|
|
225
|
+
companyName: '"companyName"',
|
|
226
|
+
score: 'score',
|
|
227
|
+
createdAt: '"createdAt"',
|
|
228
|
+
updatedAt: '"updatedAt"',
|
|
229
|
+
}
|
|
230
|
+
const orderColumn = orderColumnMap[validOrderBy] || '"createdAt"'
|
|
231
|
+
|
|
232
|
+
// Get total count
|
|
233
|
+
const countResult = await queryWithRLS<{ count: string }>(
|
|
234
|
+
`SELECT COUNT(*)::text as count FROM leads ${whereClause}`,
|
|
235
|
+
params,
|
|
236
|
+
userId
|
|
237
|
+
)
|
|
238
|
+
const total = parseInt(countResult[0]?.count || '0', 10)
|
|
239
|
+
|
|
240
|
+
// Get leads
|
|
241
|
+
params.push(limit, offset)
|
|
242
|
+
const leads = await queryWithRLS<DbLead>(
|
|
243
|
+
`SELECT id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"
|
|
244
|
+
FROM leads ${whereClause}
|
|
245
|
+
ORDER BY ${orderColumn} ${validOrderDir}
|
|
246
|
+
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
247
|
+
params,
|
|
248
|
+
userId
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
leads: leads.map((lead) => ({
|
|
253
|
+
id: lead.id,
|
|
254
|
+
companyName: lead.companyName,
|
|
255
|
+
contactName: lead.contactName,
|
|
256
|
+
email: lead.email,
|
|
257
|
+
phone: lead.phone ?? undefined,
|
|
258
|
+
website: lead.website ?? undefined,
|
|
259
|
+
source: lead.source,
|
|
260
|
+
status: lead.status,
|
|
261
|
+
score: lead.score ?? undefined,
|
|
262
|
+
industry: lead.industry ?? undefined,
|
|
263
|
+
companySize: lead.companySize ?? undefined,
|
|
264
|
+
budget: lead.budget ?? undefined,
|
|
265
|
+
assignedTo: lead.assignedTo ?? undefined,
|
|
266
|
+
notes: lead.notes ?? undefined,
|
|
267
|
+
createdAt: lead.createdAt,
|
|
268
|
+
updatedAt: lead.updatedAt,
|
|
269
|
+
})),
|
|
270
|
+
total,
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error('LeadsService.list error:', error)
|
|
274
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to list leads')
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get leads by status
|
|
280
|
+
*/
|
|
281
|
+
static async getByStatus(userId: string, status: LeadStatus, limit = 50): Promise<Lead[]> {
|
|
282
|
+
const { leads } = await this.list(userId, {
|
|
283
|
+
status,
|
|
284
|
+
limit,
|
|
285
|
+
orderBy: 'score',
|
|
286
|
+
orderDir: 'desc',
|
|
287
|
+
})
|
|
288
|
+
return leads
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get high-score leads (score >= 80)
|
|
293
|
+
*/
|
|
294
|
+
static async getHighScore(userId: string, limit = 20): Promise<Lead[]> {
|
|
295
|
+
const { leads } = await this.list(userId, {
|
|
296
|
+
minScore: 80,
|
|
297
|
+
limit,
|
|
298
|
+
orderBy: 'score',
|
|
299
|
+
orderDir: 'desc',
|
|
300
|
+
})
|
|
301
|
+
return leads
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ============================================
|
|
305
|
+
// WRITE METHODS
|
|
306
|
+
// ============================================
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Create a new lead
|
|
310
|
+
*/
|
|
311
|
+
static async create(userId: string, data: LeadCreateData): Promise<Lead> {
|
|
312
|
+
try {
|
|
313
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
314
|
+
if (!data.companyName?.trim()) throw new Error('Company name is required')
|
|
315
|
+
if (!data.contactName?.trim()) throw new Error('Contact name is required')
|
|
316
|
+
if (!data.email?.trim()) throw new Error('Email is required')
|
|
317
|
+
if (!data.teamId?.trim()) throw new Error('Team ID is required')
|
|
318
|
+
|
|
319
|
+
const id = crypto.randomUUID()
|
|
320
|
+
const now = new Date().toISOString()
|
|
321
|
+
|
|
322
|
+
const result = await mutateWithRLS<DbLead>(
|
|
323
|
+
`INSERT INTO leads (id, "userId", "teamId", "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt")
|
|
324
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
|
325
|
+
RETURNING id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"`,
|
|
326
|
+
[
|
|
327
|
+
id,
|
|
328
|
+
userId,
|
|
329
|
+
data.teamId,
|
|
330
|
+
data.companyName,
|
|
331
|
+
data.contactName,
|
|
332
|
+
data.email,
|
|
333
|
+
data.phone || null,
|
|
334
|
+
data.website || null,
|
|
335
|
+
data.source,
|
|
336
|
+
data.status || 'new',
|
|
337
|
+
data.score || null,
|
|
338
|
+
data.industry || null,
|
|
339
|
+
data.companySize || null,
|
|
340
|
+
data.budget || null,
|
|
341
|
+
data.assignedTo || null,
|
|
342
|
+
data.notes || null,
|
|
343
|
+
now,
|
|
344
|
+
now,
|
|
345
|
+
],
|
|
346
|
+
userId
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if (!result.rows[0]) throw new Error('Failed to create lead')
|
|
350
|
+
|
|
351
|
+
const lead = result.rows[0]
|
|
352
|
+
return {
|
|
353
|
+
id: lead.id,
|
|
354
|
+
companyName: lead.companyName,
|
|
355
|
+
contactName: lead.contactName,
|
|
356
|
+
email: lead.email,
|
|
357
|
+
phone: lead.phone ?? undefined,
|
|
358
|
+
website: lead.website ?? undefined,
|
|
359
|
+
source: lead.source,
|
|
360
|
+
status: lead.status,
|
|
361
|
+
score: lead.score ?? undefined,
|
|
362
|
+
industry: lead.industry ?? undefined,
|
|
363
|
+
companySize: lead.companySize ?? undefined,
|
|
364
|
+
budget: lead.budget ?? undefined,
|
|
365
|
+
assignedTo: lead.assignedTo ?? undefined,
|
|
366
|
+
notes: lead.notes ?? undefined,
|
|
367
|
+
createdAt: lead.createdAt,
|
|
368
|
+
updatedAt: lead.updatedAt,
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error('LeadsService.create error:', error)
|
|
372
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to create lead')
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Update an existing lead
|
|
378
|
+
*/
|
|
379
|
+
static async update(userId: string, id: string, data: LeadUpdateData): Promise<Lead> {
|
|
380
|
+
try {
|
|
381
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
382
|
+
if (!id?.trim()) throw new Error('Lead ID is required')
|
|
383
|
+
|
|
384
|
+
const updates: string[] = []
|
|
385
|
+
const values: unknown[] = []
|
|
386
|
+
let paramIndex = 1
|
|
387
|
+
|
|
388
|
+
if (data.companyName !== undefined) {
|
|
389
|
+
updates.push(`"companyName" = $${paramIndex++}`)
|
|
390
|
+
values.push(data.companyName)
|
|
391
|
+
}
|
|
392
|
+
if (data.contactName !== undefined) {
|
|
393
|
+
updates.push(`"contactName" = $${paramIndex++}`)
|
|
394
|
+
values.push(data.contactName)
|
|
395
|
+
}
|
|
396
|
+
if (data.email !== undefined) {
|
|
397
|
+
updates.push(`email = $${paramIndex++}`)
|
|
398
|
+
values.push(data.email)
|
|
399
|
+
}
|
|
400
|
+
if (data.phone !== undefined) {
|
|
401
|
+
updates.push(`phone = $${paramIndex++}`)
|
|
402
|
+
values.push(data.phone || null)
|
|
403
|
+
}
|
|
404
|
+
if (data.website !== undefined) {
|
|
405
|
+
updates.push(`website = $${paramIndex++}`)
|
|
406
|
+
values.push(data.website || null)
|
|
407
|
+
}
|
|
408
|
+
if (data.source !== undefined) {
|
|
409
|
+
updates.push(`source = $${paramIndex++}`)
|
|
410
|
+
values.push(data.source)
|
|
411
|
+
}
|
|
412
|
+
if (data.status !== undefined) {
|
|
413
|
+
updates.push(`status = $${paramIndex++}`)
|
|
414
|
+
values.push(data.status)
|
|
415
|
+
}
|
|
416
|
+
if (data.score !== undefined) {
|
|
417
|
+
updates.push(`score = $${paramIndex++}`)
|
|
418
|
+
values.push(data.score)
|
|
419
|
+
}
|
|
420
|
+
if (data.industry !== undefined) {
|
|
421
|
+
updates.push(`industry = $${paramIndex++}`)
|
|
422
|
+
values.push(data.industry || null)
|
|
423
|
+
}
|
|
424
|
+
if (data.companySize !== undefined) {
|
|
425
|
+
updates.push(`"companySize" = $${paramIndex++}`)
|
|
426
|
+
values.push(data.companySize || null)
|
|
427
|
+
}
|
|
428
|
+
if (data.budget !== undefined) {
|
|
429
|
+
updates.push(`budget = $${paramIndex++}`)
|
|
430
|
+
values.push(data.budget)
|
|
431
|
+
}
|
|
432
|
+
if (data.assignedTo !== undefined) {
|
|
433
|
+
updates.push(`"assignedTo" = $${paramIndex++}`)
|
|
434
|
+
values.push(data.assignedTo || null)
|
|
435
|
+
}
|
|
436
|
+
if (data.notes !== undefined) {
|
|
437
|
+
updates.push(`notes = $${paramIndex++}`)
|
|
438
|
+
values.push(data.notes || null)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (updates.length === 0) throw new Error('No fields to update')
|
|
442
|
+
|
|
443
|
+
updates.push(`"updatedAt" = $${paramIndex++}`)
|
|
444
|
+
values.push(new Date().toISOString())
|
|
445
|
+
values.push(id)
|
|
446
|
+
|
|
447
|
+
const result = await mutateWithRLS<DbLead>(
|
|
448
|
+
`UPDATE leads SET ${updates.join(', ')} WHERE id = $${paramIndex}
|
|
449
|
+
RETURNING id, "companyName", "contactName", email, phone, website, source, status, score, industry, "companySize", budget, "assignedTo", notes, "createdAt", "updatedAt"`,
|
|
450
|
+
values,
|
|
451
|
+
userId
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if (!result.rows[0]) throw new Error('Lead not found or update failed')
|
|
455
|
+
|
|
456
|
+
const lead = result.rows[0]
|
|
457
|
+
return {
|
|
458
|
+
id: lead.id,
|
|
459
|
+
companyName: lead.companyName,
|
|
460
|
+
contactName: lead.contactName,
|
|
461
|
+
email: lead.email,
|
|
462
|
+
phone: lead.phone ?? undefined,
|
|
463
|
+
website: lead.website ?? undefined,
|
|
464
|
+
source: lead.source,
|
|
465
|
+
status: lead.status,
|
|
466
|
+
score: lead.score ?? undefined,
|
|
467
|
+
industry: lead.industry ?? undefined,
|
|
468
|
+
companySize: lead.companySize ?? undefined,
|
|
469
|
+
budget: lead.budget ?? undefined,
|
|
470
|
+
assignedTo: lead.assignedTo ?? undefined,
|
|
471
|
+
notes: lead.notes ?? undefined,
|
|
472
|
+
createdAt: lead.createdAt,
|
|
473
|
+
updatedAt: lead.updatedAt,
|
|
474
|
+
}
|
|
475
|
+
} catch (error) {
|
|
476
|
+
console.error('LeadsService.update error:', error)
|
|
477
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to update lead')
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Delete a lead
|
|
483
|
+
*/
|
|
484
|
+
static async delete(userId: string, id: string): Promise<boolean> {
|
|
485
|
+
try {
|
|
486
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
487
|
+
if (!id?.trim()) throw new Error('Lead ID is required')
|
|
488
|
+
|
|
489
|
+
const result = await mutateWithRLS(`DELETE FROM leads WHERE id = $1`, [id], userId)
|
|
490
|
+
return result.rowCount > 0
|
|
491
|
+
} catch (error) {
|
|
492
|
+
console.error('LeadsService.delete error:', error)
|
|
493
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to delete lead')
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lead Service Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the LeadService.
|
|
5
|
+
* Defines types for lead management including source tracking,
|
|
6
|
+
* status progression, and qualification scoring.
|
|
7
|
+
*
|
|
8
|
+
* @module LeadTypes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Type literals for select fields
|
|
12
|
+
export type LeadSource =
|
|
13
|
+
| 'web'
|
|
14
|
+
| 'referral'
|
|
15
|
+
| 'cold_call'
|
|
16
|
+
| 'trade_show'
|
|
17
|
+
| 'social_media'
|
|
18
|
+
| 'email'
|
|
19
|
+
| 'advertising'
|
|
20
|
+
| 'partner'
|
|
21
|
+
| 'other'
|
|
22
|
+
|
|
23
|
+
export type LeadStatus =
|
|
24
|
+
| 'new'
|
|
25
|
+
| 'contacted'
|
|
26
|
+
| 'qualified'
|
|
27
|
+
| 'proposal'
|
|
28
|
+
| 'negotiation'
|
|
29
|
+
| 'converted'
|
|
30
|
+
| 'lost'
|
|
31
|
+
|
|
32
|
+
export type CompanySize = '1-10' | '11-50' | '51-200' | '201-500' | '500+'
|
|
33
|
+
|
|
34
|
+
// Main entity interface
|
|
35
|
+
export interface Lead {
|
|
36
|
+
id: string
|
|
37
|
+
teamId: string
|
|
38
|
+
companyName: string
|
|
39
|
+
contactName: string
|
|
40
|
+
email: string
|
|
41
|
+
phone?: string | null
|
|
42
|
+
website?: string | null
|
|
43
|
+
source?: LeadSource | null
|
|
44
|
+
status?: LeadStatus | null
|
|
45
|
+
score?: number | null
|
|
46
|
+
industry?: string | null
|
|
47
|
+
companySize?: CompanySize | null
|
|
48
|
+
budget?: number | null
|
|
49
|
+
assignedTo?: string | null
|
|
50
|
+
notes?: string | null
|
|
51
|
+
createdAt: string
|
|
52
|
+
updatedAt: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// List options
|
|
56
|
+
export interface LeadListOptions {
|
|
57
|
+
limit?: number
|
|
58
|
+
offset?: number
|
|
59
|
+
teamId?: string
|
|
60
|
+
source?: LeadSource
|
|
61
|
+
status?: LeadStatus
|
|
62
|
+
companySize?: CompanySize
|
|
63
|
+
assignedTo?: string
|
|
64
|
+
orderBy?:
|
|
65
|
+
| 'companyName'
|
|
66
|
+
| 'contactName'
|
|
67
|
+
| 'email'
|
|
68
|
+
| 'score'
|
|
69
|
+
| 'industry'
|
|
70
|
+
| 'createdAt'
|
|
71
|
+
| 'updatedAt'
|
|
72
|
+
orderDir?: 'asc' | 'desc'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// List result
|
|
76
|
+
export interface LeadListResult {
|
|
77
|
+
leads: Lead[]
|
|
78
|
+
total: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Create data (required fields + teamId + optional fields)
|
|
82
|
+
export interface LeadCreateData {
|
|
83
|
+
companyName: string
|
|
84
|
+
contactName: string
|
|
85
|
+
email: string
|
|
86
|
+
teamId: string
|
|
87
|
+
phone?: string
|
|
88
|
+
website?: string
|
|
89
|
+
source?: LeadSource
|
|
90
|
+
status?: LeadStatus
|
|
91
|
+
score?: number
|
|
92
|
+
industry?: string
|
|
93
|
+
companySize?: CompanySize
|
|
94
|
+
budget?: number
|
|
95
|
+
assignedTo?: string
|
|
96
|
+
notes?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Update data (all fields optional)
|
|
100
|
+
export interface LeadUpdateData {
|
|
101
|
+
companyName?: string
|
|
102
|
+
contactName?: string
|
|
103
|
+
email?: string
|
|
104
|
+
phone?: string | null
|
|
105
|
+
website?: string | null
|
|
106
|
+
source?: LeadSource | null
|
|
107
|
+
status?: LeadStatus | null
|
|
108
|
+
score?: number | null
|
|
109
|
+
industry?: string | null
|
|
110
|
+
companySize?: CompanySize | null
|
|
111
|
+
budget?: number | null
|
|
112
|
+
assignedTo?: string | null
|
|
113
|
+
notes?: string | null
|
|
114
|
+
}
|