@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.
Files changed (140) hide show
  1. package/CRM_PLAN.md +343 -0
  2. package/about.md +122 -0
  3. package/config/app.config.ts +185 -0
  4. package/config/billing.config.ts +187 -0
  5. package/config/dashboard.config.ts +372 -0
  6. package/config/dev.config.ts +55 -0
  7. package/config/features.config.ts +336 -0
  8. package/config/flows.config.ts +511 -0
  9. package/config/permissions.config.ts +297 -0
  10. package/config/theme.config.ts +111 -0
  11. package/entities/activities/activities.config.ts +61 -0
  12. package/entities/activities/activities.fields.ts +362 -0
  13. package/entities/activities/activities.service.ts +503 -0
  14. package/entities/activities/activities.types.ts +117 -0
  15. package/entities/activities/messages/en.json +123 -0
  16. package/entities/activities/messages/es.json +123 -0
  17. package/entities/activities/migrations/020_activities_table.sql +123 -0
  18. package/entities/activities/migrations/021_activities_metas.sql +114 -0
  19. package/entities/activities/migrations/022_activities_sample_data.sql +420 -0
  20. package/entities/campaigns/campaigns.config.ts +61 -0
  21. package/entities/campaigns/campaigns.fields.ts +413 -0
  22. package/entities/campaigns/campaigns.service.ts +426 -0
  23. package/entities/campaigns/campaigns.types.ts +124 -0
  24. package/entities/campaigns/messages/en.json +145 -0
  25. package/entities/campaigns/messages/es.json +145 -0
  26. package/entities/campaigns/migrations/001_campaigns_table.sql +127 -0
  27. package/entities/campaigns/migrations/002_campaigns_metas.sql +114 -0
  28. package/entities/campaigns/migrations/003_campaigns_sample_data.sql +364 -0
  29. package/entities/companies/companies.config.ts +61 -0
  30. package/entities/companies/companies.fields.ts +429 -0
  31. package/entities/companies/companies.service.ts +566 -0
  32. package/entities/companies/companies.types.ts +125 -0
  33. package/entities/companies/messages/en.json +146 -0
  34. package/entities/companies/messages/es.json +146 -0
  35. package/entities/companies/migrations/001_companies_table.sql +150 -0
  36. package/entities/companies/migrations/002_companies_metas.sql +114 -0
  37. package/entities/companies/migrations/003_companies_sample_data.sql +246 -0
  38. package/entities/contacts/contacts.config.ts +61 -0
  39. package/entities/contacts/contacts.fields.ts +359 -0
  40. package/entities/contacts/contacts.service.ts +509 -0
  41. package/entities/contacts/contacts.types.ts +108 -0
  42. package/entities/contacts/messages/en.json +117 -0
  43. package/entities/contacts/messages/es.json +117 -0
  44. package/entities/contacts/migrations/001_contacts_table.sql +134 -0
  45. package/entities/contacts/migrations/002_contacts_metas.sql +114 -0
  46. package/entities/contacts/migrations/003_contacts_sample_data.sql +421 -0
  47. package/entities/leads/leads.config.ts +61 -0
  48. package/entities/leads/leads.fields.ts +336 -0
  49. package/entities/leads/leads.service.ts +496 -0
  50. package/entities/leads/leads.types.ts +114 -0
  51. package/entities/leads/messages/en.json +132 -0
  52. package/entities/leads/messages/es.json +132 -0
  53. package/entities/leads/migrations/001_leads_table.sql +150 -0
  54. package/entities/leads/migrations/002_leads_metas.sql +120 -0
  55. package/entities/leads/migrations/003_leads_sample_data.sql +242 -0
  56. package/entities/notes/messages/en.json +114 -0
  57. package/entities/notes/messages/es.json +114 -0
  58. package/entities/notes/migrations/020_notes_table.sql +118 -0
  59. package/entities/notes/migrations/021_notes_metas.sql +114 -0
  60. package/entities/notes/migrations/022_notes_sample_data.sql +275 -0
  61. package/entities/notes/notes.config.ts +61 -0
  62. package/entities/notes/notes.fields.ts +283 -0
  63. package/entities/notes/notes.service.ts +320 -0
  64. package/entities/notes/notes.types.ts +102 -0
  65. package/entities/opportunities/messages/en.json +107 -0
  66. package/entities/opportunities/messages/es.json +107 -0
  67. package/entities/opportunities/migrations/010_opportunities_table.sql +145 -0
  68. package/entities/opportunities/migrations/011_opportunities_metas.sql +114 -0
  69. package/entities/opportunities/migrations/012_opportunities_sample_data.sql +438 -0
  70. package/entities/opportunities/opportunities.config.ts +61 -0
  71. package/entities/opportunities/opportunities.fields.ts +416 -0
  72. package/entities/opportunities/opportunities.service.ts +525 -0
  73. package/entities/opportunities/opportunities.types.ts +135 -0
  74. package/entities/pipelines/messages/en.json +115 -0
  75. package/entities/pipelines/messages/es.json +115 -0
  76. package/entities/pipelines/migrations/001_pipelines_table.sql +106 -0
  77. package/entities/pipelines/migrations/002_pipelines_metas.sql +114 -0
  78. package/entities/pipelines/migrations/003_pipelines_sample_data.sql +91 -0
  79. package/entities/pipelines/pipelines.config.ts +62 -0
  80. package/entities/pipelines/pipelines.fields.ts +193 -0
  81. package/entities/pipelines/pipelines.service.ts +383 -0
  82. package/entities/pipelines/pipelines.types.ts +78 -0
  83. package/entities/products/messages/en.json +135 -0
  84. package/entities/products/messages/es.json +135 -0
  85. package/entities/products/migrations/001_products_table.sql +117 -0
  86. package/entities/products/migrations/002_products_metas.sql +114 -0
  87. package/entities/products/migrations/003_products_sample_data.sql +247 -0
  88. package/entities/products/products.config.ts +62 -0
  89. package/entities/products/products.fields.ts +361 -0
  90. package/entities/products/products.service.ts +437 -0
  91. package/entities/products/products.types.ts +125 -0
  92. package/lib/crm-constants.ts +77 -0
  93. package/lib/crm-utils.ts +185 -0
  94. package/lib/selectors.ts +333 -0
  95. package/messages/en.json +131 -0
  96. package/messages/es.json +131 -0
  97. package/migrations/999_theme_sample_data.sql +473 -0
  98. package/package.json +18 -0
  99. package/pendings.md +205 -0
  100. package/permissions-matrix.md +216 -0
  101. package/styles/components.css +414 -0
  102. package/styles/crm-theme.css +358 -0
  103. package/styles/globals.css +576 -0
  104. package/styles/variables.css +111 -0
  105. package/templates/dashboard/(main)/activities/components/ActivityCard.tsx +169 -0
  106. package/templates/dashboard/(main)/activities/components/ActivityTimeline.tsx +165 -0
  107. package/templates/dashboard/(main)/activities/page.tsx +297 -0
  108. package/templates/dashboard/(main)/campaigns/page.tsx +373 -0
  109. package/templates/dashboard/(main)/companies/page.tsx +296 -0
  110. package/templates/dashboard/(main)/contacts/page.tsx +347 -0
  111. package/templates/dashboard/(main)/layout.tsx +98 -0
  112. package/templates/dashboard/(main)/leads/page.tsx +335 -0
  113. package/templates/dashboard/(main)/opportunities/[id]/edit/page.tsx +95 -0
  114. package/templates/dashboard/(main)/opportunities/create/page.tsx +94 -0
  115. package/templates/dashboard/(main)/opportunities/page.tsx +350 -0
  116. package/templates/dashboard/(main)/pipelines/[id]/edit/page.tsx +95 -0
  117. package/templates/dashboard/(main)/pipelines/[id]/page.tsx +143 -0
  118. package/templates/dashboard/(main)/pipelines/create/page.tsx +94 -0
  119. package/templates/dashboard/(main)/pipelines/page.tsx +234 -0
  120. package/templates/dashboard/(main)/products/[id]/edit/page.tsx +97 -0
  121. package/templates/dashboard/(main)/products/[id]/page.tsx +509 -0
  122. package/templates/dashboard/(main)/products/create/page.tsx +96 -0
  123. package/templates/dashboard/(main)/products/page.tsx +308 -0
  124. package/templates/shared/ActionButtons.tsx +41 -0
  125. package/templates/shared/CRMDashboard.tsx +519 -0
  126. package/templates/shared/CRMDataTable.tsx +441 -0
  127. package/templates/shared/CRMMetricCard.tsx +76 -0
  128. package/templates/shared/CRMMobileNav.tsx +172 -0
  129. package/templates/shared/CRMSidebar.tsx +346 -0
  130. package/templates/shared/CRMTopBar.tsx +265 -0
  131. package/templates/shared/DealCard.tsx +123 -0
  132. package/templates/shared/EntityCard.tsx +58 -0
  133. package/templates/shared/OpportunityForm.tsx +649 -0
  134. package/templates/shared/PipelineForm.tsx +367 -0
  135. package/templates/shared/PipelineKanban.tsx +194 -0
  136. package/templates/shared/QuickFilters.tsx +47 -0
  137. package/templates/shared/StageColumn.tsx +175 -0
  138. package/templates/shared/StageSelect.tsx +177 -0
  139. package/templates/shared/StagesRepeater.tsx +317 -0
  140. package/templates/shared/index.ts +9 -0
