@spree/dashboard-core 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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,818 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ CardTitle,
5
+ DropdownMenu,
6
+ DropdownMenuCheckboxItem,
7
+ DropdownMenuContent,
8
+ DropdownMenuLabel,
9
+ DropdownMenuRadioGroup,
10
+ DropdownMenuRadioItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ Input,
14
+ Popover,
15
+ PopoverContent,
16
+ PopoverTrigger,
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ Tooltip,
23
+ TooltipContent,
24
+ TooltipTrigger,
25
+ } from '@spree/dashboard-ui'
26
+ import { useQuery } from '@tanstack/react-query'
27
+ import {
28
+ ArrowUpDownIcon,
29
+ Columns3Icon,
30
+ FilterIcon,
31
+ PlusIcon,
32
+ SearchIcon,
33
+ Trash2Icon,
34
+ XIcon,
35
+ } from 'lucide-react'
36
+ import { useCallback, useMemo, useRef, useState } from 'react'
37
+ import { useTranslation } from 'react-i18next'
38
+ import {
39
+ type ColumnDef,
40
+ type FilterRule,
41
+ parseFilterIds,
42
+ type SortOption,
43
+ } from '../lib/table-registry'
44
+ import { useStore } from '../providers/store-provider'
45
+ import { ResourceMultiAutocomplete } from './resource-multi-autocomplete'
46
+ import { StoreDatePicker } from './store-date-picker'
47
+ import { TagCombobox } from './tag-combobox'
48
+
49
+ interface TableToolbarProps {
50
+ /** Displayable columns (for column selector and table headers) */
51
+ columns: ColumnDef[]
52
+ visibleColumns: string[]
53
+ onVisibleColumnsChange: (columns: string[]) => void
54
+ search: string
55
+ onSearchChange: (value: string) => void
56
+ searchPlaceholder?: string
57
+ sort: SortOption
58
+ onSortChange: (sort: SortOption) => void
59
+ filters: FilterRule[]
60
+ onFiltersChange: (filters: FilterRule[]) => void
61
+ /** All columns including filter-only ones (for the filter drawer). Falls back to `columns` if not provided. */
62
+ allColumns?: ColumnDef[]
63
+ /** Title displayed in the toolbar header */
64
+ title?: string
65
+ actions?: React.ReactNode
66
+ /** Hide the sort dropdown — used when the table is drag-reorderable, where free sorting would defeat the drag. */
67
+ hideSort?: boolean
68
+ }
69
+
70
+ // ============================================================================
71
+ // Operators
72
+ // ============================================================================
73
+
74
+ // Operator labels are stored as i18n keys (`admin.components.table_toolbar.operators.*`)
75
+ // and resolved with `t()` at render time so they follow the active language.
76
+ const operatorsByType: Record<string, { value: string; labelKey: string }[]> = {
77
+ string: [
78
+ { value: 'cont', labelKey: 'admin.components.table_toolbar.operators.contains' },
79
+ { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.equals' },
80
+ { value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.does_not_equal' },
81
+ { value: 'start', labelKey: 'admin.components.table_toolbar.operators.starts_with' },
82
+ { value: 'end', labelKey: 'admin.components.table_toolbar.operators.ends_with' },
83
+ { value: 'present', labelKey: 'admin.components.table_toolbar.operators.is_set' },
84
+ { value: 'blank', labelKey: 'admin.components.table_toolbar.operators.is_not_set' },
85
+ ],
86
+ enum: [
87
+ { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
88
+ { value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
89
+ { value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
90
+ { value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
91
+ ],
92
+ boolean: [{ value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' }],
93
+ number: [
94
+ { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.equals' },
95
+ { value: 'gt', labelKey: 'admin.components.table_toolbar.operators.greater_than' },
96
+ { value: 'gteq', labelKey: 'admin.components.table_toolbar.operators.greater_than_or_equal' },
97
+ { value: 'lt', labelKey: 'admin.components.table_toolbar.operators.less_than' },
98
+ { value: 'lteq', labelKey: 'admin.components.table_toolbar.operators.less_than_or_equal' },
99
+ ],
100
+ date: [
101
+ { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
102
+ { value: 'gt', labelKey: 'admin.components.table_toolbar.operators.after' },
103
+ { value: 'lt', labelKey: 'admin.components.table_toolbar.operators.before' },
104
+ { value: 'gteq', labelKey: 'admin.components.table_toolbar.operators.on_or_after' },
105
+ { value: 'lteq', labelKey: 'admin.components.table_toolbar.operators.on_or_before' },
106
+ ],
107
+ resource: [
108
+ { value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
109
+ { value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
110
+ ],
111
+ // Tag names round-trip as CSV; `filtersToRansack` decodes to the
112
+ // `tags_name_in` / `tags_name_not_in` predicate when emitting Ransack.
113
+ tags: [
114
+ { value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
115
+ { value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
116
+ ],
117
+ // Same shape as `enum` — the value picker just sources its option list
118
+ // from the active store's `supported_currencies`.
119
+ currency: [
120
+ { value: 'eq', labelKey: 'admin.components.table_toolbar.operators.is' },
121
+ { value: 'not_eq', labelKey: 'admin.components.table_toolbar.operators.is_not' },
122
+ { value: 'in', labelKey: 'admin.components.table_toolbar.operators.is_any_of' },
123
+ { value: 'not_in', labelKey: 'admin.components.table_toolbar.operators.is_none_of' },
124
+ ],
125
+ }
126
+
127
+ function getOperators(type: string) {
128
+ return operatorsByType[type] || operatorsByType.string
129
+ }
130
+
131
+ const noValueOperators = ['present', 'blank']
132
+
133
+ // ============================================================================
134
+ // TableToolbar
135
+ // ============================================================================
136
+
137
+ export function TableToolbar({
138
+ columns,
139
+ visibleColumns,
140
+ onVisibleColumnsChange,
141
+ search,
142
+ onSearchChange,
143
+ searchPlaceholder,
144
+ sort,
145
+ onSortChange,
146
+ filters,
147
+ onFiltersChange,
148
+ allColumns,
149
+ title,
150
+ actions,
151
+ hideSort = false,
152
+ }: TableToolbarProps) {
153
+ const { t } = useTranslation()
154
+ const [filterOpen, setFilterOpen] = useState(false)
155
+ const searchRef = useRef<HTMLInputElement>(null)
156
+
157
+ const allCols = allColumns ?? columns
158
+ // Memoize so `FilterPanel`'s `useMemo` deps stay stable across parent
159
+ // re-renders. Otherwise picking a filter value triggers an `items` change
160
+ // in the field-picker Select, which Base UI re-emits as a state change.
161
+ const sortableColumns = useMemo(() => allCols.filter((c) => c.sortable), [allCols])
162
+ const filterableColumns = useMemo(() => allCols.filter((c) => c.filterable), [allCols])
163
+ const activeFilterCount = filters.length
164
+
165
+ return (
166
+ <>
167
+ <div className="flex flex-col lg:flex-row gap-2 items-start lg:items-center justify-between p-3 pl-4 border-b border-border">
168
+ {title && <CardTitle>{title}</CardTitle>}
169
+ <div className="flex gap-2 items-center flex-wrap ml-auto">
170
+ {/* Search */}
171
+ <div className="flex items-center gap-2 border border-border bg-card rounded-lg shadow-xs px-2.5 h-[2.125rem] lg:w-[300px] focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_rgba(59,130,246,0.15)] transition-all duration-100 ease-in-out">
172
+ <SearchIcon className="size-4 text-muted-foreground shrink-0" />
173
+ <input
174
+ ref={searchRef}
175
+ placeholder={
176
+ searchPlaceholder ?? t('admin.components.table_toolbar.search_placeholder')
177
+ }
178
+ value={search}
179
+ onChange={(e) => onSearchChange(e.target.value)}
180
+ className="h-full w-full border-0 bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-0"
181
+ />
182
+ {search && (
183
+ <button
184
+ type="button"
185
+ className="p-0.5 text-muted-foreground hover:text-foreground shrink-0"
186
+ onClick={() => {
187
+ onSearchChange('')
188
+ searchRef.current?.focus()
189
+ }}
190
+ >
191
+ <XIcon className="size-3.5" />
192
+ </button>
193
+ )}
194
+ </div>
195
+ </div>
196
+
197
+ <div className="flex gap-2 items-center">
198
+ {/* Filter button */}
199
+ {filterableColumns.length > 0 && (
200
+ <Popover open={filterOpen} onOpenChange={setFilterOpen}>
201
+ <Tooltip>
202
+ <TooltipTrigger asChild>
203
+ <PopoverTrigger asChild>
204
+ <Button variant="outline" size="sm" className="h-[2.125rem]">
205
+ <FilterIcon className="size-4" />
206
+ {activeFilterCount > 0 && (
207
+ <Badge variant="outline" className="ml-1 px-1.5 py-0 text-xs">
208
+ {activeFilterCount}
209
+ </Badge>
210
+ )}
211
+ </Button>
212
+ </PopoverTrigger>
213
+ </TooltipTrigger>
214
+ <TooltipContent>
215
+ {t('admin.components.table_toolbar.filters_button')}
216
+ </TooltipContent>
217
+ </Tooltip>
218
+ <PopoverContent align="end" className="w-[480px] p-0">
219
+ <FilterPanel
220
+ columns={filterableColumns}
221
+ filters={filters}
222
+ onApply={(f) => {
223
+ onFiltersChange(f)
224
+ setFilterOpen(false)
225
+ }}
226
+ onClose={() => setFilterOpen(false)}
227
+ />
228
+ </PopoverContent>
229
+ </Popover>
230
+ )}
231
+
232
+ {/* Sort dropdown — hidden when the table is drag-reorderable, since free sorting would defeat the drag. */}
233
+ {!hideSort && sortableColumns.length > 0 && (
234
+ <SortDropdown columns={sortableColumns} sort={sort} onSortChange={onSortChange} />
235
+ )}
236
+
237
+ {/* Column selector */}
238
+ <ColumnSelector
239
+ columns={columns.filter((c) => c.default !== undefined)}
240
+ visibleColumns={visibleColumns}
241
+ onVisibleColumnsChange={onVisibleColumnsChange}
242
+ />
243
+
244
+ {actions}
245
+ </div>
246
+ </div>
247
+
248
+ {/* Active filter badges */}
249
+ {filters.length > 0 && (
250
+ <div className="flex gap-2 flex-wrap px-3 py-2 border-b border-border">
251
+ {filters.map((filter) => (
252
+ <FilterChip
253
+ key={filter.id}
254
+ filter={filter}
255
+ col={allCols.find((c) => c.key === filter.field)}
256
+ onRemove={() => onFiltersChange(filters.filter((f) => f.id !== filter.id))}
257
+ />
258
+ ))}
259
+ <button
260
+ type="button"
261
+ className="text-xs text-muted-foreground hover:text-foreground"
262
+ onClick={() => onFiltersChange([])}
263
+ >
264
+ {t('admin.components.table_toolbar.clear_all')}
265
+ </button>
266
+ </div>
267
+ )}
268
+ </>
269
+ )
270
+ }
271
+
272
+ // ============================================================================
273
+ // Filter Chip
274
+ // ============================================================================
275
+
276
+ function FilterChip({
277
+ filter,
278
+ col,
279
+ onRemove,
280
+ }: {
281
+ filter: FilterRule
282
+ col: ColumnDef | undefined
283
+ onRemove: () => void
284
+ }) {
285
+ const { t } = useTranslation()
286
+ const ops = getOperators(col?.filterType ?? 'string')
287
+ const opLabelKey = ops.find((o) => o.value === filter.operator)?.labelKey
288
+ const opLabel = opLabelKey ? t(opLabelKey) : filter.operator
289
+ const showValue = !noValueOperators.includes(filter.operator)
290
+
291
+ return (
292
+ <Badge variant="outline" className="gap-1.5 pr-0.5">
293
+ <span className="font-medium">{col?.label ?? filter.field}</span>
294
+ <span className="text-muted-foreground">{opLabel}</span>
295
+ {showValue &&
296
+ (col?.filterType === 'resource' && col.filterResource ? (
297
+ <ResourceFilterValue value={filter.value} config={col.filterResource} />
298
+ ) : col?.filterType === 'tags' ? (
299
+ <span className="font-medium">{parseFilterIds(filter.value).join(', ')}</span>
300
+ ) : col?.filterType === 'enum' && col.filterOptions ? (
301
+ <span className="font-medium">
302
+ {col.filterOptions.find((o) => o.value === filter.value)?.label ?? filter.value}
303
+ </span>
304
+ ) : (
305
+ <span className="font-medium">{filter.value}</span>
306
+ ))}
307
+ <button
308
+ type="button"
309
+ className="ml-0.5 p-0.5 rounded-full hover:bg-accent"
310
+ onClick={onRemove}
311
+ >
312
+ <XIcon className="size-3" />
313
+ </button>
314
+ </Badge>
315
+ )
316
+ }
317
+
318
+ /**
319
+ * Hydrates the CSV id list in a resource filter into human labels. Reuses
320
+ * the resource's `hydrate` callback so each chip benefits from React Query's
321
+ * cache (the picker inside the panel populates the same `queryKey`).
322
+ */
323
+ function ResourceFilterValue({
324
+ value,
325
+ config,
326
+ }: {
327
+ value: string
328
+ config: NonNullable<ColumnDef['filterResource']>
329
+ }) {
330
+ const { storeId } = useStore()
331
+ const ids = useMemo(() => parseFilterIds(value), [value])
332
+
333
+ const { data } = useQuery({
334
+ // Store-scope the cache key so chips don't resolve against another store's
335
+ // hydrate results after a store switch.
336
+ queryKey: ['filter-chip', config.queryKey, storeId, ids],
337
+ queryFn: () => config.hydrate(ids),
338
+ enabled: ids.length > 0,
339
+ staleTime: 60_000,
340
+ })
341
+
342
+ if (ids.length === 0) return null
343
+
344
+ const labels = ids.map((id) => {
345
+ const record = data?.data.find((r) => r.id === id)
346
+ return record ? config.getOptionLabel(record) : id
347
+ })
348
+
349
+ return <span className="font-medium">{labels.join(', ')}</span>
350
+ }
351
+
352
+ /**
353
+ * Currency value-picker for `filterType: 'currency'`. Options come from the
354
+ * active store's `supported_currencies`. Branches on operator: `eq`/`not_eq`
355
+ * use a single `<Select>`; `in`/`not_in` accept a CSV via Base UI's
356
+ * multi-select Combobox chips, matching the other array-valued filters.
357
+ */
358
+ function CurrencyFilterPicker({
359
+ value,
360
+ operator,
361
+ onChange,
362
+ }: {
363
+ value: string
364
+ operator: string
365
+ onChange: (next: string) => void
366
+ }) {
367
+ const { t } = useTranslation()
368
+ const { currencies } = useStore()
369
+ const items = useMemo(
370
+ () => currencies.map((code) => ({ value: code, label: code })),
371
+ [currencies],
372
+ )
373
+
374
+ if (operator === 'in' || operator === 'not_in') {
375
+ return (
376
+ <div className="flex-1 min-w-0">
377
+ <CurrencyMultiSelect
378
+ codes={currencies}
379
+ value={parseFilterIds(value)}
380
+ onChange={(next) => onChange(next.join(','))}
381
+ />
382
+ </div>
383
+ )
384
+ }
385
+
386
+ return (
387
+ <Select items={items} value={value || undefined} onValueChange={(val) => onChange(val)}>
388
+ <SelectTrigger size="sm" className="flex-1">
389
+ <SelectValue placeholder={t('admin.components.table_toolbar.filter_select_placeholder')} />
390
+ </SelectTrigger>
391
+ <SelectContent>
392
+ {items.map((opt) => (
393
+ <SelectItem key={opt.value} value={opt.value}>
394
+ {opt.label}
395
+ </SelectItem>
396
+ ))}
397
+ </SelectContent>
398
+ </Select>
399
+ )
400
+ }
401
+
402
+ function CurrencyMultiSelect({
403
+ codes,
404
+ value,
405
+ onChange,
406
+ }: {
407
+ codes: string[]
408
+ value: string[]
409
+ onChange: (next: string[]) => void
410
+ }) {
411
+ function toggle(code: string) {
412
+ onChange(value.includes(code) ? value.filter((c) => c !== code) : [...value, code])
413
+ }
414
+ return (
415
+ <div className="flex flex-wrap gap-1 rounded-md border border-input px-2 py-1.5 text-sm">
416
+ {codes.map((code) => {
417
+ const selected = value.includes(code)
418
+ return (
419
+ <button
420
+ key={code}
421
+ type="button"
422
+ onClick={() => toggle(code)}
423
+ className={`rounded px-1.5 py-0.5 text-xs font-medium ${
424
+ selected
425
+ ? 'bg-primary text-primary-foreground'
426
+ : 'bg-muted text-muted-foreground hover:bg-accent'
427
+ }`}
428
+ >
429
+ {code}
430
+ </button>
431
+ )
432
+ })}
433
+ </div>
434
+ )
435
+ }
436
+
437
+ // ============================================================================
438
+ // Sort Dropdown
439
+ // ============================================================================
440
+
441
+ function SortDropdown({
442
+ columns,
443
+ sort,
444
+ onSortChange,
445
+ }: {
446
+ columns: ColumnDef[]
447
+ sort: SortOption
448
+ onSortChange: (sort: SortOption) => void
449
+ }) {
450
+ const { t } = useTranslation()
451
+ const currentCol = columns.find((c) => c.key === sort.field)
452
+
453
+ return (
454
+ <DropdownMenu>
455
+ <Tooltip>
456
+ <TooltipTrigger asChild>
457
+ <DropdownMenuTrigger asChild>
458
+ <Button variant="outline" size="sm" className="h-[2.125rem]">
459
+ <ArrowUpDownIcon className="size-4" />
460
+ </Button>
461
+ </DropdownMenuTrigger>
462
+ </TooltipTrigger>
463
+ <TooltipContent>
464
+ {currentCol?.label ?? t('admin.components.table_toolbar.sort_tooltip')}
465
+ </TooltipContent>
466
+ </Tooltip>
467
+ <DropdownMenuContent align="end" className="min-w-48">
468
+ <DropdownMenuLabel>{t('admin.components.table_toolbar.sort_by')}</DropdownMenuLabel>
469
+ <DropdownMenuSeparator />
470
+ <DropdownMenuRadioGroup
471
+ value={sort.field}
472
+ onValueChange={(field) => onSortChange({ field, direction: sort.direction })}
473
+ >
474
+ {columns.map((col) => (
475
+ <DropdownMenuRadioItem key={col.key} value={col.key}>
476
+ {col.label}
477
+ </DropdownMenuRadioItem>
478
+ ))}
479
+ </DropdownMenuRadioGroup>
480
+ <DropdownMenuSeparator />
481
+ <DropdownMenuLabel>{t('admin.components.table_toolbar.order')}</DropdownMenuLabel>
482
+ <DropdownMenuSeparator />
483
+ <DropdownMenuRadioGroup
484
+ value={sort.direction}
485
+ onValueChange={(dir) =>
486
+ onSortChange({
487
+ field: sort.field,
488
+ direction: dir as 'asc' | 'desc',
489
+ })
490
+ }
491
+ >
492
+ <DropdownMenuRadioItem value="asc">
493
+ {t('admin.components.table_toolbar.ascending')}
494
+ </DropdownMenuRadioItem>
495
+ <DropdownMenuRadioItem value="desc">
496
+ {t('admin.components.table_toolbar.descending')}
497
+ </DropdownMenuRadioItem>
498
+ </DropdownMenuRadioGroup>
499
+ </DropdownMenuContent>
500
+ </DropdownMenu>
501
+ )
502
+ }
503
+
504
+ // ============================================================================
505
+ // Column Selector
506
+ // ============================================================================
507
+
508
+ function ColumnSelector({
509
+ columns,
510
+ visibleColumns,
511
+ onVisibleColumnsChange,
512
+ }: {
513
+ columns: ColumnDef[]
514
+ visibleColumns: string[]
515
+ onVisibleColumnsChange: (columns: string[]) => void
516
+ }) {
517
+ const { t } = useTranslation()
518
+ const defaults = columns.filter((c) => c.default).map((c) => c.key)
519
+
520
+ return (
521
+ <DropdownMenu>
522
+ <Tooltip>
523
+ <TooltipTrigger asChild>
524
+ <DropdownMenuTrigger asChild>
525
+ <Button variant="outline" size="sm" className="h-[2.125rem]">
526
+ <Columns3Icon className="size-4" />
527
+ </Button>
528
+ </DropdownMenuTrigger>
529
+ </TooltipTrigger>
530
+ <TooltipContent>{t('admin.components.table_toolbar.columns_tooltip')}</TooltipContent>
531
+ </Tooltip>
532
+ <DropdownMenuContent align="end" className="min-w-48">
533
+ <DropdownMenuLabel>{t('admin.components.table_toolbar.visible_columns')}</DropdownMenuLabel>
534
+ <DropdownMenuSeparator />
535
+ {columns.map((col) => (
536
+ <DropdownMenuCheckboxItem
537
+ key={col.key}
538
+ checked={visibleColumns.includes(col.key)}
539
+ onCheckedChange={(checked) => {
540
+ onVisibleColumnsChange(
541
+ checked
542
+ ? [...visibleColumns, col.key]
543
+ : visibleColumns.filter((k) => k !== col.key),
544
+ )
545
+ }}
546
+ onSelect={(e) => e.preventDefault()}
547
+ >
548
+ {col.label}
549
+ </DropdownMenuCheckboxItem>
550
+ ))}
551
+ <DropdownMenuSeparator />
552
+ <div className="px-1 py-1">
553
+ <Button
554
+ variant="ghost"
555
+ size="sm"
556
+ className="w-full justify-center"
557
+ onClick={() => onVisibleColumnsChange(defaults)}
558
+ >
559
+ {t('admin.components.table_toolbar.reset_to_default')}
560
+ </Button>
561
+ </div>
562
+ </DropdownMenuContent>
563
+ </DropdownMenu>
564
+ )
565
+ }
566
+
567
+ // ============================================================================
568
+ // Filter Drawer
569
+ // ============================================================================
570
+
571
+ function FilterPanel({
572
+ columns,
573
+ filters: initialFilters,
574
+ onApply,
575
+ onClose,
576
+ }: {
577
+ columns: ColumnDef[]
578
+ filters: FilterRule[]
579
+ onApply: (filters: FilterRule[]) => void
580
+ onClose: () => void
581
+ }) {
582
+ const { t } = useTranslation()
583
+ const { storeId } = useStore()
584
+ const [draft, setDraft] = useState<FilterRule[]>(initialFilters)
585
+ const booleanItems = useMemo(
586
+ () => [
587
+ { value: 'true', label: t('admin.common.yes') },
588
+ { value: 'false', label: t('admin.common.no') },
589
+ ],
590
+ [t],
591
+ )
592
+ // Stable `{ value, label }` array for the field-picker `<Select items>`.
593
+ // Building it inline per render produces a new reference each time, which
594
+ // Base UI's Select treats as an `items` change and re-emits state — causing
595
+ // a "Maximum update depth exceeded" loop when the value-Select sits inside
596
+ // the same panel.
597
+ const fieldItems = useMemo(
598
+ () => columns.map((c) => ({ value: c.key, label: c.label })),
599
+ [columns],
600
+ )
601
+
602
+ const addFilter = useCallback(() => {
603
+ const first = columns[0]
604
+ if (!first) return
605
+ setDraft((prev) => [
606
+ ...prev,
607
+ {
608
+ id: crypto.randomUUID(),
609
+ field: first.key,
610
+ operator: getOperators(first.filterType ?? 'string')[0].value,
611
+ value: '',
612
+ },
613
+ ])
614
+ }, [columns])
615
+
616
+ const updateFilter = useCallback((id: string, update: Partial<FilterRule>) => {
617
+ setDraft((prev) => prev.map((f) => (f.id === id ? { ...f, ...update } : f)))
618
+ }, [])
619
+
620
+ const removeFilter = useCallback((id: string) => {
621
+ setDraft((prev) => prev.filter((f) => f.id !== id))
622
+ }, [])
623
+
624
+ function handleApply() {
625
+ const valid = draft.filter(
626
+ (f) => noValueOperators.includes(f.operator) || f.value.trim() !== '',
627
+ )
628
+ onApply(valid)
629
+ }
630
+
631
+ return (
632
+ <div className="flex flex-col">
633
+ <div className="flex items-center justify-between border-b px-3 py-2">
634
+ <span className="text-sm font-medium">
635
+ {t('admin.components.table_toolbar.filters_button')}
636
+ </span>
637
+ <button type="button" onClick={onClose} className="p-1 rounded hover:bg-muted">
638
+ <XIcon className="size-3.5" />
639
+ </button>
640
+ </div>
641
+
642
+ <div className="max-h-[320px] overflow-y-auto p-3 space-y-2">
643
+ {draft.map((filter) => {
644
+ const col = columns.find((c) => c.key === filter.field)
645
+ const type = col?.filterType ?? 'string'
646
+ const ops = getOperators(type).map((op) => ({
647
+ value: op.value,
648
+ label: t(op.labelKey),
649
+ }))
650
+
651
+ return (
652
+ <div key={filter.id} className="flex min-w-0 items-center gap-1.5">
653
+ <Select
654
+ items={fieldItems}
655
+ value={filter.field}
656
+ onValueChange={(val) => {
657
+ const newCol = columns.find((c) => c.key === val)
658
+ const newOps = getOperators(newCol?.filterType ?? 'string')
659
+ updateFilter(filter.id, {
660
+ field: val,
661
+ operator: newOps[0].value,
662
+ value: '',
663
+ })
664
+ }}
665
+ >
666
+ <SelectTrigger size="sm" className="flex-1">
667
+ <SelectValue />
668
+ </SelectTrigger>
669
+ <SelectContent>
670
+ {columns.map((c) => (
671
+ <SelectItem key={c.key} value={c.key}>
672
+ {c.label}
673
+ </SelectItem>
674
+ ))}
675
+ </SelectContent>
676
+ </Select>
677
+
678
+ <Select
679
+ items={ops}
680
+ value={filter.operator}
681
+ onValueChange={(val) => updateFilter(filter.id, { operator: val })}
682
+ >
683
+ <SelectTrigger size="sm" className="w-[120px] shrink-0">
684
+ <SelectValue />
685
+ </SelectTrigger>
686
+ <SelectContent>
687
+ {ops.map((op) => (
688
+ <SelectItem key={op.value} value={op.value}>
689
+ {op.label}
690
+ </SelectItem>
691
+ ))}
692
+ </SelectContent>
693
+ </Select>
694
+
695
+ {!noValueOperators.includes(filter.operator) &&
696
+ (col?.filterType === 'resource' && col.filterResource ? (
697
+ <div className="flex-1 min-w-0">
698
+ <ResourceMultiAutocomplete
699
+ // Store-scope the cache key so a store switch can't reuse
700
+ // the previous store's search/hydrate results.
701
+ queryKey={`${col.filterResource.queryKey}-${storeId}`}
702
+ value={parseFilterIds(filter.value)}
703
+ onChange={(ids) => updateFilter(filter.id, { value: ids.join(',') })}
704
+ search={col.filterResource.search}
705
+ hydrate={col.filterResource.hydrate}
706
+ getOptionLabel={col.filterResource.getOptionLabel}
707
+ placeholder={col.filterResource.placeholder}
708
+ emptyText={col.filterResource.emptyText}
709
+ />
710
+ </div>
711
+ ) : col?.filterType === 'tags' && col.taggableType ? (
712
+ <div className="flex-1 min-w-0">
713
+ <TagCombobox
714
+ taggableType={col.taggableType}
715
+ value={parseFilterIds(filter.value)}
716
+ onChange={(names) => updateFilter(filter.id, { value: names.join(',') })}
717
+ />
718
+ </div>
719
+ ) : col?.filterType === 'enum' && col.filterOptions ? (
720
+ <Select
721
+ items={col.filterOptions}
722
+ value={filter.value || undefined}
723
+ onValueChange={(val) => updateFilter(filter.id, { value: val })}
724
+ >
725
+ <SelectTrigger size="sm" className="flex-1">
726
+ <SelectValue
727
+ placeholder={t('admin.components.table_toolbar.filter_select_placeholder')}
728
+ />
729
+ </SelectTrigger>
730
+ <SelectContent>
731
+ {col.filterOptions.map((opt) => (
732
+ <SelectItem key={opt.value} value={opt.value}>
733
+ {opt.label}
734
+ </SelectItem>
735
+ ))}
736
+ </SelectContent>
737
+ </Select>
738
+ ) : col?.filterType === 'currency' ? (
739
+ <CurrencyFilterPicker
740
+ value={filter.value}
741
+ operator={filter.operator}
742
+ onChange={(val) => updateFilter(filter.id, { value: val })}
743
+ />
744
+ ) : col?.filterType === 'boolean' ? (
745
+ <Select
746
+ items={booleanItems}
747
+ value={filter.value || undefined}
748
+ onValueChange={(val) => updateFilter(filter.id, { value: val })}
749
+ >
750
+ <SelectTrigger size="sm" className="flex-1">
751
+ <SelectValue
752
+ placeholder={t('admin.components.table_toolbar.filter_select_placeholder')}
753
+ />
754
+ </SelectTrigger>
755
+ <SelectContent>
756
+ <SelectItem value="true">{t('admin.common.yes')}</SelectItem>
757
+ <SelectItem value="false">{t('admin.common.no')}</SelectItem>
758
+ </SelectContent>
759
+ </Select>
760
+ ) : col?.filterType === 'date' ? (
761
+ // Filter values are stored as plain strings; for dates we
762
+ // persist `yyyy-MM-dd`, which Ransack accepts as-is for
763
+ // `*_eq`/`*_gt`/`*_lt`. The picker emits `yyyy-MM-dd`
764
+ // directly in date-only mode.
765
+ <div className="flex-1">
766
+ <StoreDatePicker
767
+ value={filter.value || null}
768
+ onChange={(next) => updateFilter(filter.id, { value: next ?? '' })}
769
+ placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
770
+ />
771
+ </div>
772
+ ) : (
773
+ <Input
774
+ type={col?.filterType === 'number' ? 'number' : 'text'}
775
+ className="flex-1 py-1 px-2 text-sm h-7"
776
+ placeholder={t('admin.components.table_toolbar.filter_text_placeholder')}
777
+ value={filter.value}
778
+ onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
779
+ />
780
+ ))}
781
+
782
+ <button
783
+ type="button"
784
+ className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
785
+ onClick={() => removeFilter(filter.id)}
786
+ >
787
+ <Trash2Icon className="size-3.5" />
788
+ </button>
789
+ </div>
790
+ )
791
+ })}
792
+
793
+ {draft.length === 0 && (
794
+ <p className="text-xs text-muted-foreground text-center py-4">
795
+ {t('admin.components.table_toolbar.no_filters')}
796
+ </p>
797
+ )}
798
+ </div>
799
+
800
+ <div className="flex items-center justify-between border-t px-3 py-2">
801
+ <Button variant="ghost" size="sm" onClick={addFilter}>
802
+ <PlusIcon className="size-3.5" />
803
+ {t('admin.components.table_toolbar.add_filter')}
804
+ </Button>
805
+ <div className="flex gap-1.5">
806
+ {draft.length > 0 && (
807
+ <Button variant="ghost" size="sm" onClick={() => setDraft([])}>
808
+ {t('admin.actions.clear')}
809
+ </Button>
810
+ )}
811
+ <Button size="sm" onClick={handleApply}>
812
+ {t('admin.actions.apply')}
813
+ </Button>
814
+ </div>
815
+ </div>
816
+ </div>
817
+ )
818
+ }