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