@moontra/moonui-pro 2.3.8 → 2.4.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.
@@ -0,0 +1,442 @@
1
+ "use client"
2
+
3
+ import React, { useState, useMemo } from 'react'
4
+ import { Column, Table } from '@tanstack/react-table'
5
+ import { X, Filter, Trash2, Plus } from 'lucide-react'
6
+ import { Button } from '../ui/button'
7
+ import { Input } from '../ui/input'
8
+ import { Label } from '../ui/label'
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
10
+ import { Switch } from '../ui/switch'
11
+ import { Separator } from '../ui/separator'
12
+ import { cn } from '../../lib/utils'
13
+ import { motion, AnimatePresence } from 'framer-motion'
14
+
15
+ export interface FilterCondition {
16
+ column: string
17
+ operator: FilterOperator
18
+ value: any
19
+ }
20
+
21
+ export type FilterOperator =
22
+ | 'equals'
23
+ | 'notEquals'
24
+ | 'contains'
25
+ | 'notContains'
26
+ | 'startsWith'
27
+ | 'endsWith'
28
+ | 'greaterThan'
29
+ | 'lessThan'
30
+ | 'greaterThanOrEqual'
31
+ | 'lessThanOrEqual'
32
+ | 'between'
33
+ | 'in'
34
+ | 'notIn'
35
+ | 'isNull'
36
+ | 'isNotNull'
37
+
38
+ export interface DataTableFilterDrawerProps<TData> {
39
+ table: Table<TData>
40
+ open: boolean
41
+ onOpenChange: (open: boolean) => void
42
+ position?: 'left' | 'right'
43
+ width?: string
44
+ filters?: FilterCondition[]
45
+ onFiltersChange?: (filters: FilterCondition[]) => void
46
+ customFilters?: React.ReactNode
47
+ }
48
+
49
+ const operatorLabels: Record<FilterOperator, string> = {
50
+ equals: 'Equals',
51
+ notEquals: 'Not equals',
52
+ contains: 'Contains',
53
+ notContains: 'Not contains',
54
+ startsWith: 'Starts with',
55
+ endsWith: 'Ends with',
56
+ greaterThan: 'Greater than',
57
+ lessThan: 'Less than',
58
+ greaterThanOrEqual: 'Greater than or equal',
59
+ lessThanOrEqual: 'Less than or equal',
60
+ between: 'Between',
61
+ in: 'In',
62
+ notIn: 'Not in',
63
+ isNull: 'Is empty',
64
+ isNotNull: 'Is not empty',
65
+ }
66
+
67
+ function getOperatorsForColumnType(columnType?: string): FilterOperator[] {
68
+ switch (columnType) {
69
+ case 'number':
70
+ return ['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'between', 'isNull', 'isNotNull']
71
+ case 'date':
72
+ return ['equals', 'notEquals', 'greaterThan', 'lessThan', 'greaterThanOrEqual', 'lessThanOrEqual', 'between', 'isNull', 'isNotNull']
73
+ case 'boolean':
74
+ return ['equals', 'notEquals', 'isNull', 'isNotNull']
75
+ case 'select':
76
+ return ['equals', 'notEquals', 'in', 'notIn', 'isNull', 'isNotNull']
77
+ default: // string
78
+ return ['equals', 'notEquals', 'contains', 'notContains', 'startsWith', 'endsWith', 'isNull', 'isNotNull']
79
+ }
80
+ }
81
+
82
+ export function DataTableFilterDrawer<TData>({
83
+ table,
84
+ open,
85
+ onOpenChange,
86
+ position = 'right',
87
+ width = '400px',
88
+ filters: externalFilters,
89
+ onFiltersChange,
90
+ customFilters,
91
+ }: DataTableFilterDrawerProps<TData>) {
92
+ const [internalFilters, setInternalFilters] = useState<FilterCondition[]>([])
93
+ const [matchAll, setMatchAll] = useState(true)
94
+
95
+ const filters = externalFilters || internalFilters
96
+ const setFilters = onFiltersChange || setInternalFilters
97
+
98
+ // Get filterable columns
99
+ const filterableColumns = useMemo(() => {
100
+ return table.getAllColumns().filter(column => {
101
+ // Skip special columns
102
+ if (column.id === 'select' || column.id === 'actions' || column.id === 'expander') {
103
+ return false
104
+ }
105
+ // Only include columns that can be filtered
106
+ return column.getCanFilter()
107
+ })
108
+ }, [table])
109
+
110
+ const addFilter = () => {
111
+ const firstColumn = filterableColumns[0]
112
+ if (!firstColumn) return
113
+
114
+ const newFilter: FilterCondition = {
115
+ column: firstColumn.id,
116
+ operator: 'contains',
117
+ value: '',
118
+ }
119
+ setFilters([...filters, newFilter])
120
+ }
121
+
122
+ const updateFilter = (index: number, updates: Partial<FilterCondition>) => {
123
+ const newFilters = [...filters]
124
+ newFilters[index] = { ...newFilters[index], ...updates }
125
+ setFilters(newFilters)
126
+ }
127
+
128
+ const removeFilter = (index: number) => {
129
+ setFilters(filters.filter((_, i) => i !== index))
130
+ }
131
+
132
+ const clearAllFilters = () => {
133
+ setFilters([])
134
+ table.resetColumnFilters()
135
+ }
136
+
137
+ const applyFilters = () => {
138
+ // Reset all column filters first
139
+ table.resetColumnFilters()
140
+
141
+ // Apply each filter
142
+ filters.forEach(filter => {
143
+ const column = table.getColumn(filter.column)
144
+ if (!column) return
145
+
146
+ // Apply filter based on operator
147
+ switch (filter.operator) {
148
+ case 'equals':
149
+ column.setFilterValue(filter.value)
150
+ break
151
+ case 'notEquals':
152
+ column.setFilterValue((value: any) => value !== filter.value)
153
+ break
154
+ case 'contains':
155
+ column.setFilterValue((value: any) =>
156
+ String(value).toLowerCase().includes(String(filter.value).toLowerCase())
157
+ )
158
+ break
159
+ case 'notContains':
160
+ column.setFilterValue((value: any) =>
161
+ !String(value).toLowerCase().includes(String(filter.value).toLowerCase())
162
+ )
163
+ break
164
+ case 'startsWith':
165
+ column.setFilterValue((value: any) =>
166
+ String(value).toLowerCase().startsWith(String(filter.value).toLowerCase())
167
+ )
168
+ break
169
+ case 'endsWith':
170
+ column.setFilterValue((value: any) =>
171
+ String(value).toLowerCase().endsWith(String(filter.value).toLowerCase())
172
+ )
173
+ break
174
+ case 'greaterThan':
175
+ column.setFilterValue((value: any) => Number(value) > Number(filter.value))
176
+ break
177
+ case 'lessThan':
178
+ column.setFilterValue((value: any) => Number(value) < Number(filter.value))
179
+ break
180
+ case 'greaterThanOrEqual':
181
+ column.setFilterValue((value: any) => Number(value) >= Number(filter.value))
182
+ break
183
+ case 'lessThanOrEqual':
184
+ column.setFilterValue((value: any) => Number(value) <= Number(filter.value))
185
+ break
186
+ case 'isNull':
187
+ column.setFilterValue((value: any) => value == null || value === '')
188
+ break
189
+ case 'isNotNull':
190
+ column.setFilterValue((value: any) => value != null && value !== '')
191
+ break
192
+ // Add more operators as needed
193
+ }
194
+ })
195
+
196
+ onOpenChange(false)
197
+ }
198
+
199
+ return (
200
+ <>
201
+ {/* Backdrop */}
202
+ <AnimatePresence>
203
+ {open && (
204
+ <motion.div
205
+ initial={{ opacity: 0 }}
206
+ animate={{ opacity: 1 }}
207
+ exit={{ opacity: 0 }}
208
+ className="fixed inset-0 bg-black/20 z-40"
209
+ onClick={() => onOpenChange(false)}
210
+ />
211
+ )}
212
+ </AnimatePresence>
213
+
214
+ {/* Drawer */}
215
+ <AnimatePresence>
216
+ {open && (
217
+ <motion.div
218
+ initial={{ x: position === 'right' ? '100%' : '-100%' }}
219
+ animate={{ x: 0 }}
220
+ exit={{ x: position === 'right' ? '100%' : '-100%' }}
221
+ transition={{ type: 'spring', damping: 30, stiffness: 300 }}
222
+ className={cn(
223
+ "fixed top-0 bottom-0 z-50 bg-background border-l shadow-xl",
224
+ position === 'right' ? 'right-0' : 'left-0'
225
+ )}
226
+ style={{ width }}
227
+ >
228
+ <div className="flex flex-col h-full">
229
+ {/* Header */}
230
+ <div className="flex items-center justify-between p-4 border-b">
231
+ <div className="flex items-center gap-2">
232
+ <Filter className="h-5 w-5" />
233
+ <h2 className="text-lg font-semibold">Filters</h2>
234
+ {filters.length > 0 && (
235
+ <span className="text-sm text-muted-foreground">
236
+ ({filters.length} active)
237
+ </span>
238
+ )}
239
+ </div>
240
+ <Button
241
+ variant="ghost"
242
+ size="icon"
243
+ onClick={() => onOpenChange(false)}
244
+ >
245
+ <X className="h-4 w-4" />
246
+ </Button>
247
+ </div>
248
+
249
+ {/* Content */}
250
+ <div className="flex-1 overflow-y-auto p-4">
251
+ {/* Match mode */}
252
+ <div className="mb-6">
253
+ <Label className="text-sm font-medium mb-2 block">
254
+ Match conditions
255
+ </Label>
256
+ <div className="flex items-center gap-2">
257
+ <Button
258
+ variant={matchAll ? 'primary' : 'outline'}
259
+ size="sm"
260
+ onClick={() => setMatchAll(true)}
261
+ className="flex-1"
262
+ >
263
+ Match all
264
+ </Button>
265
+ <Button
266
+ variant={!matchAll ? 'primary' : 'outline'}
267
+ size="sm"
268
+ onClick={() => setMatchAll(false)}
269
+ className="flex-1"
270
+ >
271
+ Match any
272
+ </Button>
273
+ </div>
274
+ </div>
275
+
276
+ <Separator className="mb-6" />
277
+
278
+ {/* Custom filters */}
279
+ {customFilters && (
280
+ <>
281
+ {customFilters}
282
+ <Separator className="my-6" />
283
+ </>
284
+ )}
285
+
286
+ {/* Filter conditions */}
287
+ <div className="space-y-4">
288
+ {filters.map((filter, index) => (
289
+ <FilterConditionRow
290
+ key={index}
291
+ filter={filter}
292
+ columns={filterableColumns}
293
+ onUpdate={(updates) => updateFilter(index, updates)}
294
+ onRemove={() => removeFilter(index)}
295
+ />
296
+ ))}
297
+ </div>
298
+
299
+ {/* Add filter button */}
300
+ <Button
301
+ variant="outline"
302
+ size="sm"
303
+ onClick={addFilter}
304
+ className="w-full mt-4"
305
+ >
306
+ <Plus className="h-4 w-4 mr-2" />
307
+ Add filter
308
+ </Button>
309
+ </div>
310
+
311
+ {/* Footer */}
312
+ <div className="p-4 border-t space-y-2">
313
+ <div className="flex gap-2">
314
+ <Button
315
+ variant="outline"
316
+ onClick={clearAllFilters}
317
+ disabled={filters.length === 0}
318
+ className="flex-1"
319
+ >
320
+ <Trash2 className="h-4 w-4 mr-2" />
321
+ Clear all
322
+ </Button>
323
+ <Button onClick={applyFilters} className="flex-1">
324
+ Apply filters
325
+ </Button>
326
+ </div>
327
+ </div>
328
+ </div>
329
+ </motion.div>
330
+ )}
331
+ </AnimatePresence>
332
+ </>
333
+ )
334
+ }
335
+
336
+ interface FilterConditionRowProps<TData> {
337
+ filter: FilterCondition
338
+ columns: Column<TData, any>[]
339
+ onUpdate: (updates: Partial<FilterCondition>) => void
340
+ onRemove: () => void
341
+ }
342
+
343
+ function FilterConditionRow<TData>({
344
+ filter,
345
+ columns,
346
+ onUpdate,
347
+ onRemove,
348
+ }: FilterConditionRowProps<TData>) {
349
+ const selectedColumn = columns.find(col => col.id === filter.column)
350
+ const columnDef = selectedColumn?.columnDef as any
351
+ const columnType = columnDef?.meta?.filterType || 'string'
352
+ const availableOperators = getOperatorsForColumnType(columnType)
353
+
354
+ const needsValue = filter.operator !== 'isNull' && filter.operator !== 'isNotNull'
355
+
356
+ return (
357
+ <div className="space-y-2 p-3 border rounded-lg bg-muted/30">
358
+ {/* Column selector */}
359
+ <div className="flex items-center gap-2">
360
+ <Select
361
+ value={filter.column}
362
+ onValueChange={(value) => onUpdate({ column: value })}
363
+ >
364
+ <SelectTrigger className="flex-1">
365
+ <SelectValue />
366
+ </SelectTrigger>
367
+ <SelectContent>
368
+ {columns.map(column => {
369
+ const header = column.columnDef.header
370
+ const label = typeof header === 'function' ? column.id : header || column.id
371
+
372
+ return (
373
+ <SelectItem key={column.id} value={column.id}>
374
+ {label}
375
+ </SelectItem>
376
+ )
377
+ })}
378
+ </SelectContent>
379
+ </Select>
380
+
381
+ <Button
382
+ variant="ghost"
383
+ size="icon"
384
+ onClick={onRemove}
385
+ className="h-8 w-8"
386
+ >
387
+ <X className="h-4 w-4" />
388
+ </Button>
389
+ </div>
390
+
391
+ {/* Operator selector */}
392
+ <Select
393
+ value={filter.operator}
394
+ onValueChange={(value) => onUpdate({ operator: value as FilterOperator })}
395
+ >
396
+ <SelectTrigger>
397
+ <SelectValue />
398
+ </SelectTrigger>
399
+ <SelectContent>
400
+ {availableOperators.map(operator => (
401
+ <SelectItem key={operator} value={operator}>
402
+ {operatorLabels[operator]}
403
+ </SelectItem>
404
+ ))}
405
+ </SelectContent>
406
+ </Select>
407
+
408
+ {/* Value input */}
409
+ {needsValue && (
410
+ <div>
411
+ {columnType === 'boolean' ? (
412
+ <Select
413
+ value={String(filter.value)}
414
+ onValueChange={(value) => onUpdate({ value: value === 'true' })}
415
+ >
416
+ <SelectTrigger>
417
+ <SelectValue />
418
+ </SelectTrigger>
419
+ <SelectContent>
420
+ <SelectItem value="true">True</SelectItem>
421
+ <SelectItem value="false">False</SelectItem>
422
+ </SelectContent>
423
+ </Select>
424
+ ) : columnType === 'number' ? (
425
+ <Input
426
+ type="number"
427
+ value={filter.value || ''}
428
+ onChange={(e) => onUpdate({ value: e.target.value })}
429
+ placeholder="Enter value..."
430
+ />
431
+ ) : (
432
+ <Input
433
+ value={filter.value || ''}
434
+ onChange={(e) => onUpdate({ value: e.target.value })}
435
+ placeholder="Enter value..."
436
+ />
437
+ )}
438
+ </div>
439
+ )}
440
+ </div>
441
+ )
442
+ }