@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,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Products Service
|
|
3
|
+
*
|
|
4
|
+
* Provides data access methods for products.
|
|
5
|
+
* Products is a private entity - users only see products in their team.
|
|
6
|
+
*
|
|
7
|
+
* All methods require authentication (use RLS with userId filter).
|
|
8
|
+
*
|
|
9
|
+
* @module ProductsService
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
|
|
13
|
+
|
|
14
|
+
// Product interface
|
|
15
|
+
export interface Product {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
sku?: string
|
|
19
|
+
description?: string
|
|
20
|
+
price: number
|
|
21
|
+
currency?: string
|
|
22
|
+
category?: string
|
|
23
|
+
isActive?: boolean
|
|
24
|
+
createdAt: string
|
|
25
|
+
updatedAt: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// List options
|
|
29
|
+
export interface ProductListOptions {
|
|
30
|
+
limit?: number
|
|
31
|
+
offset?: number
|
|
32
|
+
category?: string
|
|
33
|
+
isActive?: boolean
|
|
34
|
+
minPrice?: number
|
|
35
|
+
maxPrice?: number
|
|
36
|
+
orderBy?: 'name' | 'price' | 'sku' | 'createdAt' | 'updatedAt'
|
|
37
|
+
orderDir?: 'asc' | 'desc'
|
|
38
|
+
teamId?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// List result
|
|
42
|
+
export interface ProductListResult {
|
|
43
|
+
products: Product[]
|
|
44
|
+
total: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Create data
|
|
48
|
+
export interface ProductCreateData {
|
|
49
|
+
name: string
|
|
50
|
+
sku?: string
|
|
51
|
+
description?: string
|
|
52
|
+
price: number
|
|
53
|
+
currency?: string
|
|
54
|
+
category?: string
|
|
55
|
+
isActive?: boolean
|
|
56
|
+
teamId: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Update data
|
|
60
|
+
export interface ProductUpdateData {
|
|
61
|
+
name?: string
|
|
62
|
+
sku?: string
|
|
63
|
+
description?: string
|
|
64
|
+
price?: number
|
|
65
|
+
currency?: string
|
|
66
|
+
category?: string
|
|
67
|
+
isActive?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Database row type
|
|
71
|
+
interface DbProduct {
|
|
72
|
+
id: string
|
|
73
|
+
name: string
|
|
74
|
+
sku: string | null
|
|
75
|
+
description: string | null
|
|
76
|
+
price: number
|
|
77
|
+
currency: string | null
|
|
78
|
+
category: string | null
|
|
79
|
+
isActive: boolean | null
|
|
80
|
+
createdAt: string
|
|
81
|
+
updatedAt: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class ProductsService {
|
|
85
|
+
// ============================================
|
|
86
|
+
// READ METHODS
|
|
87
|
+
// ============================================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get a product by ID
|
|
91
|
+
*/
|
|
92
|
+
static async getById(id: string, userId: string): Promise<Product | null> {
|
|
93
|
+
try {
|
|
94
|
+
if (!id?.trim()) throw new Error('Product ID is required')
|
|
95
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
96
|
+
|
|
97
|
+
const product = await queryOneWithRLS<DbProduct>(
|
|
98
|
+
`SELECT id, name, sku, description, price, currency, category, "isActive", "createdAt", "updatedAt"
|
|
99
|
+
FROM products WHERE id = $1`,
|
|
100
|
+
[id],
|
|
101
|
+
userId
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if (!product) return null
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: product.id,
|
|
108
|
+
name: product.name,
|
|
109
|
+
sku: product.sku ?? undefined,
|
|
110
|
+
description: product.description ?? undefined,
|
|
111
|
+
price: product.price,
|
|
112
|
+
currency: product.currency ?? undefined,
|
|
113
|
+
category: product.category ?? undefined,
|
|
114
|
+
isActive: product.isActive ?? undefined,
|
|
115
|
+
createdAt: product.createdAt,
|
|
116
|
+
updatedAt: product.updatedAt,
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error('ProductsService.getById error:', error)
|
|
120
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to fetch product')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get a product by SKU
|
|
126
|
+
*/
|
|
127
|
+
static async getBySku(sku: string, userId: string): Promise<Product | null> {
|
|
128
|
+
try {
|
|
129
|
+
if (!sku?.trim()) throw new Error('SKU is required')
|
|
130
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
131
|
+
|
|
132
|
+
const product = await queryOneWithRLS<DbProduct>(
|
|
133
|
+
`SELECT id, name, sku, description, price, currency, category, "isActive", "createdAt", "updatedAt"
|
|
134
|
+
FROM products WHERE sku = $1`,
|
|
135
|
+
[sku],
|
|
136
|
+
userId
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if (!product) return null
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
id: product.id,
|
|
143
|
+
name: product.name,
|
|
144
|
+
sku: product.sku ?? undefined,
|
|
145
|
+
description: product.description ?? undefined,
|
|
146
|
+
price: product.price,
|
|
147
|
+
currency: product.currency ?? undefined,
|
|
148
|
+
category: product.category ?? undefined,
|
|
149
|
+
isActive: product.isActive ?? undefined,
|
|
150
|
+
createdAt: product.createdAt,
|
|
151
|
+
updatedAt: product.updatedAt,
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('ProductsService.getBySku error:', error)
|
|
155
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to fetch product')
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* List products with pagination and filtering
|
|
161
|
+
*/
|
|
162
|
+
static async list(userId: string, options: ProductListOptions = {}): Promise<ProductListResult> {
|
|
163
|
+
try {
|
|
164
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
165
|
+
|
|
166
|
+
const {
|
|
167
|
+
limit = 10,
|
|
168
|
+
offset = 0,
|
|
169
|
+
category,
|
|
170
|
+
isActive,
|
|
171
|
+
minPrice,
|
|
172
|
+
maxPrice,
|
|
173
|
+
orderBy = 'createdAt',
|
|
174
|
+
orderDir = 'desc',
|
|
175
|
+
teamId,
|
|
176
|
+
} = options
|
|
177
|
+
|
|
178
|
+
// Build WHERE clause
|
|
179
|
+
const conditions: string[] = []
|
|
180
|
+
const params: unknown[] = []
|
|
181
|
+
let paramIndex = 1
|
|
182
|
+
|
|
183
|
+
if (category) {
|
|
184
|
+
conditions.push(`category = $${paramIndex++}`)
|
|
185
|
+
params.push(category)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isActive !== undefined) {
|
|
189
|
+
conditions.push(`"isActive" = $${paramIndex++}`)
|
|
190
|
+
params.push(isActive)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (minPrice !== undefined) {
|
|
194
|
+
conditions.push(`price >= $${paramIndex++}`)
|
|
195
|
+
params.push(minPrice)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (maxPrice !== undefined) {
|
|
199
|
+
conditions.push(`price <= $${paramIndex++}`)
|
|
200
|
+
params.push(maxPrice)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (teamId) {
|
|
204
|
+
conditions.push(`"teamId" = $${paramIndex++}`)
|
|
205
|
+
params.push(teamId)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
209
|
+
|
|
210
|
+
// Validate orderBy
|
|
211
|
+
const validOrderBy = ['name', 'price', 'sku', 'createdAt', 'updatedAt'].includes(orderBy) ? orderBy : 'createdAt'
|
|
212
|
+
const validOrderDir = orderDir === 'asc' ? 'ASC' : 'DESC'
|
|
213
|
+
const orderColumnMap: Record<string, string> = {
|
|
214
|
+
name: 'name',
|
|
215
|
+
price: 'price',
|
|
216
|
+
sku: 'sku',
|
|
217
|
+
createdAt: '"createdAt"',
|
|
218
|
+
updatedAt: '"updatedAt"',
|
|
219
|
+
}
|
|
220
|
+
const orderColumn = orderColumnMap[validOrderBy] || '"createdAt"'
|
|
221
|
+
|
|
222
|
+
// Get total count
|
|
223
|
+
const countResult = await queryWithRLS<{ count: string }>(
|
|
224
|
+
`SELECT COUNT(*)::text as count FROM products ${whereClause}`,
|
|
225
|
+
params,
|
|
226
|
+
userId
|
|
227
|
+
)
|
|
228
|
+
const total = parseInt(countResult[0]?.count || '0', 10)
|
|
229
|
+
|
|
230
|
+
// Get products
|
|
231
|
+
params.push(limit, offset)
|
|
232
|
+
const products = await queryWithRLS<DbProduct>(
|
|
233
|
+
`SELECT id, name, sku, description, price, currency, category, "isActive", "createdAt", "updatedAt"
|
|
234
|
+
FROM products ${whereClause}
|
|
235
|
+
ORDER BY ${orderColumn} ${validOrderDir}
|
|
236
|
+
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
|
237
|
+
params,
|
|
238
|
+
userId
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
products: products.map((product) => ({
|
|
243
|
+
id: product.id,
|
|
244
|
+
name: product.name,
|
|
245
|
+
sku: product.sku ?? undefined,
|
|
246
|
+
description: product.description ?? undefined,
|
|
247
|
+
price: product.price,
|
|
248
|
+
currency: product.currency ?? undefined,
|
|
249
|
+
category: product.category ?? undefined,
|
|
250
|
+
isActive: product.isActive ?? undefined,
|
|
251
|
+
createdAt: product.createdAt,
|
|
252
|
+
updatedAt: product.updatedAt,
|
|
253
|
+
})),
|
|
254
|
+
total,
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error('ProductsService.list error:', error)
|
|
258
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to list products')
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get active products
|
|
264
|
+
*/
|
|
265
|
+
static async getActive(userId: string, limit = 50): Promise<Product[]> {
|
|
266
|
+
const { products } = await this.list(userId, {
|
|
267
|
+
isActive: true,
|
|
268
|
+
limit,
|
|
269
|
+
orderBy: 'name',
|
|
270
|
+
orderDir: 'asc',
|
|
271
|
+
})
|
|
272
|
+
return products
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Get products by category
|
|
277
|
+
*/
|
|
278
|
+
static async getByCategory(userId: string, category: string, limit = 50): Promise<Product[]> {
|
|
279
|
+
const { products } = await this.list(userId, {
|
|
280
|
+
category,
|
|
281
|
+
limit,
|
|
282
|
+
orderBy: 'name',
|
|
283
|
+
orderDir: 'asc',
|
|
284
|
+
})
|
|
285
|
+
return products
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ============================================
|
|
289
|
+
// WRITE METHODS
|
|
290
|
+
// ============================================
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Create a new product
|
|
294
|
+
*/
|
|
295
|
+
static async create(userId: string, data: ProductCreateData): Promise<Product> {
|
|
296
|
+
try {
|
|
297
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
298
|
+
if (!data.name?.trim()) throw new Error('Product name is required')
|
|
299
|
+
if (data.price === undefined || data.price < 0) throw new Error('Valid price is required')
|
|
300
|
+
if (!data.teamId?.trim()) throw new Error('Team ID is required')
|
|
301
|
+
|
|
302
|
+
const id = crypto.randomUUID()
|
|
303
|
+
const now = new Date().toISOString()
|
|
304
|
+
|
|
305
|
+
const result = await mutateWithRLS<DbProduct>(
|
|
306
|
+
`INSERT INTO products (id, "userId", "teamId", name, sku, description, price, currency, category, "isActive", "createdAt", "updatedAt")
|
|
307
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
308
|
+
RETURNING id, name, sku, description, price, currency, category, "isActive", "createdAt", "updatedAt"`,
|
|
309
|
+
[
|
|
310
|
+
id,
|
|
311
|
+
userId,
|
|
312
|
+
data.teamId,
|
|
313
|
+
data.name,
|
|
314
|
+
data.sku || null,
|
|
315
|
+
data.description || null,
|
|
316
|
+
data.price,
|
|
317
|
+
data.currency || 'USD',
|
|
318
|
+
data.category || null,
|
|
319
|
+
data.isActive !== undefined ? data.isActive : true,
|
|
320
|
+
now,
|
|
321
|
+
now,
|
|
322
|
+
],
|
|
323
|
+
userId
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if (!result.rows[0]) throw new Error('Failed to create product')
|
|
327
|
+
|
|
328
|
+
const product = result.rows[0]
|
|
329
|
+
return {
|
|
330
|
+
id: product.id,
|
|
331
|
+
name: product.name,
|
|
332
|
+
sku: product.sku ?? undefined,
|
|
333
|
+
description: product.description ?? undefined,
|
|
334
|
+
price: product.price,
|
|
335
|
+
currency: product.currency ?? undefined,
|
|
336
|
+
category: product.category ?? undefined,
|
|
337
|
+
isActive: product.isActive ?? undefined,
|
|
338
|
+
createdAt: product.createdAt,
|
|
339
|
+
updatedAt: product.updatedAt,
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
console.error('ProductsService.create error:', error)
|
|
343
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to create product')
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Update an existing product
|
|
349
|
+
*/
|
|
350
|
+
static async update(userId: string, id: string, data: ProductUpdateData): Promise<Product> {
|
|
351
|
+
try {
|
|
352
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
353
|
+
if (!id?.trim()) throw new Error('Product ID is required')
|
|
354
|
+
|
|
355
|
+
const updates: string[] = []
|
|
356
|
+
const values: unknown[] = []
|
|
357
|
+
let paramIndex = 1
|
|
358
|
+
|
|
359
|
+
if (data.name !== undefined) {
|
|
360
|
+
updates.push(`name = $${paramIndex++}`)
|
|
361
|
+
values.push(data.name)
|
|
362
|
+
}
|
|
363
|
+
if (data.sku !== undefined) {
|
|
364
|
+
updates.push(`sku = $${paramIndex++}`)
|
|
365
|
+
values.push(data.sku || null)
|
|
366
|
+
}
|
|
367
|
+
if (data.description !== undefined) {
|
|
368
|
+
updates.push(`description = $${paramIndex++}`)
|
|
369
|
+
values.push(data.description || null)
|
|
370
|
+
}
|
|
371
|
+
if (data.price !== undefined) {
|
|
372
|
+
updates.push(`price = $${paramIndex++}`)
|
|
373
|
+
values.push(data.price)
|
|
374
|
+
}
|
|
375
|
+
if (data.currency !== undefined) {
|
|
376
|
+
updates.push(`currency = $${paramIndex++}`)
|
|
377
|
+
values.push(data.currency || null)
|
|
378
|
+
}
|
|
379
|
+
if (data.category !== undefined) {
|
|
380
|
+
updates.push(`category = $${paramIndex++}`)
|
|
381
|
+
values.push(data.category || null)
|
|
382
|
+
}
|
|
383
|
+
if (data.isActive !== undefined) {
|
|
384
|
+
updates.push(`"isActive" = $${paramIndex++}`)
|
|
385
|
+
values.push(data.isActive)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (updates.length === 0) throw new Error('No fields to update')
|
|
389
|
+
|
|
390
|
+
updates.push(`"updatedAt" = $${paramIndex++}`)
|
|
391
|
+
values.push(new Date().toISOString())
|
|
392
|
+
values.push(id)
|
|
393
|
+
|
|
394
|
+
const result = await mutateWithRLS<DbProduct>(
|
|
395
|
+
`UPDATE products SET ${updates.join(', ')} WHERE id = $${paramIndex}
|
|
396
|
+
RETURNING id, name, sku, description, price, currency, category, "isActive", "createdAt", "updatedAt"`,
|
|
397
|
+
values,
|
|
398
|
+
userId
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if (!result.rows[0]) throw new Error('Product not found or update failed')
|
|
402
|
+
|
|
403
|
+
const product = result.rows[0]
|
|
404
|
+
return {
|
|
405
|
+
id: product.id,
|
|
406
|
+
name: product.name,
|
|
407
|
+
sku: product.sku ?? undefined,
|
|
408
|
+
description: product.description ?? undefined,
|
|
409
|
+
price: product.price,
|
|
410
|
+
currency: product.currency ?? undefined,
|
|
411
|
+
category: product.category ?? undefined,
|
|
412
|
+
isActive: product.isActive ?? undefined,
|
|
413
|
+
createdAt: product.createdAt,
|
|
414
|
+
updatedAt: product.updatedAt,
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
console.error('ProductsService.update error:', error)
|
|
418
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to update product')
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Delete a product
|
|
424
|
+
*/
|
|
425
|
+
static async delete(userId: string, id: string): Promise<boolean> {
|
|
426
|
+
try {
|
|
427
|
+
if (!userId?.trim()) throw new Error('User ID is required')
|
|
428
|
+
if (!id?.trim()) throw new Error('Product ID is required')
|
|
429
|
+
|
|
430
|
+
const result = await mutateWithRLS(`DELETE FROM products WHERE id = $1`, [id], userId)
|
|
431
|
+
return result.rowCount > 0
|
|
432
|
+
} catch (error) {
|
|
433
|
+
console.error('ProductsService.delete error:', error)
|
|
434
|
+
throw new Error(error instanceof Error ? error.message : 'Failed to delete product')
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product Service Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the ProductService.
|
|
5
|
+
* Defines types for product catalog management including pricing,
|
|
6
|
+
* inventory units, and product classification.
|
|
7
|
+
*
|
|
8
|
+
* @module ProductTypes
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Type literals for select fields
|
|
12
|
+
export type ProductType = 'product' | 'service' | 'subscription' | 'bundle' | 'addon'
|
|
13
|
+
|
|
14
|
+
export type Currency =
|
|
15
|
+
| 'USD'
|
|
16
|
+
| 'EUR'
|
|
17
|
+
| 'GBP'
|
|
18
|
+
| 'MXN'
|
|
19
|
+
| 'CAD'
|
|
20
|
+
| 'AUD'
|
|
21
|
+
| 'JPY'
|
|
22
|
+
| 'CNY'
|
|
23
|
+
| 'INR'
|
|
24
|
+
| 'BRL'
|
|
25
|
+
|
|
26
|
+
export type ProductUnit =
|
|
27
|
+
| 'piece'
|
|
28
|
+
| 'hour'
|
|
29
|
+
| 'day'
|
|
30
|
+
| 'week'
|
|
31
|
+
| 'month'
|
|
32
|
+
| 'year'
|
|
33
|
+
| 'kg'
|
|
34
|
+
| 'lb'
|
|
35
|
+
| 'meter'
|
|
36
|
+
| 'foot'
|
|
37
|
+
| 'license'
|
|
38
|
+
| 'user'
|
|
39
|
+
|
|
40
|
+
// Main entity interface
|
|
41
|
+
export interface Product {
|
|
42
|
+
id: string
|
|
43
|
+
teamId: string
|
|
44
|
+
code: string
|
|
45
|
+
name: string
|
|
46
|
+
category?: string | null
|
|
47
|
+
type?: ProductType | null
|
|
48
|
+
description?: string | null
|
|
49
|
+
price: number
|
|
50
|
+
cost?: number | null
|
|
51
|
+
currency?: Currency | null
|
|
52
|
+
unit?: ProductUnit | null
|
|
53
|
+
isActive?: boolean | null
|
|
54
|
+
minimumQuantity?: number | null
|
|
55
|
+
image?: string | null
|
|
56
|
+
brochureUrl?: string | null
|
|
57
|
+
commissionRate?: number | null
|
|
58
|
+
createdAt: string
|
|
59
|
+
updatedAt: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// List options
|
|
63
|
+
export interface ProductListOptions {
|
|
64
|
+
limit?: number
|
|
65
|
+
offset?: number
|
|
66
|
+
teamId?: string
|
|
67
|
+
category?: string
|
|
68
|
+
type?: ProductType
|
|
69
|
+
currency?: Currency
|
|
70
|
+
unit?: ProductUnit
|
|
71
|
+
isActive?: boolean
|
|
72
|
+
orderBy?:
|
|
73
|
+
| 'code'
|
|
74
|
+
| 'name'
|
|
75
|
+
| 'category'
|
|
76
|
+
| 'price'
|
|
77
|
+
| 'cost'
|
|
78
|
+
| 'minimumQuantity'
|
|
79
|
+
| 'createdAt'
|
|
80
|
+
| 'updatedAt'
|
|
81
|
+
orderDir?: 'asc' | 'desc'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// List result
|
|
85
|
+
export interface ProductListResult {
|
|
86
|
+
products: Product[]
|
|
87
|
+
total: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Create data (required fields + teamId + optional fields)
|
|
91
|
+
export interface ProductCreateData {
|
|
92
|
+
code: string
|
|
93
|
+
name: string
|
|
94
|
+
price: number
|
|
95
|
+
teamId: string
|
|
96
|
+
category?: string
|
|
97
|
+
type?: ProductType
|
|
98
|
+
description?: string
|
|
99
|
+
cost?: number
|
|
100
|
+
currency?: Currency
|
|
101
|
+
unit?: ProductUnit
|
|
102
|
+
isActive?: boolean
|
|
103
|
+
minimumQuantity?: number
|
|
104
|
+
image?: string
|
|
105
|
+
brochureUrl?: string
|
|
106
|
+
commissionRate?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Update data (all fields optional)
|
|
110
|
+
export interface ProductUpdateData {
|
|
111
|
+
code?: string
|
|
112
|
+
name?: string
|
|
113
|
+
category?: string | null
|
|
114
|
+
type?: ProductType | null
|
|
115
|
+
description?: string | null
|
|
116
|
+
price?: number
|
|
117
|
+
cost?: number | null
|
|
118
|
+
currency?: Currency | null
|
|
119
|
+
unit?: ProductUnit | null
|
|
120
|
+
isActive?: boolean | null
|
|
121
|
+
minimumQuantity?: number | null
|
|
122
|
+
image?: string | null
|
|
123
|
+
brochureUrl?: string | null
|
|
124
|
+
commissionRate?: number | null
|
|
125
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRM Theme Constants
|
|
3
|
+
* Shared constants for the CRM theme
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const PIPELINE_STAGE_COLORS = {
|
|
7
|
+
qualification: '#3B82F6',
|
|
8
|
+
analysis: '#10B981',
|
|
9
|
+
proposal: '#F59E0B',
|
|
10
|
+
negotiation: '#8B5CF6',
|
|
11
|
+
won: '#059669',
|
|
12
|
+
lost: '#DC2626',
|
|
13
|
+
} as const
|
|
14
|
+
|
|
15
|
+
export const ACTIVITY_TYPE_COLORS = {
|
|
16
|
+
call: '#3B82F6',
|
|
17
|
+
email: '#8B5CF6',
|
|
18
|
+
meeting: '#10B981',
|
|
19
|
+
task: '#F59E0B',
|
|
20
|
+
note: '#6B7280',
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export const ACTIVITY_TYPE_ICONS = {
|
|
24
|
+
call: '📞',
|
|
25
|
+
email: '📧',
|
|
26
|
+
meeting: '🤝',
|
|
27
|
+
task: '✅',
|
|
28
|
+
note: '📝',
|
|
29
|
+
demo: '🎬',
|
|
30
|
+
presentation: '📊',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
export const PRIORITY_LEVELS = {
|
|
34
|
+
low: { label: 'Low', color: '#9CA3AF' },
|
|
35
|
+
medium: { label: 'Medium', color: '#F59E0B' },
|
|
36
|
+
high: { label: 'High', color: '#EF4444' },
|
|
37
|
+
urgent: { label: 'Urgent', color: '#DC2626' },
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
export const LEAD_STATUS_OPTIONS = [
|
|
41
|
+
{ value: 'new', label: 'New', color: '#3B82F6' },
|
|
42
|
+
{ value: 'contacted', label: 'Contacted', color: '#10B981' },
|
|
43
|
+
{ value: 'qualified', label: 'Qualified', color: '#F59E0B' },
|
|
44
|
+
{ value: 'proposal', label: 'Proposal', color: '#8B5CF6' },
|
|
45
|
+
{ value: 'negotiation', label: 'Negotiation', color: '#EC4899' },
|
|
46
|
+
{ value: 'converted', label: 'Converted', color: '#059669' },
|
|
47
|
+
{ value: 'lost', label: 'Lost', color: '#DC2626' },
|
|
48
|
+
] as const
|
|
49
|
+
|
|
50
|
+
export const OPPORTUNITY_STATUS_OPTIONS = [
|
|
51
|
+
{ value: 'open', label: 'Open', color: '#3B82F6' },
|
|
52
|
+
{ value: 'won', label: 'Won', color: '#059669' },
|
|
53
|
+
{ value: 'lost', label: 'Lost', color: '#DC2626' },
|
|
54
|
+
{ value: 'abandoned', label: 'Abandoned', color: '#6B7280' },
|
|
55
|
+
] as const
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_PIPELINE_STAGES = [
|
|
58
|
+
{ order: 1, name: 'Qualification', probability: 10, color: '#3B82F6' },
|
|
59
|
+
{ order: 2, name: 'Needs Analysis', probability: 25, color: '#10B981' },
|
|
60
|
+
{ order: 3, name: 'Proposal', probability: 50, color: '#F59E0B' },
|
|
61
|
+
{ order: 4, name: 'Negotiation', probability: 75, color: '#8B5CF6' },
|
|
62
|
+
{ order: 5, name: 'Closed Won', probability: 100, color: '#059669' },
|
|
63
|
+
{ order: 6, name: 'Closed Lost', probability: 0, color: '#EF4444' },
|
|
64
|
+
] as const
|
|
65
|
+
|
|
66
|
+
export const CRM_ROUTES = {
|
|
67
|
+
dashboard: '/dashboard/crm',
|
|
68
|
+
pipelines: '/dashboard/crm/pipelines',
|
|
69
|
+
opportunities: '/dashboard/crm/opportunities',
|
|
70
|
+
activities: '/dashboard/crm/activities',
|
|
71
|
+
leads: '/dashboard/crm/leads',
|
|
72
|
+
contacts: '/dashboard/crm/contacts',
|
|
73
|
+
companies: '/dashboard/crm/companies',
|
|
74
|
+
campaigns: '/dashboard/crm/campaigns',
|
|
75
|
+
products: '/dashboard/crm/products',
|
|
76
|
+
notes: '/dashboard/crm/notes',
|
|
77
|
+
} as const
|