@startsimpli/ui 0.1.0
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/README.md +537 -0
- package/package.json +80 -0
- package/src/components/index.ts +50 -0
- package/src/components/navigation/sidebar.tsx +178 -0
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +70 -0
- package/src/components/ui/card.tsx +68 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +12 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/index.ts +24 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +31 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/components/unified-table/UnifiedTable.tsx +553 -0
- package/src/components/unified-table/__tests__/components/BulkActionBar.test.tsx +477 -0
- package/src/components/unified-table/__tests__/components/ExportButton.test.tsx +467 -0
- package/src/components/unified-table/__tests__/components/InlineEditCell.test.tsx +159 -0
- package/src/components/unified-table/__tests__/components/SavedViewsDropdown.test.tsx +128 -0
- package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +374 -0
- package/src/components/unified-table/__tests__/hooks/useColumnReorder.test.ts +191 -0
- package/src/components/unified-table/__tests__/hooks/useColumnResize.test.ts +122 -0
- package/src/components/unified-table/__tests__/hooks/useColumnVisibility.test.ts +594 -0
- package/src/components/unified-table/__tests__/hooks/useFilters.test.ts +460 -0
- package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +439 -0
- package/src/components/unified-table/__tests__/hooks/useResponsive.test.ts +421 -0
- package/src/components/unified-table/__tests__/hooks/useSelection.test.ts +367 -0
- package/src/components/unified-table/__tests__/hooks/useTableKeyboard.test.ts +803 -0
- package/src/components/unified-table/__tests__/hooks/useTableState.test.ts +210 -0
- package/src/components/unified-table/__tests__/integration/table-with-selection.test.tsx +624 -0
- package/src/components/unified-table/__tests__/utils/export.test.ts +427 -0
- package/src/components/unified-table/components/BulkActionBar/index.tsx +119 -0
- package/src/components/unified-table/components/DataTableCore/index.tsx +473 -0
- package/src/components/unified-table/components/InlineEditCell/index.tsx +159 -0
- package/src/components/unified-table/components/MobileView/Card.tsx +218 -0
- package/src/components/unified-table/components/MobileView/CardActions.tsx +126 -0
- package/src/components/unified-table/components/MobileView/README.md +411 -0
- package/src/components/unified-table/components/MobileView/index.tsx +77 -0
- package/src/components/unified-table/components/MobileView/types.ts +77 -0
- package/src/components/unified-table/components/TableFilters/index.tsx +298 -0
- package/src/components/unified-table/components/TablePagination/index.tsx +157 -0
- package/src/components/unified-table/components/Toolbar/ExportButton.tsx +229 -0
- package/src/components/unified-table/components/Toolbar/SavedViewsDropdown.tsx +251 -0
- package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +146 -0
- package/src/components/unified-table/components/Toolbar/index.tsx +3 -0
- package/src/components/unified-table/hooks/index.ts +21 -0
- package/src/components/unified-table/hooks/useColumnReorder.ts +90 -0
- package/src/components/unified-table/hooks/useColumnResize.ts +123 -0
- package/src/components/unified-table/hooks/useColumnVisibility.ts +92 -0
- package/src/components/unified-table/hooks/useFilters.ts +53 -0
- package/src/components/unified-table/hooks/usePagination.ts +120 -0
- package/src/components/unified-table/hooks/useResponsive.ts +50 -0
- package/src/components/unified-table/hooks/useSelection.ts +152 -0
- package/src/components/unified-table/hooks/useTableKeyboard.ts +206 -0
- package/src/components/unified-table/hooks/useTablePreferences.ts +198 -0
- package/src/components/unified-table/hooks/useTableState.ts +103 -0
- package/src/components/unified-table/hooks/useTableURL.test.tsx +921 -0
- package/src/components/unified-table/hooks/useTableURL.ts +301 -0
- package/src/components/unified-table/index.ts +16 -0
- package/src/components/unified-table/types.ts +393 -0
- package/src/components/unified-table/utils/export.ts +236 -0
- package/src/components/unified-table/utils/index.ts +4 -0
- package/src/components/unified-table/utils/renderers.ts +105 -0
- package/src/components/unified-table/utils/themes.ts +87 -0
- package/src/components/unified-table/utils/validation.ts +122 -0
- package/src/index.ts +6 -0
- package/src/lib/utils.ts +1 -0
- package/src/theme/contract.ts +46 -0
- package/src/theme/index.ts +9 -0
- package/src/theme/tailwind.config.js +70 -0
- package/src/theme/tailwind.preset.ts +93 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +91 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Button } from '../../../ui/button'
|
|
4
|
+
import { Badge } from '../../../ui/badge'
|
|
5
|
+
import { Input } from '../../../ui/input'
|
|
6
|
+
import { Label } from '../../../ui/label'
|
|
7
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
|
|
8
|
+
import { Filter, XCircle, ChevronDown, ChevronUp } from 'lucide-react'
|
|
9
|
+
import { FilterConfig, FilterSection, UseFiltersReturn } from '../../types'
|
|
10
|
+
import { cn } from '../../../../lib/utils'
|
|
11
|
+
import { useState, useCallback } from 'react'
|
|
12
|
+
|
|
13
|
+
interface TableFiltersProps {
|
|
14
|
+
config: FilterConfig
|
|
15
|
+
filters: UseFiltersReturn
|
|
16
|
+
className?: string
|
|
17
|
+
defaultExpanded?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TableFilters({ config, filters, className, defaultExpanded = false }: TableFiltersProps) {
|
|
21
|
+
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
|
22
|
+
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
|
23
|
+
|
|
24
|
+
const toggleSection = (sectionId: string) => {
|
|
25
|
+
setExpandedSections((prev) => {
|
|
26
|
+
const next = new Set(prev)
|
|
27
|
+
if (next.has(sectionId)) {
|
|
28
|
+
next.delete(sectionId)
|
|
29
|
+
} else {
|
|
30
|
+
next.add(sectionId)
|
|
31
|
+
}
|
|
32
|
+
return next
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const renderChipsFilter = (section: FilterConfig['sections'][0]) => {
|
|
37
|
+
if (section.type !== 'chips' || !section.filters) return null
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
41
|
+
{section.filters.map((filter) => {
|
|
42
|
+
if (!filter.options) return null
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div key={filter.id} className="flex items-center gap-2">
|
|
46
|
+
<Label className="text-sm text-muted-foreground whitespace-nowrap">
|
|
47
|
+
{filter.label}:
|
|
48
|
+
</Label>
|
|
49
|
+
<div className="flex gap-1">
|
|
50
|
+
{filter.options.map((option) => {
|
|
51
|
+
const isActive = filters.filters[filter.id] === option
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Button
|
|
55
|
+
key={option}
|
|
56
|
+
variant={isActive ? 'default' : 'outline'}
|
|
57
|
+
size="sm"
|
|
58
|
+
onClick={() => {
|
|
59
|
+
if (isActive) {
|
|
60
|
+
filters.clearFilter(filter.id)
|
|
61
|
+
} else {
|
|
62
|
+
filters.setFilter(filter.id, option)
|
|
63
|
+
}
|
|
64
|
+
}}
|
|
65
|
+
className="h-7 px-2 text-xs"
|
|
66
|
+
>
|
|
67
|
+
{option}
|
|
68
|
+
</Button>
|
|
69
|
+
)
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const renderBucketsFilter = (section: FilterConfig['sections'][0]) => {
|
|
80
|
+
if (section.type !== 'buckets' || !section.buckets) return null
|
|
81
|
+
|
|
82
|
+
const currentValue = filters.filters[section.id] || 'all'
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex items-center gap-1.5">
|
|
86
|
+
<Label htmlFor={section.id} className="text-xs text-muted-foreground whitespace-nowrap">
|
|
87
|
+
{section.label}:
|
|
88
|
+
</Label>
|
|
89
|
+
<Select value={currentValue} onValueChange={(value) => filters.setFilter(section.id, value)}>
|
|
90
|
+
<SelectTrigger id={section.id} className="w-32 h-8 text-xs">
|
|
91
|
+
<SelectValue placeholder="Select range" />
|
|
92
|
+
</SelectTrigger>
|
|
93
|
+
<SelectContent>
|
|
94
|
+
{section.buckets.map((bucket, idx) => (
|
|
95
|
+
<SelectItem key={idx} value={bucket.label.toLowerCase().replace(/\s+/g, '-')}>
|
|
96
|
+
{bucket.label}
|
|
97
|
+
</SelectItem>
|
|
98
|
+
))}
|
|
99
|
+
</SelectContent>
|
|
100
|
+
</Select>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const renderDropdownFilter = (filter: NonNullable<FilterConfig['sections'][0]['filters']>[0]) => {
|
|
106
|
+
if (!filter.options) return null
|
|
107
|
+
|
|
108
|
+
const currentValue = filters.filters[filter.id] || 'all'
|
|
109
|
+
// Filter out 'all' from options since we add it explicitly below
|
|
110
|
+
const filteredOptions = filter.options.filter((option: string) => option.toLowerCase() !== 'all')
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div key={filter.id} className="flex items-center gap-1.5">
|
|
114
|
+
<Label htmlFor={filter.id} className="text-xs text-muted-foreground whitespace-nowrap">
|
|
115
|
+
{filter.label}:
|
|
116
|
+
</Label>
|
|
117
|
+
<Select value={currentValue} onValueChange={(value) => filters.setFilter(filter.id, value)}>
|
|
118
|
+
<SelectTrigger id={filter.id} className="w-28 h-8 text-xs">
|
|
119
|
+
<SelectValue placeholder="All" />
|
|
120
|
+
</SelectTrigger>
|
|
121
|
+
<SelectContent>
|
|
122
|
+
<SelectItem value="all">All</SelectItem>
|
|
123
|
+
{filteredOptions.map((option: string) => (
|
|
124
|
+
<SelectItem key={option} value={option}>
|
|
125
|
+
{option === 'null' ? 'Not Set' : option}
|
|
126
|
+
</SelectItem>
|
|
127
|
+
))}
|
|
128
|
+
</SelectContent>
|
|
129
|
+
</Select>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const renderSearchFilter = (filter: NonNullable<FilterConfig['sections'][0]['filters']>[0]) => {
|
|
135
|
+
const currentValue = filters.filters[filter.id] || ''
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div key={filter.id} className="flex items-center gap-1.5">
|
|
139
|
+
<Label htmlFor={filter.id} className="text-xs text-muted-foreground whitespace-nowrap">
|
|
140
|
+
{filter.label}:
|
|
141
|
+
</Label>
|
|
142
|
+
<Input
|
|
143
|
+
id={filter.id}
|
|
144
|
+
type="text"
|
|
145
|
+
placeholder={filter.label}
|
|
146
|
+
value={currentValue}
|
|
147
|
+
onChange={(e) => filters.setFilter(filter.id, e.target.value)}
|
|
148
|
+
className="w-32 h-8 text-xs"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const renderCollapsibleSection = (section: FilterConfig['sections'][0]) => {
|
|
155
|
+
if (section.type !== 'collapsible' || !section.filters) return null
|
|
156
|
+
|
|
157
|
+
const isExpanded = expandedSections.has(section.id)
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div key={section.id} className="border rounded-lg p-3">
|
|
161
|
+
<Button
|
|
162
|
+
variant="ghost"
|
|
163
|
+
size="sm"
|
|
164
|
+
onClick={() => toggleSection(section.id)}
|
|
165
|
+
className="w-full justify-between -ml-2"
|
|
166
|
+
>
|
|
167
|
+
<span className="font-medium">{section.label}</span>
|
|
168
|
+
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
169
|
+
</Button>
|
|
170
|
+
|
|
171
|
+
{isExpanded && (
|
|
172
|
+
<div className="mt-3 space-y-3">
|
|
173
|
+
{section.filters.map((filter) => {
|
|
174
|
+
if (filter.type === 'dropdown') {
|
|
175
|
+
return renderDropdownFilter(filter)
|
|
176
|
+
}
|
|
177
|
+
if (filter.type === 'search') {
|
|
178
|
+
return renderSearchFilter(filter)
|
|
179
|
+
}
|
|
180
|
+
return null
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Render custom filter using the provided render function
|
|
189
|
+
const renderCustomFilter = useCallback((section: FilterSection) => {
|
|
190
|
+
if (section.type !== 'custom' || !section.customFilter) return null
|
|
191
|
+
|
|
192
|
+
const currentValue = filters.filters[section.id] ?? section.customFilter.defaultValue
|
|
193
|
+
const onChange = (value: any) => {
|
|
194
|
+
if (value === section.customFilter?.defaultValue || value === undefined || value === null) {
|
|
195
|
+
filters.clearFilter(section.id)
|
|
196
|
+
} else {
|
|
197
|
+
filters.setFilter(section.id, value)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div key={section.id} className="flex items-center gap-1.5">
|
|
203
|
+
{section.label && (
|
|
204
|
+
<Label className="text-xs text-muted-foreground whitespace-nowrap">
|
|
205
|
+
{section.label}:
|
|
206
|
+
</Label>
|
|
207
|
+
)}
|
|
208
|
+
{section.customFilter.render(currentValue, onChange)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}, [filters])
|
|
212
|
+
|
|
213
|
+
const hasActiveFilters = filters.hasActiveFilters()
|
|
214
|
+
const activeFilterCount = filters.getActiveFilterCount()
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className={cn('rounded-lg border bg-muted/30', className)}>
|
|
218
|
+
{/* Collapsible Header */}
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
222
|
+
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
|
223
|
+
>
|
|
224
|
+
<div className="flex items-center gap-2">
|
|
225
|
+
<Filter className="h-4 w-4 text-muted-foreground" />
|
|
226
|
+
<span className="text-sm font-medium">Filters</span>
|
|
227
|
+
{hasActiveFilters && (
|
|
228
|
+
<Badge variant="secondary" className="text-xs">
|
|
229
|
+
{activeFilterCount}
|
|
230
|
+
</Badge>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div className="flex items-center gap-2">
|
|
235
|
+
{hasActiveFilters && (
|
|
236
|
+
<Button
|
|
237
|
+
variant="ghost"
|
|
238
|
+
size="sm"
|
|
239
|
+
onClick={(e) => {
|
|
240
|
+
e.stopPropagation()
|
|
241
|
+
filters.clearAllFilters()
|
|
242
|
+
}}
|
|
243
|
+
className="gap-1 h-6 px-2 text-xs"
|
|
244
|
+
>
|
|
245
|
+
<XCircle className="h-3 w-3" />
|
|
246
|
+
Clear
|
|
247
|
+
</Button>
|
|
248
|
+
)}
|
|
249
|
+
{isExpanded ? (
|
|
250
|
+
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
251
|
+
) : (
|
|
252
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</button>
|
|
256
|
+
|
|
257
|
+
{/* Expanded filter content */}
|
|
258
|
+
{isExpanded && (
|
|
259
|
+
<div className="px-3 pb-3 pt-1">
|
|
260
|
+
{/* Compact horizontal layout for all filters */}
|
|
261
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
262
|
+
{config.sections.map((section) => {
|
|
263
|
+
if (section.type === 'chips') {
|
|
264
|
+
return <div key={section.id}>{renderChipsFilter(section)}</div>
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (section.type === 'buckets') {
|
|
268
|
+
return <div key={section.id}>{renderBucketsFilter(section)}</div>
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (section.type === 'collapsible') {
|
|
272
|
+
return renderCollapsibleSection(section)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (section.type === 'dropdown' && section.filters) {
|
|
276
|
+
return section.filters.map((filter) => (
|
|
277
|
+
<div key={filter.id}>{renderDropdownFilter(filter)}</div>
|
|
278
|
+
))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (section.type === 'search' && section.filters) {
|
|
282
|
+
return section.filters.map((filter) => (
|
|
283
|
+
<div key={filter.id}>{renderSearchFilter(filter)}</div>
|
|
284
|
+
))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (section.type === 'custom') {
|
|
288
|
+
return <div key={section.id}>{renderCustomFilter(section)}</div>
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null
|
|
292
|
+
})}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Button } from '../../../ui/button'
|
|
4
|
+
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
|
|
5
|
+
import { UsePaginationReturn } from '../../types'
|
|
6
|
+
|
|
7
|
+
interface TablePaginationProps {
|
|
8
|
+
pagination: UsePaginationReturn
|
|
9
|
+
showSelectAllPrompt?: boolean
|
|
10
|
+
onSelectAllPages?: () => void
|
|
11
|
+
totalFilteredCount?: number
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function TablePagination({
|
|
16
|
+
pagination,
|
|
17
|
+
showSelectAllPrompt,
|
|
18
|
+
onSelectAllPages,
|
|
19
|
+
totalFilteredCount,
|
|
20
|
+
className,
|
|
21
|
+
}: TablePaginationProps) {
|
|
22
|
+
const {
|
|
23
|
+
currentPage,
|
|
24
|
+
totalPages,
|
|
25
|
+
totalCount,
|
|
26
|
+
canGoNext,
|
|
27
|
+
canGoPrevious,
|
|
28
|
+
goToPage,
|
|
29
|
+
goToFirstPage,
|
|
30
|
+
goToLastPage,
|
|
31
|
+
goToNextPage,
|
|
32
|
+
goToPreviousPage,
|
|
33
|
+
getPageNumbers,
|
|
34
|
+
getDisplayRange,
|
|
35
|
+
} = pagination
|
|
36
|
+
|
|
37
|
+
const { start, end } = getDisplayRange()
|
|
38
|
+
const pageNumbers = getPageNumbers()
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={className}>
|
|
42
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
43
|
+
{/* Item count display */}
|
|
44
|
+
<div className="text-sm text-muted-foreground">
|
|
45
|
+
Showing <span className="font-medium">{start}-{end}</span> of{' '}
|
|
46
|
+
<span className="font-medium">{totalCount}</span>
|
|
47
|
+
{totalFilteredCount && totalFilteredCount !== totalCount && (
|
|
48
|
+
<span> filtered items</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{/* Page controls */}
|
|
53
|
+
{totalPages > 1 && (
|
|
54
|
+
<div className="flex items-center gap-2">
|
|
55
|
+
<span className="text-sm text-muted-foreground hidden sm:inline">
|
|
56
|
+
Page {currentPage} of {totalPages}
|
|
57
|
+
</span>
|
|
58
|
+
|
|
59
|
+
<div className="flex gap-1">
|
|
60
|
+
{/* First page */}
|
|
61
|
+
<Button
|
|
62
|
+
variant="outline"
|
|
63
|
+
size="sm"
|
|
64
|
+
onClick={goToFirstPage}
|
|
65
|
+
disabled={!canGoPrevious}
|
|
66
|
+
className="h-8 w-8 p-0"
|
|
67
|
+
title="First page"
|
|
68
|
+
>
|
|
69
|
+
<ChevronsLeft className="h-4 w-4" />
|
|
70
|
+
</Button>
|
|
71
|
+
|
|
72
|
+
{/* Previous page */}
|
|
73
|
+
<Button
|
|
74
|
+
variant="outline"
|
|
75
|
+
size="sm"
|
|
76
|
+
onClick={goToPreviousPage}
|
|
77
|
+
disabled={!canGoPrevious}
|
|
78
|
+
className="h-8 w-8 p-0"
|
|
79
|
+
title="Previous page"
|
|
80
|
+
>
|
|
81
|
+
<ChevronLeft className="h-4 w-4" />
|
|
82
|
+
</Button>
|
|
83
|
+
|
|
84
|
+
{/* Page numbers */}
|
|
85
|
+
{pageNumbers.map((pageNum, idx) => {
|
|
86
|
+
if (pageNum === '...') {
|
|
87
|
+
return (
|
|
88
|
+
<span
|
|
89
|
+
key={`ellipsis-${idx}`}
|
|
90
|
+
className="flex items-center px-2 text-muted-foreground"
|
|
91
|
+
>
|
|
92
|
+
•••
|
|
93
|
+
</span>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const page = pageNum as number
|
|
98
|
+
return (
|
|
99
|
+
<Button
|
|
100
|
+
key={page}
|
|
101
|
+
variant={page === currentPage ? 'default' : 'outline'}
|
|
102
|
+
size="sm"
|
|
103
|
+
onClick={() => goToPage(page)}
|
|
104
|
+
className="h-8 w-8 p-0"
|
|
105
|
+
>
|
|
106
|
+
{page}
|
|
107
|
+
</Button>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
|
|
111
|
+
{/* Next page */}
|
|
112
|
+
<Button
|
|
113
|
+
variant="outline"
|
|
114
|
+
size="sm"
|
|
115
|
+
onClick={goToNextPage}
|
|
116
|
+
disabled={!canGoNext}
|
|
117
|
+
className="h-8 w-8 p-0"
|
|
118
|
+
title="Next page"
|
|
119
|
+
>
|
|
120
|
+
<ChevronRight className="h-4 w-4" />
|
|
121
|
+
</Button>
|
|
122
|
+
|
|
123
|
+
{/* Last page */}
|
|
124
|
+
<Button
|
|
125
|
+
variant="outline"
|
|
126
|
+
size="sm"
|
|
127
|
+
onClick={goToLastPage}
|
|
128
|
+
disabled={!canGoNext}
|
|
129
|
+
className="h-8 w-8 p-0"
|
|
130
|
+
title="Last page"
|
|
131
|
+
>
|
|
132
|
+
<ChevronsRight className="h-4 w-4" />
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Select all pages prompt */}
|
|
140
|
+
{showSelectAllPrompt && onSelectAllPages && totalFilteredCount && totalFilteredCount > end && (
|
|
141
|
+
<div className="mt-3 flex items-center justify-center p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
|
142
|
+
<span className="text-sm text-blue-900 mr-2">
|
|
143
|
+
Select all <span className="font-semibold">{totalFilteredCount}</span> items?
|
|
144
|
+
</span>
|
|
145
|
+
<Button
|
|
146
|
+
size="sm"
|
|
147
|
+
variant="link"
|
|
148
|
+
onClick={onSelectAllPages}
|
|
149
|
+
className="text-blue-700 hover:text-blue-900 font-semibold p-0 h-auto"
|
|
150
|
+
>
|
|
151
|
+
Select all {totalFilteredCount} items
|
|
152
|
+
</Button>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react'
|
|
4
|
+
import { Button } from '../../../ui/button'
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from '../../../ui/dropdown-menu'
|
|
12
|
+
import { Download, FileSpreadsheet, FileText, Loader2 } from 'lucide-react'
|
|
13
|
+
import { ColumnConfig } from '../../types'
|
|
14
|
+
import { exportToCSV, exportToExcel, generateExportFilename } from '../../utils/export'
|
|
15
|
+
|
|
16
|
+
export type ExportScope = 'all' | 'filtered' | 'selected'
|
|
17
|
+
|
|
18
|
+
export interface ExportButtonProps<TData> {
|
|
19
|
+
data: TData[]
|
|
20
|
+
filteredData?: TData[]
|
|
21
|
+
selectedData?: TData[]
|
|
22
|
+
columns: ColumnConfig<TData>[]
|
|
23
|
+
baseFilename?: string
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
className?: string
|
|
26
|
+
showProgress?: boolean
|
|
27
|
+
onExportStart?: (format: 'csv' | 'excel', scope: ExportScope) => void
|
|
28
|
+
onExportComplete?: (format: 'csv' | 'excel', scope: ExportScope, rowCount: number) => void
|
|
29
|
+
onExportError?: (error: Error) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ExportButton<TData>({
|
|
33
|
+
data,
|
|
34
|
+
filteredData,
|
|
35
|
+
selectedData,
|
|
36
|
+
columns,
|
|
37
|
+
baseFilename = 'export',
|
|
38
|
+
disabled = false,
|
|
39
|
+
className,
|
|
40
|
+
showProgress = true,
|
|
41
|
+
onExportStart,
|
|
42
|
+
onExportComplete,
|
|
43
|
+
onExportError,
|
|
44
|
+
}: ExportButtonProps<TData>) {
|
|
45
|
+
const [isExporting, setIsExporting] = useState(false)
|
|
46
|
+
const [progress, setProgress] = useState(0)
|
|
47
|
+
|
|
48
|
+
// Determine what export options are available
|
|
49
|
+
const hasFilteredData = filteredData && filteredData.length > 0 && filteredData.length < data.length
|
|
50
|
+
const hasSelectedData = selectedData && selectedData.length > 0
|
|
51
|
+
|
|
52
|
+
const handleExport = useCallback(
|
|
53
|
+
async (format: 'csv' | 'excel', scope: ExportScope) => {
|
|
54
|
+
setIsExporting(true)
|
|
55
|
+
setProgress(0)
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
onExportStart?.(format, scope)
|
|
59
|
+
|
|
60
|
+
// Determine which data to export
|
|
61
|
+
let dataToExport: TData[]
|
|
62
|
+
let scopeLabel: string
|
|
63
|
+
|
|
64
|
+
switch (scope) {
|
|
65
|
+
case 'selected':
|
|
66
|
+
if (!selectedData || selectedData.length === 0) {
|
|
67
|
+
throw new Error('No rows selected')
|
|
68
|
+
}
|
|
69
|
+
dataToExport = selectedData
|
|
70
|
+
scopeLabel = 'selected'
|
|
71
|
+
break
|
|
72
|
+
case 'filtered':
|
|
73
|
+
if (!filteredData || filteredData.length === 0) {
|
|
74
|
+
throw new Error('No filtered data available')
|
|
75
|
+
}
|
|
76
|
+
dataToExport = filteredData
|
|
77
|
+
scopeLabel = 'filtered'
|
|
78
|
+
break
|
|
79
|
+
case 'all':
|
|
80
|
+
default:
|
|
81
|
+
dataToExport = data
|
|
82
|
+
scopeLabel = 'all'
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Simulate progress for large datasets
|
|
87
|
+
if (showProgress && dataToExport.length > 100) {
|
|
88
|
+
setProgress(25)
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
90
|
+
setProgress(50)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Generate filename with timestamp and scope
|
|
94
|
+
const filename = `${baseFilename}_${scopeLabel}_${generateExportFilename('')}`.replace(
|
|
95
|
+
/__/g,
|
|
96
|
+
'_'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// Export based on format
|
|
100
|
+
if (format === 'csv') {
|
|
101
|
+
exportToCSV(dataToExport, columns, filename)
|
|
102
|
+
} else {
|
|
103
|
+
exportToExcel(dataToExport, columns, filename)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (showProgress) {
|
|
107
|
+
setProgress(100)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
onExportComplete?.(format, scope, dataToExport.length)
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error('Export failed:', error)
|
|
113
|
+
const errorObj = error instanceof Error ? error : new Error('Export failed')
|
|
114
|
+
onExportError?.(errorObj)
|
|
115
|
+
} finally {
|
|
116
|
+
// Reset state after a brief delay to show completion
|
|
117
|
+
if (showProgress) {
|
|
118
|
+
setTimeout(() => {
|
|
119
|
+
setIsExporting(false)
|
|
120
|
+
setProgress(0)
|
|
121
|
+
}, 500)
|
|
122
|
+
} else {
|
|
123
|
+
setIsExporting(false)
|
|
124
|
+
setProgress(0)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[
|
|
129
|
+
data,
|
|
130
|
+
filteredData,
|
|
131
|
+
selectedData,
|
|
132
|
+
columns,
|
|
133
|
+
baseFilename,
|
|
134
|
+
showProgress,
|
|
135
|
+
onExportStart,
|
|
136
|
+
onExportComplete,
|
|
137
|
+
onExportError,
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const isDisabled = disabled || isExporting || data.length === 0
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<DropdownMenu>
|
|
145
|
+
<DropdownMenuTrigger asChild>
|
|
146
|
+
<Button
|
|
147
|
+
variant="outline"
|
|
148
|
+
size="sm"
|
|
149
|
+
disabled={isDisabled}
|
|
150
|
+
className={className}
|
|
151
|
+
aria-label="Export data"
|
|
152
|
+
>
|
|
153
|
+
{isExporting ? (
|
|
154
|
+
<>
|
|
155
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
156
|
+
Exporting{showProgress && progress > 0 ? ` ${progress}%` : '...'}
|
|
157
|
+
</>
|
|
158
|
+
) : (
|
|
159
|
+
<>
|
|
160
|
+
<Download className="mr-2 h-4 w-4" />
|
|
161
|
+
Export
|
|
162
|
+
</>
|
|
163
|
+
)}
|
|
164
|
+
</Button>
|
|
165
|
+
</DropdownMenuTrigger>
|
|
166
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
167
|
+
{/* CSV Export Options */}
|
|
168
|
+
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">CSV Format</div>
|
|
169
|
+
<DropdownMenuItem
|
|
170
|
+
onClick={() => handleExport('csv', 'all')}
|
|
171
|
+
disabled={isExporting || data.length === 0}
|
|
172
|
+
>
|
|
173
|
+
<FileText className="mr-2 h-4 w-4" />
|
|
174
|
+
Export All ({data.length} rows)
|
|
175
|
+
</DropdownMenuItem>
|
|
176
|
+
{hasFilteredData && (
|
|
177
|
+
<DropdownMenuItem
|
|
178
|
+
onClick={() => handleExport('csv', 'filtered')}
|
|
179
|
+
disabled={isExporting}
|
|
180
|
+
>
|
|
181
|
+
<FileText className="mr-2 h-4 w-4" />
|
|
182
|
+
Export Filtered ({filteredData?.length || 0} rows)
|
|
183
|
+
</DropdownMenuItem>
|
|
184
|
+
)}
|
|
185
|
+
{hasSelectedData && (
|
|
186
|
+
<DropdownMenuItem
|
|
187
|
+
onClick={() => handleExport('csv', 'selected')}
|
|
188
|
+
disabled={isExporting}
|
|
189
|
+
>
|
|
190
|
+
<FileText className="mr-2 h-4 w-4" />
|
|
191
|
+
Export Selected ({selectedData?.length || 0} rows)
|
|
192
|
+
</DropdownMenuItem>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
<DropdownMenuSeparator />
|
|
196
|
+
|
|
197
|
+
{/* Excel Export Options */}
|
|
198
|
+
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
199
|
+
Excel Format
|
|
200
|
+
</div>
|
|
201
|
+
<DropdownMenuItem
|
|
202
|
+
onClick={() => handleExport('excel', 'all')}
|
|
203
|
+
disabled={isExporting || data.length === 0}
|
|
204
|
+
>
|
|
205
|
+
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
206
|
+
Export All ({data.length} rows)
|
|
207
|
+
</DropdownMenuItem>
|
|
208
|
+
{hasFilteredData && (
|
|
209
|
+
<DropdownMenuItem
|
|
210
|
+
onClick={() => handleExport('excel', 'filtered')}
|
|
211
|
+
disabled={isExporting}
|
|
212
|
+
>
|
|
213
|
+
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
214
|
+
Export Filtered ({filteredData?.length || 0} rows)
|
|
215
|
+
</DropdownMenuItem>
|
|
216
|
+
)}
|
|
217
|
+
{hasSelectedData && (
|
|
218
|
+
<DropdownMenuItem
|
|
219
|
+
onClick={() => handleExport('excel', 'selected')}
|
|
220
|
+
disabled={isExporting}
|
|
221
|
+
>
|
|
222
|
+
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
|
223
|
+
Export Selected ({selectedData?.length || 0} rows)
|
|
224
|
+
</DropdownMenuItem>
|
|
225
|
+
)}
|
|
226
|
+
</DropdownMenuContent>
|
|
227
|
+
</DropdownMenu>
|
|
228
|
+
)
|
|
229
|
+
}
|