@@ -0,0 +1,441 @@
1
+ /**
2
+ * CRM Data Table Component
3
+ * Professional data table with selection, sorting, bulk actions, and pagination
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import React, { useState, useMemo } from 'react'
9
+ import {
10
+ ChevronDown,
11
+ ChevronUp,
12
+ ChevronsUpDown,
13
+ ChevronLeft,
14
+ ChevronRight,
15
+ Trash2,
16
+ Download,
17
+ MoreHorizontal,
18
+ Search,
19
+ X,
20
+ Inbox
21
+ } from 'lucide-react'
22
+ import { Button } from '@nextsparkjs/core/components/ui/button'
23
+ import { cn } from '@nextsparkjs/core/lib/utils'
24
+
25
+ // Types
26
+ export interface Column<T> {
27
+ key: keyof T | string
28
+ header: string
29
+ width?: string
30
+ sortable?: boolean
31
+ render?: (value: any, row: T) => React.ReactNode
32
+ className?: string
33
+ }
34
+
35
+ export interface BulkAction {
36
+ id: string
37
+ label: string
38
+ icon?: React.ReactNode
39
+ variant?: 'default' | 'destructive'
40
+ onClick: (selectedIds: string[]) => void | Promise<void>
41
+ }
42
+
43
+ interface CRMDataTableProps<T extends { id: string }> {
44
+ data: T[]
45
+ columns: Column<T>[]
46
+ bulkActions?: BulkAction[]
47
+ onRowClick?: (row: T) => void
48
+ isLoading?: boolean
49
+ searchPlaceholder?: string
50
+ searchFields?: (keyof T)[]
51
+ pageSize?: number
52
+ emptyMessage?: string
53
+ emptyDescription?: string
54
+ /** Entity slug for data-cy selectors (e.g., 'leads', 'contacts') */
55
+ entitySlug?: string
56
+ }
57
+
58
+ // Sorting
59
+ type SortDirection = 'asc' | 'desc' | null
60
+ interface SortState {
61
+ key: string | null
62
+ direction: SortDirection
63
+ }
64
+
65
+ export function CRMDataTable<T extends { id: string }>({
66
+ data,
67
+ columns,
68
+ bulkActions = [],
69
+ onRowClick,
70
+ isLoading = false,
71
+ searchPlaceholder = 'Search...',
72
+ searchFields = [],
73
+ pageSize = 10,
74
+ emptyMessage = 'No data found',
75
+ emptyDescription = 'Try adjusting your filters or add new records.',
76
+ entitySlug,
77
+ }: CRMDataTableProps<T>) {
78
+ // Generate data-cy attribute with entity-specific or generic prefix
79
+ const dataCy = (suffix: string) => entitySlug ? `${entitySlug}-${suffix}` : `crm-datatable-${suffix}`
80
+ // State
81
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
82
+ const [sort, setSort] = useState<SortState>({ key: null, direction: null })
83
+ const [searchQuery, setSearchQuery] = useState('')
84
+ const [currentPage, setCurrentPage] = useState(1)
85
+
86
+ // Filter and sort data
87
+ const processedData = useMemo(() => {
88
+ let result = [...data]
89
+
90
+ // Search filter
91
+ if (searchQuery && searchFields.length > 0) {
92
+ const query = searchQuery.toLowerCase()
93
+ result = result.filter((row) =>
94
+ searchFields.some((field) => {
95
+ const value = row[field]
96
+ return value && String(value).toLowerCase().includes(query)
97
+ })
98
+ )
99
+ }
100
+
101
+ // Sort
102
+ if (sort.key && sort.direction) {
103
+ result.sort((a, b) => {
104
+ const aVal = (a as any)[sort.key!]
105
+ const bVal = (b as any)[sort.key!]
106
+
107
+ if (aVal === bVal) return 0
108
+ if (aVal == null) return 1
109
+ if (bVal == null) return -1
110
+
111
+ const comparison = aVal < bVal ? -1 : 1
112
+ return sort.direction === 'asc' ? comparison : -comparison
113
+ })
114
+ }
115
+
116
+ return result
117
+ }, [data, searchQuery, searchFields, sort])
118
+
119
+ // Pagination
120
+ const totalPages = Math.ceil(processedData.length / pageSize)
121
+ const paginatedData = useMemo(() => {
122
+ const start = (currentPage - 1) * pageSize
123
+ return processedData.slice(start, start + pageSize)
124
+ }, [processedData, currentPage, pageSize])
125
+
126
+ // Selection handlers
127
+ const isAllSelected = paginatedData.length > 0 && paginatedData.every(row => selectedIds.has(row.id))
128
+ const isSomeSelected = paginatedData.some(row => selectedIds.has(row.id))
129
+
130
+ const toggleSelectAll = () => {
131
+ if (isAllSelected) {
132
+ const newSelected = new Set(selectedIds)
133
+ paginatedData.forEach(row => newSelected.delete(row.id))
134
+ setSelectedIds(newSelected)
135
+ } else {
136
+ const newSelected = new Set(selectedIds)
137
+ paginatedData.forEach(row => newSelected.add(row.id))
138
+ setSelectedIds(newSelected)
139
+ }
140
+ }
141
+
142
+ const toggleSelectRow = (id: string) => {
143
+ const newSelected = new Set(selectedIds)
144
+ if (newSelected.has(id)) {
145
+ newSelected.delete(id)
146
+ } else {
147
+ newSelected.add(id)
148
+ }
149
+ setSelectedIds(newSelected)
150
+ }
151
+
152
+ const clearSelection = () => {
153
+ setSelectedIds(new Set())
154
+ }
155
+
156
+ // Sort handler
157
+ const handleSort = (key: string) => {
158
+ setSort((prev) => {
159
+ if (prev.key !== key) {
160
+ return { key, direction: 'asc' }
161
+ }
162
+ if (prev.direction === 'asc') {
163
+ return { key, direction: 'desc' }
164
+ }
165
+ return { key: null, direction: null }
166
+ })
167
+ }
168
+
169
+ // Get cell value
170
+ const getCellValue = (row: T, column: Column<T>): any => {
171
+ const keys = String(column.key).split('.')
172
+ let value: any = row
173
+ for (const key of keys) {
174
+ value = value?.[key]
175
+ }
176
+ return value
177
+ }
178
+
179
+ // Render sort icon
180
+ const renderSortIcon = (columnKey: string) => {
181
+ if (sort.key !== columnKey) {
182
+ return <ChevronsUpDown className="w-4 h-4 text-muted-foreground/50" />
183
+ }
184
+ if (sort.direction === 'asc') {
185
+ return <ChevronUp className="w-4 h-4 text-primary" />
186
+ }
187
+ return <ChevronDown className="w-4 h-4 text-primary" />
188
+ }
189
+
190
+ // Loading skeleton
191
+ if (isLoading) {
192
+ return (
193
+ <div className="space-y-4">
194
+ <div className="h-10 w-72 bg-muted animate-pulse rounded-lg" />
195
+ <div className="border rounded-xl overflow-hidden">
196
+ <div className="bg-muted/50 h-12" />
197
+ {[1, 2, 3, 4, 5].map((i) => (
198
+ <div key={i} className="h-16 border-t bg-card animate-pulse" />
199
+ ))}
200
+ </div>
201
+ </div>
202
+ )
203
+ }
204
+
205
+ return (
206
+ <div className="space-y-4" data-cy={dataCy('list')}>
207
+ {/* Toolbar */}
208
+ <div className="flex items-center justify-between gap-4">
209
+ {/* Search */}
210
+ {searchFields.length > 0 && (
211
+ <div className="relative flex-1 max-w-sm">
212
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
213
+ <input
214
+ type="text"
215
+ value={searchQuery}
216
+ onChange={(e) => {
217
+ setSearchQuery(e.target.value)
218
+ setCurrentPage(1)
219
+ }}
220
+ placeholder={searchPlaceholder}
221
+ className="w-full pl-10 pr-10 py-2 bg-muted/50 border border-border rounded-lg text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
222
+ data-cy={dataCy('search')}
223
+ />
224
+ {searchQuery && (
225
+ <button
226
+ onClick={() => setSearchQuery('')}
227
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
228
+ >
229
+ <X className="w-4 h-4" />
230
+ </button>
231
+ )}
232
+ </div>
233
+ )}
234
+
235
+ {/* Bulk Actions Bar */}
236
+ {selectedIds.size > 0 && (
237
+ <div className="flex items-center gap-3 animate-in fade-in slide-in-from-top-2" data-cy={dataCy('bulk-actions')}>
238
+ <span className="text-sm text-muted-foreground">
239
+ {selectedIds.size} selected
240
+ </span>
241
+ <div className="flex items-center gap-2">
242
+ {bulkActions.map((action) => (
243
+ <Button
244
+ key={action.id}
245
+ variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
246
+ size="sm"
247
+ onClick={() => action.onClick(Array.from(selectedIds))}
248
+ className="gap-2"
249
+ >
250
+ {action.icon}
251
+ {action.label}
252
+ </Button>
253
+ ))}
254
+ </div>
255
+ <button
256
+ onClick={clearSelection}
257
+ className="text-sm text-muted-foreground hover:text-foreground"
258
+ >
259
+ Clear
260
+ </button>
261
+ </div>
262
+ )}
263
+ </div>
264
+
265
+ {/* Table */}
266
+ <div className="border rounded-xl overflow-hidden">
267
+ <table className="w-full" data-cy={dataCy('table')}>
268
+ <thead>
269
+ <tr className="bg-muted/50">
270
+ {/* Checkbox column */}
271
+ <th className="w-12 px-4 py-3">
272
+ <input
273
+ type="checkbox"
274
+ checked={isAllSelected}
275
+ ref={(el) => {
276
+ if (el) el.indeterminate = !isAllSelected && isSomeSelected
277
+ }}
278
+ onChange={toggleSelectAll}
279
+ className="w-4 h-4 rounded border-border text-primary focus:ring-primary/20 cursor-pointer"
280
+ />
281
+ </th>
282
+ {columns.map((column) => (
283
+ <th
284
+ key={String(column.key)}
285
+ className={cn(
286
+ 'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground',
287
+ column.sortable && 'cursor-pointer hover:text-foreground select-none',
288
+ column.className
289
+ )}
290
+ style={{ width: column.width }}
291
+ onClick={() => column.sortable && handleSort(String(column.key))}
292
+ >
293
+ <div className="flex items-center gap-1">
294
+ {column.header}
295
+ {column.sortable && renderSortIcon(String(column.key))}
296
+ </div>
297
+ </th>
298
+ ))}
299
+ {/* Actions column */}
300
+ <th className="w-12 px-4 py-3" />
301
+ </tr>
302
+ </thead>
303
+ <tbody className="divide-y divide-border">
304
+ {paginatedData.length > 0 ? (
305
+ paginatedData.map((row, index) => (
306
+ <tr
307
+ key={row.id}
308
+ data-cy={dataCy(`row-${row.id}`)}
309
+ className={cn(
310
+ 'bg-card transition-colors',
311
+ onRowClick && 'cursor-pointer hover:bg-muted/50',
312
+ selectedIds.has(row.id) && 'bg-primary/5',
313
+ 'animate-in fade-in'
314
+ )}
315
+ style={{ animationDelay: `${index * 20}ms`, animationFillMode: 'backwards' }}
316
+ >
317
+ {/* Checkbox */}
318
+ <td className="px-4 py-3">
319
+ <input
320
+ type="checkbox"
321
+ checked={selectedIds.has(row.id)}
322
+ onChange={(e) => {
323
+ e.stopPropagation()
324
+ toggleSelectRow(row.id)
325
+ }}
326
+ onClick={(e) => e.stopPropagation()}
327
+ className="w-4 h-4 rounded border-border text-primary focus:ring-primary/20 cursor-pointer"
328
+ />
329
+ </td>
330
+ {columns.map((column) => (
331
+ <td
332
+ key={String(column.key)}
333
+ className={cn('px-4 py-3 text-sm', column.className)}
334
+ onClick={() => onRowClick?.(row)}
335
+ >
336
+ {column.render
337
+ ? column.render(getCellValue(row, column), row)
338
+ : getCellValue(row, column) ?? '-'}
339
+ </td>
340
+ ))}
341
+ {/* Row actions */}
342
+ <td className="px-4 py-3">
343
+ <button
344
+ onClick={(e) => {
345
+ e.stopPropagation()
346
+ onRowClick?.(row)
347
+ }}
348
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
349
+ >
350
+ <MoreHorizontal className="w-4 h-4" />
351
+ </button>
352
+ </td>
353
+ </tr>
354
+ ))
355
+ ) : (
356
+ <tr>
357
+ <td colSpan={columns.length + 2} className="px-4 py-16">
358
+ <div className="flex flex-col items-center justify-center text-center" data-cy={dataCy('empty')}>
359
+ <div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center mb-3">
360
+ <Inbox className="w-6 h-6 text-muted-foreground" />
361
+ </div>
362
+ <p className="text-sm font-medium text-foreground mb-1">
363
+ {emptyMessage}
364
+ </p>
365
+ <p className="text-xs text-muted-foreground">
366
+ {emptyDescription}
367
+ </p>
368
+ </div>
369
+ </td>
370
+ </tr>
371
+ )}
372
+ </tbody>
373
+ </table>
374
+ </div>
375
+
376
+ {/* Pagination */}
377
+ {totalPages > 1 && (
378
+ <div className="flex items-center justify-between" data-cy={dataCy('pagination')}>
379
+ <p className="text-sm text-muted-foreground">
380
+ Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, processedData.length)} of {processedData.length} results
381
+ </p>
382
+
383
+ <div className="flex items-center gap-2">
384
+ <Button
385
+ variant="outline"
386
+ size="sm"
387
+ onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
388
+ disabled={currentPage === 1}
389
+ className="gap-1"
390
+ data-cy={dataCy('pagination-prev')}
391
+ >
392
+ <ChevronLeft className="w-4 h-4" />
393
+ Previous
394
+ </Button>
395
+
396
+ <div className="flex items-center gap-1">
397
+ {Array.from({ length: totalPages }, (_, i) => i + 1)
398
+ .filter((page) => {
399
+ if (totalPages <= 5) return true
400
+ if (page === 1 || page === totalPages) return true
401
+ return Math.abs(page - currentPage) <= 1
402
+ })
403
+ .map((page, index, arr) => (
404
+ <React.Fragment key={page}>
405
+ {index > 0 && arr[index - 1] !== page - 1 && (
406
+ <span className="px-2 text-muted-foreground">...</span>
407
+ )}
408
+ <button
409
+ onClick={() => setCurrentPage(page)}
410
+ className={cn(
411
+ 'w-8 h-8 rounded-md text-sm font-medium transition-colors',
412
+ page === currentPage
413
+ ? 'bg-primary text-primary-foreground'
414
+ : 'hover:bg-muted text-muted-foreground'
415
+ )}
416
+ >
417
+ {page}
418
+ </button>
419
+ </React.Fragment>
420
+ ))}
421
+ </div>
422
+
423
+ <Button
424
+ variant="outline"
425
+ size="sm"
426
+ onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
427
+ disabled={currentPage === totalPages}
428
+ className="gap-1"
429
+ data-cy={dataCy('pagination-next')}
430
+ >
431
+ Next
432
+ <ChevronRight className="w-4 h-4" />
433
+ </Button>
434
+ </div>
435
+ </div>
436
+ )}
437
+ </div>
438
+ )
439
+ }
440
+
441
+ export default CRMDataTable
@@ -0,0 +1,76 @@
1
+ import { Card } from '@nextsparkjs/core/components/ui/card'
2
+ import { formatCurrency, formatCompactNumber, calculatePercentageChange } from '../../lib/crm-utils'
3
+ import { TrendingUp, TrendingDown } from 'lucide-react'
4
+
5
+ interface CRMMetricCardProps {
6
+ label: string
7
+ value: number | string
8
+ previousValue?: number
9
+ format?: 'currency' | 'number' | 'compact' | 'percentage' | 'none'
10
+ currency?: string
11
+ icon?: React.ReactNode
12
+ className?: string
13
+ }
14
+
15
+ export function CRMMetricCard({
16
+ label,
17
+ value,
18
+ previousValue,
19
+ format = 'none',
20
+ currency = 'USD',
21
+ icon,
22
+ className = '',
23
+ }: CRMMetricCardProps) {
24
+ const formattedValue = typeof value === 'number'
25
+ ? formatValue(value, format, currency)
26
+ : value
27
+
28
+ const change = previousValue !== undefined && typeof value === 'number'
29
+ ? calculatePercentageChange(value, previousValue)
30
+ : null
31
+
32
+ return (
33
+ <Card className={`crm-metric-card ${className}`}>
34
+ <div className="flex items-start justify-between">
35
+ <div className="flex-1">
36
+ <p className="crm-metric-label">{label}</p>
37
+ <p className="crm-metric-value">{formattedValue}</p>
38
+
39
+ {change && (
40
+ <div className={`crm-metric-change ${change.isPositive ? 'positive' : 'negative'}`}>
41
+ {change.isPositive ? (
42
+ <TrendingUp className="inline w-4 h-4 mr-1" />
43
+ ) : (
44
+ <TrendingDown className="inline w-4 h-4 mr-1" />
45
+ )}
46
+ <span>{change.value.toFixed(1)}%</span>
47
+ </div>
48
+ )}
49
+ </div>
50
+
51
+ {icon && (
52
+ <div className="text-3xl opacity-20">
53
+ {icon}
54
+ </div>
55
+ )}
56
+ </div>
57
+ </Card>
58
+ )
59
+ }
60
+
61
+ function formatValue(value: number, format: string, currency: string): string {
62
+ switch (format) {
63
+ case 'currency':
64
+ return formatCurrency(value, currency)
65
+ case 'compact':
66
+ return formatCompactNumber(value)
67
+ case 'percentage':
68
+ return `${value.toFixed(1)}%`
69
+ case 'number':
70
+ return value.toLocaleString()
71
+ default:
72
+ return value.toString()
73
+ }
74
+ }
75
+
76
+ export default CRMMetricCard