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