@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -1,7 +1,7 @@
1
1
  import {
2
- Badge,
3
2
  Button,
4
- CardTitle,
3
+ Checkbox,
4
+ cn,
5
5
  DropdownMenu,
6
6
  DropdownMenuCheckboxItem,
7
7
  DropdownMenuContent,
@@ -14,37 +14,48 @@ import {
14
14
  Popover,
15
15
  PopoverContent,
16
16
  PopoverTrigger,
17
+ SearchInput,
17
18
  Select,
18
19
  SelectContent,
19
20
  SelectItem,
20
21
  SelectTrigger,
21
22
  SelectValue,
23
+ StatusDot,
22
24
  Tooltip,
23
25
  TooltipContent,
24
26
  TooltipTrigger,
25
27
  } from '@spree/dashboard-ui'
26
- import { useQuery } from '@tanstack/react-query'
27
28
  import {
28
29
  ArrowUpDownIcon,
30
+ CheckIcon,
31
+ ChevronDownIcon,
32
+ ChevronLeftIcon,
33
+ ChevronRightIcon,
29
34
  Columns3Icon,
30
- FilterIcon,
31
- PlusIcon,
32
- SearchIcon,
33
- Trash2Icon,
35
+ ListFilter,
34
36
  XIcon,
35
- } from 'lucide-react'
36
- import { useCallback, useMemo, useRef, useState } from 'react'
37
+ } from '@spree/dashboard-ui/icons'
38
+ import { useQuery } from '@tanstack/react-query'
39
+ import { useCallback, useDeferredValue, useMemo, useState } from 'react'
37
40
  import { useTranslation } from 'react-i18next'
41
+ import { getApiClient } from '../api-client'
42
+ import { useAuth } from '../hooks/use-auth'
43
+ import {
44
+ DATE_PRESET_KEYS,
45
+ type DatePresetKey,
46
+ matchDatePreset,
47
+ resolveDatePreset,
48
+ } from '../lib/date-presets'
38
49
  import {
39
50
  type ColumnDef,
40
51
  type FilterRule,
41
52
  parseFilterIds,
42
53
  type SortOption,
54
+ type TaggableType,
43
55
  } from '../lib/table-registry'
44
- import { useStore } from '../providers/store-provider'
45
- import { ResourceMultiAutocomplete } from './resource-multi-autocomplete'
56
+ import { useOptionalStore } from '../providers/store-provider'
57
+ import { useTenantId } from '../providers/tenant-provider'
46
58
  import { StoreDatePicker } from './store-date-picker'
47
- import { TagCombobox } from './tag-combobox'
48
59
 
49
60
  interface TableToolbarProps {
50
61
  /** Displayable columns (for column selector and table headers) */
@@ -60,9 +71,6 @@ interface TableToolbarProps {
60
71
  onFiltersChange: (filters: FilterRule[]) => void
61
72
  /** All columns including filter-only ones (for the filter drawer). Falls back to `columns` if not provided. */
62
73
  allColumns?: ColumnDef[]
63
- /** Title displayed in the toolbar header */
64
- title?: string
65
- actions?: React.ReactNode
66
74
  /** Hide the sort dropdown — used when the table is drag-reorderable, where free sorting would defeat the drag. */
67
75
  hideSort?: boolean
68
76
  }
@@ -145,6 +153,10 @@ function getOperatorLabelKey(type: string, operator: string) {
145
153
 
146
154
  const noValueOperators = ['present', 'blank']
147
155
 
156
+ /** Stable empty list, so a panel with no store doesn't rebuild `items` (and
157
+ * re-emit Base UI Select state) on every render. */
158
+ const noCurrencies: string[] = []
159
+
148
160
  // ============================================================================
149
161
  // TableToolbar
150
162
  // ============================================================================
@@ -161,13 +173,10 @@ export function TableToolbar({
161
173
  filters,
162
174
  onFiltersChange,
163
175
  allColumns,
164
- title,
165
- actions,
166
176
  hideSort = false,
167
177
  }: TableToolbarProps) {
168
178
  const { t } = useTranslation()
169
179
  const [filterOpen, setFilterOpen] = useState(false)
170
- const searchRef = useRef<HTMLInputElement>(null)
171
180
 
172
181
  const allCols = allColumns ?? columns
173
182
  // Memoize so `FilterPanel`'s `useMemo` deps stay stable across parent
@@ -175,109 +184,234 @@ export function TableToolbar({
175
184
  // in the field-picker Select, which Base UI re-emits as a state change.
176
185
  const sortableColumns = useMemo(() => allCols.filter((c) => c.sortable), [allCols])
177
186
  const filterableColumns = useMemo(() => allCols.filter((c) => c.filterable), [allCols])
178
- const activeFilterCount = filters.length
187
+ // Only enum and date columns have a control worth surfacing — the rest need
188
+ // a free-text value, which is what the popover is for.
189
+ const quickFilterColumns = useMemo(
190
+ () =>
191
+ filterableColumns.filter(
192
+ (c) =>
193
+ c.quickFilter &&
194
+ (c.filterType === 'enum' ||
195
+ c.filterType === 'boolean' ||
196
+ c.filterType === 'date' ||
197
+ (c.filterType === 'resource' && !!c.filterResource?.listAll)),
198
+ ),
199
+ [filterableColumns],
200
+ )
201
+ // A filter a quick control actually displays needs no chip — the control
202
+ // already states its value, and a second copy would offer two ways to clear
203
+ // one constraint.
204
+ //
205
+ // Matched on operator as well as field, because each control speaks exactly
206
+ // one: the enum control reads `in`, the date control `gteq`/`lteq`. A
207
+ // `status not_eq active` set from the panel lands on a field a control owns
208
+ // but in an operator it cannot show, so it must keep its chip — hiding it
209
+ // would leave the list filtered by something invisible and unremovable.
210
+ // The field list leads with what has no quick control. A quick-filtered
211
+ // field is listed after those, under its own heading, because the panel is
212
+ // the only place to express an operator its control cannot — a `status is
213
+ // not active` has no representation in a list of checkboxes.
214
+ const quickFilterKeys = useMemo(
215
+ () => new Set(quickFilterColumns.map((c) => c.key)),
216
+ [quickFilterColumns],
217
+ )
218
+ const panelColumns = useMemo(
219
+ () => filterableColumns.filter((c) => !quickFilterKeys.has(c.key)),
220
+ [filterableColumns, quickFilterKeys],
221
+ )
222
+ const chipFilters = useMemo(() => {
223
+ const covered = new Set(
224
+ quickFilterColumns.flatMap((c) =>
225
+ c.filterType === 'date' ? [`${c.key}:gteq`, `${c.key}:lteq`] : [`${c.key}:in`],
226
+ ),
227
+ )
228
+ const visible = filters.filter((f) => !covered.has(`${f.field}:${f.operator}`))
229
+
230
+ // A bounded date range is stored as two rules but is one idea, so it shows
231
+ // as one chip. Two chips read as two unrelated filters, and removing
232
+ // either leaves a half-range the operator did not ask for — a "between"
233
+ // that silently became an "after".
234
+ const entries: { key: string; rules: FilterRule[] }[] = []
235
+ const rangeIndex = new Map<string, number>()
236
+ for (const rule of visible) {
237
+ const isBound = rule.operator === 'gteq' || rule.operator === 'lteq'
238
+ const column = allCols.find((c) => c.key === rule.field)
239
+ if (isBound && column?.filterType === 'date') {
240
+ const existing = rangeIndex.get(rule.field)
241
+ if (existing !== undefined) {
242
+ entries[existing].rules.push(rule)
243
+ continue
244
+ }
245
+ rangeIndex.set(rule.field, entries.length)
246
+ entries.push({ key: rule.id, rules: [rule] })
247
+ continue
248
+ }
249
+ entries.push({ key: rule.id, rules: [rule] })
250
+ }
251
+ return entries
252
+ }, [filters, quickFilterColumns, allCols])
179
253
 
180
254
  return (
181
255
  <>
182
- <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">
183
- {title && <CardTitle>{title}</CardTitle>}
184
- <div className="flex gap-2 items-center flex-wrap ml-auto">
185
- {/* Search */}
186
- <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">
187
- <SearchIcon className="size-4 text-muted-foreground shrink-0" />
188
- <input
189
- ref={searchRef}
256
+ {/* One filter row: the quick controls and any filters set the long way
257
+ sit together, because to the operator they are the same thing — a
258
+ constraint currently on the list. Splitting them into a "controls"
259
+ band and an "applied" band asked the reader to learn which of their
260
+ own filters lives where, and cost a third row of chrome above every
261
+ table to say it. */}
262
+ {/* Renders whenever the row has anything to hold. Sort and columns count:
263
+ without them a table with no filterable columns would lose both
264
+ controls along with the row. */}
265
+ {(filterableColumns.length > 0 ||
266
+ search ||
267
+ (!hideSort && sortableColumns.length > 0) ||
268
+ columns.some((c) => c.default !== undefined)) && (
269
+ <div className="flex items-start gap-2">
270
+ {/* Only the filters wrap. They grow with what the operator has set,
271
+ where sort and columns are a fixed pair — letting the whole row
272
+ wrap dropped those two onto a line of their own the moment the
273
+ filters filled the width. */}
274
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
275
+ {/* Search leads: it is the widest net, and the filters beside it
276
+ narrow what it finds. Hidden on a phone, where the sidebar drawer
277
+ already carries a search field — two search boxes stacked in one
278
+ column is the more confusing cost. A term set on desktop still
279
+ shows as a chip there, so it never filters a list invisibly. */}
280
+ <SearchInput
281
+ value={search}
282
+ onValueChange={onSearchChange}
190
283
  placeholder={
191
284
  searchPlaceholder ?? t('admin.components.table_toolbar.search_placeholder')
192
285
  }
193
- value={search}
194
- onChange={(e) => onSearchChange(e.target.value)}
195
- className="h-full w-full border-0 bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-0"
286
+ clearLabel={t('admin.common.clear')}
287
+ className="hidden h-[2.125rem] w-[240px] bg-card text-sm lg:flex"
196
288
  />
289
+
290
+ {/* Reads as a verb rather than an icon: it is the way *in* to
291
+ everything the quick controls beside it don't cover, so it has to
292
+ look like an action and not a toggle. */}
293
+ {/* Hidden when every filterable field already has a quick control:
294
+ the button would open onto an empty list. */}
295
+ {panelColumns.length > 0 && (
296
+ <Popover open={filterOpen} onOpenChange={setFilterOpen}>
297
+ <PopoverTrigger asChild>
298
+ <Button variant="outline" size="sm" className="h-11 gap-1.5 lg:h-[2.125rem]">
299
+ <ListFilter className="size-3.5" />
300
+ {t('admin.components.table_toolbar.add_filter')}
301
+ </Button>
302
+ </PopoverTrigger>
303
+ {/* `min(480px, …)` rather than a flat 480px: the panel is wider
304
+ than a phone, so a fixed width runs its right edge and the
305
+ Apply button off the screen. */}
306
+ <PopoverContent align="start" className="w-[min(480px,calc(100vw-1rem))] p-0">
307
+ <FilterPanel
308
+ columns={panelColumns}
309
+ allColumns={filterableColumns}
310
+ quickFilterKeys={quickFilterKeys}
311
+ filters={filters}
312
+ onApply={(f) => {
313
+ onFiltersChange(f)
314
+ setFilterOpen(false)
315
+ }}
316
+ onChange={onFiltersChange}
317
+ />
318
+ </PopoverContent>
319
+ </Popover>
320
+ )}
321
+
322
+ {quickFilterColumns.map((col) =>
323
+ col.filterType === 'date' ? (
324
+ <QuickDateFilter
325
+ key={col.key}
326
+ column={col}
327
+ filters={filters}
328
+ onFiltersChange={onFiltersChange}
329
+ />
330
+ ) : col.filterType === 'resource' && col.filterResource ? (
331
+ <QuickResourceFilter
332
+ key={col.key}
333
+ column={col}
334
+ config={col.filterResource}
335
+ filters={filters}
336
+ onFiltersChange={onFiltersChange}
337
+ />
338
+ ) : (
339
+ <QuickEnumFilter
340
+ key={col.key}
341
+ column={col}
342
+ filters={filters}
343
+ onFiltersChange={onFiltersChange}
344
+ />
345
+ ),
346
+ )}
347
+
348
+ {/* The search term as a chip, mobile only — the field it came from is
349
+ hidden there, so this is the only way to see or clear it. */}
197
350
  {search && (
198
- <button
199
- type="button"
200
- className="p-0.5 text-muted-foreground hover:text-foreground shrink-0"
201
- onClick={() => {
202
- onSearchChange('')
203
- searchRef.current?.focus()
351
+ <span className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md border border-border bg-card py-1 pr-1 pl-2.5 text-sm shadow-xs lg:hidden">
352
+ <span className="text-muted-foreground">
353
+ {t('admin.components.table_toolbar.search_placeholder')}
354
+ </span>
355
+ <span className="truncate">{search}</span>
356
+ <Button
357
+ type="button"
358
+ variant="ghost"
359
+ size="icon-sm"
360
+ aria-label={t('admin.common.clear')}
361
+ onClick={() => onSearchChange('')}
362
+ >
363
+ <XIcon className="size-3.5" />
364
+ </Button>
365
+ </span>
366
+ )}
367
+ {chipFilters.map((entry) => (
368
+ <FilterChip
369
+ key={entry.key}
370
+ rules={entry.rules}
371
+ col={allCols.find((c) => c.key === entry.rules[0].field)}
372
+ onRemove={() => {
373
+ const removing = new Set(entry.rules.map((r) => r.id))
374
+ onFiltersChange(filters.filter((f) => !removing.has(f.id)))
204
375
  }}
376
+ />
377
+ ))}
378
+ {filters.length > 0 && (
379
+ // Stays borderless while everything beside it is outlined: the
380
+ // rest of the row sets constraints, this one undoes them, and
381
+ // giving it the same weight would make "clear" look like a fifth
382
+ // filter. It matches on height so the row still sits on one line.
383
+ <Button
384
+ type="button"
385
+ variant="ghost"
386
+ size="sm"
387
+ className="h-11 text-muted-foreground hover:text-foreground lg:h-[2.125rem]"
388
+ onClick={() => onFiltersChange([])}
205
389
  >
206
- <XIcon className="size-3.5" />
207
- </button>
390
+ {t('admin.components.table_toolbar.clear_all')}
391
+ </Button>
208
392
  )}
209
393
  </div>
210
- </div>
211
394
 
212
- <div className="flex gap-2 items-center">
213
- {/* Filter button */}
214
- {filterableColumns.length > 0 && (
215
- <Popover open={filterOpen} onOpenChange={setFilterOpen}>
216
- <Tooltip>
217
- <TooltipTrigger asChild>
218
- <PopoverTrigger asChild>
219
- <Button variant="outline" size="sm" className="h-[2.125rem]">
220
- <FilterIcon className="size-4" />
221
- {activeFilterCount > 0 && (
222
- <Badge variant="outline" className="ml-1 px-1.5 py-0 text-xs">
223
- {activeFilterCount}
224
- </Badge>
225
- )}
226
- </Button>
227
- </PopoverTrigger>
228
- </TooltipTrigger>
229
- <TooltipContent>
230
- {t('admin.components.table_toolbar.filters_button')}
231
- </TooltipContent>
232
- </Tooltip>
233
- <PopoverContent align="end" className="w-[480px] p-0">
234
- <FilterPanel
235
- columns={filterableColumns}
236
- filters={filters}
237
- onApply={(f) => {
238
- onFiltersChange(f)
239
- setFilterOpen(false)
240
- }}
241
- onClose={() => setFilterOpen(false)}
242
- />
243
- </PopoverContent>
244
- </Popover>
245
- )}
246
-
247
- {/* Sort dropdown — hidden when the table is drag-reorderable, since free sorting would defeat the drag. */}
248
- {!hideSort && sortableColumns.length > 0 && (
249
- <SortDropdown columns={sortableColumns} sort={sort} onSortChange={onSortChange} />
250
- )}
251
-
252
- {/* Column selector */}
253
- <ColumnSelector
254
- columns={columns.filter((c) => c.default !== undefined)}
255
- visibleColumns={visibleColumns}
256
- onVisibleColumnsChange={onVisibleColumnsChange}
257
- />
258
-
259
- {actions}
260
- </div>
261
- </div>
395
+ {/* Sort and columns close the row from the right. They shape the
396
+ view rather than narrowing it, so they sit apart from the filters
397
+ — and stay on the first line however many filters are set. */}
398
+ <div className="flex shrink-0 items-center gap-2">
399
+ {/* Hidden when the table is drag-reorderable, since free sorting
400
+ would defeat the drag. */}
401
+ {!hideSort && sortableColumns.length > 0 && (
402
+ <SortDropdown columns={sortableColumns} sort={sort} onSortChange={onSortChange} />
403
+ )}
262
404
 
263
- {/* Active filter badges */}
264
- {filters.length > 0 && (
265
- <div className="flex gap-2 flex-wrap px-3 py-2 border-b border-border">
266
- {filters.map((filter) => (
267
- <FilterChip
268
- key={filter.id}
269
- filter={filter}
270
- col={allCols.find((c) => c.key === filter.field)}
271
- onRemove={() => onFiltersChange(filters.filter((f) => f.id !== filter.id))}
272
- />
273
- ))}
274
- <button
275
- type="button"
276
- className="text-xs text-muted-foreground hover:text-foreground"
277
- onClick={() => onFiltersChange([])}
278
- >
279
- {t('admin.components.table_toolbar.clear_all')}
280
- </button>
405
+ {/* Hidden on a phone, where too few columns are visible at once for
406
+ choosing between them to mean anything. */}
407
+ <div className="hidden lg:contents">
408
+ <ColumnSelector
409
+ columns={columns.filter((c) => c.default !== undefined)}
410
+ visibleColumns={visibleColumns}
411
+ onVisibleColumnsChange={onVisibleColumnsChange}
412
+ />
413
+ </div>
414
+ </div>
281
415
  </div>
282
416
  )}
283
417
  </>
@@ -289,43 +423,97 @@ export function TableToolbar({
289
423
  // ============================================================================
290
424
 
291
425
  function FilterChip({
292
- filter,
426
+ rules,
293
427
  col,
294
428
  onRemove,
295
429
  }: {
296
- filter: FilterRule
430
+ /** Usually one rule; a bounded date range is two, shown as one chip. */
431
+ rules: FilterRule[]
297
432
  col: ColumnDef | undefined
298
433
  onRemove: () => void
299
434
  }) {
300
- const { t } = useTranslation()
301
- const opLabelKey = getOperatorLabelKey(col?.filterType ?? 'string', filter.operator)
302
- const opLabel = opLabelKey ? t(opLabelKey) : filter.operator
303
- const showValue = !noValueOperators.includes(filter.operator)
435
+ const { t, i18n } = useTranslation()
436
+ const first = rules[0]
437
+
438
+ /**
439
+ * A `yyyy-MM-dd` bound is a calendar date, not an instant, so it is
440
+ * formatted in the viewer's locale without a timezone conversion — shifting
441
+ * it by a zone offset is what would turn "1 August" into "31 July".
442
+ */
443
+ function formatDate(value: string) {
444
+ const [year, month, day] = value.split('-').map(Number)
445
+ if (!year || !month || !day) return value
446
+ return new Date(year, month - 1, day).toLocaleDateString(i18n.language, {
447
+ day: 'numeric',
448
+ month: 'short',
449
+ year: 'numeric',
450
+ })
451
+ }
452
+
453
+ // Read the range's ends rather than assuming an order: the two rules arrive
454
+ // in whatever order they were applied.
455
+ const from = rules.find((rule) => rule.operator === 'gteq')?.value
456
+ const to = rules.find((rule) => rule.operator === 'lteq')?.value
457
+ const isRange = col?.filterType === 'date' && rules.length > 1 && from && to
458
+
459
+ const label = col?.label ?? first.field
460
+ const opLabelKey = getOperatorLabelKey(col?.filterType ?? 'string', first.operator)
461
+ const opLabel = opLabelKey ? t(opLabelKey) : first.operator
462
+ const showValue = !noValueOperators.includes(first.operator)
304
463
 
305
464
  return (
306
- <Badge variant="outline" className="gap-1.5 pr-0.5">
307
- <span className="font-medium">{col?.label ?? filter.field}</span>
308
- <span className="text-muted-foreground">{opLabel}</span>
309
- {showValue &&
310
- (col?.filterType === 'resource' && col.filterResource ? (
311
- <ResourceFilterValue value={filter.value} config={col.filterResource} />
312
- ) : col?.filterType === 'tags' ? (
313
- <span className="font-medium">{parseFilterIds(filter.value).join(', ')}</span>
314
- ) : col?.filterType === 'enum' && col.filterOptions ? (
315
- <span className="font-medium">
316
- {col.filterOptions.find((o) => o.value === filter.value)?.label ?? filter.value}
317
- </span>
318
- ) : (
319
- <span className="font-medium">{filter.value}</span>
320
- ))}
465
+ // Deliberately shaped like the quick-filter buttons beside it rather than
466
+ // like a Badge: on this row it is the same kind of thing — a constraint
467
+ // you can see and remove — and a chip that reads as a label instead of a
468
+ // control invites the reader to look for the control elsewhere.
469
+ //
470
+ // So it wears the outline button's own surface — `bg-card`, `shadow-xs`,
471
+ // `rounded-md` — rather than a `bg-muted` of its own. A filled chip beside
472
+ // outlined controls reads as a different kind of object, when the only
473
+ // real difference is that this one is set and they are not: the solid
474
+ // border already says that, against the dashed one an unused control wears.
475
+ <span className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md border border-border bg-card py-1 pr-1 pl-2.5 text-sm shadow-xs lg:h-[2.125rem]">
476
+ <span className="text-muted-foreground">{label}</span>
477
+ {isRange ? (
478
+ // "1 Aug – 29 Aug 2026" rather than two chips of ISO bounds: the range
479
+ // is what was asked for, and it is read far more often than it is
480
+ // edited.
481
+ <span>
482
+ {formatDate(from)} – {formatDate(to)}
483
+ </span>
484
+ ) : (
485
+ <>
486
+ <span className="text-muted-foreground">{opLabel}</span>
487
+ {showValue &&
488
+ (col?.filterType === 'date' ? (
489
+ <span>{formatDate(first.value)}</span>
490
+ ) : col?.filterType === 'resource' && col.filterResource ? (
491
+ <ResourceFilterValue value={first.value} config={col.filterResource} />
492
+ ) : col?.filterType === 'tags' ? (
493
+ <span>{parseFilterIds(first.value).join(', ')}</span>
494
+ ) : col?.filterType === 'enum' && col.filterOptions ? (
495
+ <span>
496
+ {parseFilterIds(first.value)
497
+ .map((v) => col.filterOptions?.find((o) => o.value === v)?.label ?? v)
498
+ .join(', ')}
499
+ </span>
500
+ ) : (
501
+ <span>{first.value}</span>
502
+ ))}
503
+ </>
504
+ )}
505
+ {/* 20px box around a 12px glyph: the chip's own height caps how large
506
+ this can be, and the chips are spaced by `gap-2`, which is what the
507
+ target-size rule asks for when the control itself is under 24px. */}
321
508
  <button
322
509
  type="button"
323
- className="ml-0.5 p-0.5 rounded-full hover:bg-accent"
510
+ aria-label={t('admin.components.table_toolbar.remove_filter')}
511
+ className="ml-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
324
512
  onClick={onRemove}
325
513
  >
326
514
  <XIcon className="size-3" />
327
515
  </button>
328
- </Badge>
516
+ </span>
329
517
  )
330
518
  }
331
519
 
@@ -341,13 +529,13 @@ function ResourceFilterValue({
341
529
  value: string
342
530
  config: NonNullable<ColumnDef['filterResource']>
343
531
  }) {
344
- const { storeId } = useStore()
532
+ const tenantId = useTenantId()
345
533
  const ids = useMemo(() => parseFilterIds(value), [value])
346
534
 
347
535
  const { data } = useQuery({
348
- // Store-scope the cache key so chips don't resolve against another store's
349
- // hydrate results after a store switch.
350
- queryKey: ['filter-chip', config.queryKey, storeId, ids],
536
+ // Tenant-scope the cache key so chips don't resolve against another
537
+ // tenant's hydrate results after a store or seller switch.
538
+ queryKey: ['filter-chip', config.queryKey, tenantId, ids],
351
539
  queryFn: () => config.hydrate(ids),
352
540
  enabled: ids.length > 0,
353
541
  staleTime: 60_000,
@@ -360,92 +548,7 @@ function ResourceFilterValue({
360
548
  return record ? config.getOptionLabel(record) : id
361
549
  })
362
550
 
363
- return <span className="font-medium">{labels.join(', ')}</span>
364
- }
365
-
366
- /**
367
- * Currency value-picker for `filterType: 'currency'`. Options come from the
368
- * active store's `supported_currencies`. Branches on operator: `eq`/`not_eq`
369
- * use a single `<Select>`; `in`/`not_in` accept a CSV via Base UI's
370
- * multi-select Combobox chips, matching the other array-valued filters.
371
- */
372
- function CurrencyFilterPicker({
373
- value,
374
- operator,
375
- onChange,
376
- }: {
377
- value: string
378
- operator: string
379
- onChange: (next: string) => void
380
- }) {
381
- const { t } = useTranslation()
382
- const { currencies } = useStore()
383
- const items = useMemo(
384
- () => currencies.map((code) => ({ value: code, label: code })),
385
- [currencies],
386
- )
387
-
388
- if (operator === 'in' || operator === 'not_in') {
389
- return (
390
- <div className="flex-1 min-w-0">
391
- <CurrencyMultiSelect
392
- codes={currencies}
393
- value={parseFilterIds(value)}
394
- onChange={(next) => onChange(next.join(','))}
395
- />
396
- </div>
397
- )
398
- }
399
-
400
- return (
401
- <Select items={items} value={value || undefined} onValueChange={(val) => onChange(val)}>
402
- <SelectTrigger size="sm" className="flex-1">
403
- <SelectValue placeholder={t('admin.components.table_toolbar.filter_select_placeholder')} />
404
- </SelectTrigger>
405
- <SelectContent>
406
- {items.map((opt) => (
407
- <SelectItem key={opt.value} value={opt.value}>
408
- {opt.label}
409
- </SelectItem>
410
- ))}
411
- </SelectContent>
412
- </Select>
413
- )
414
- }
415
-
416
- function CurrencyMultiSelect({
417
- codes,
418
- value,
419
- onChange,
420
- }: {
421
- codes: string[]
422
- value: string[]
423
- onChange: (next: string[]) => void
424
- }) {
425
- function toggle(code: string) {
426
- onChange(value.includes(code) ? value.filter((c) => c !== code) : [...value, code])
427
- }
428
- return (
429
- <div className="flex flex-wrap gap-1 rounded-md border border-input px-2 py-1.5 text-sm">
430
- {codes.map((code) => {
431
- const selected = value.includes(code)
432
- return (
433
- <button
434
- key={code}
435
- type="button"
436
- onClick={() => toggle(code)}
437
- className={`rounded px-1.5 py-0.5 text-xs font-medium ${
438
- selected
439
- ? 'bg-primary text-primary-foreground'
440
- : 'bg-muted text-muted-foreground hover:bg-accent'
441
- }`}
442
- >
443
- {code}
444
- </button>
445
- )
446
- })}
447
- </div>
448
- )
551
+ return <span>{labels.join(', ')}</span>
449
552
  }
450
553
 
451
554
  // ============================================================================
@@ -469,7 +572,12 @@ function SortDropdown({
469
572
  <Tooltip>
470
573
  <TooltipTrigger asChild>
471
574
  <DropdownMenuTrigger asChild>
472
- <Button variant="outline" size="sm" className="h-[2.125rem]">
575
+ <Button
576
+ variant="outline"
577
+ size="sm"
578
+ className="h-11 lg:h-[2.125rem]"
579
+ aria-label={t('admin.components.table_toolbar.sort_tooltip')}
580
+ >
473
581
  <ArrowUpDownIcon className="size-4" />
474
582
  </Button>
475
583
  </DropdownMenuTrigger>
@@ -515,6 +623,388 @@ function SortDropdown({
515
623
  )
516
624
  }
517
625
 
626
+ // ============================================================================
627
+ // Quick filters
628
+ // ============================================================================
629
+
630
+ /**
631
+ * Trigger styling shared by the three quick filters.
632
+ *
633
+ * A control that is not narrowing anything wears a dashed border. Solid
634
+ * borders read as "this is set", and a row of four identical outlines made the
635
+ * operator check each one's value to find which were actually filtering the
636
+ * list. Dashed is the quieter state, so the ones doing work stand out without
637
+ * having to shout — the chips beside them stay solid for the same reason.
638
+ *
639
+ * `Add filter` keeps its solid border: it is an action, not a filter that
640
+ * happens to be empty.
641
+ */
642
+ function quickFilterTriggerClass(unset: boolean) {
643
+ return cn('h-11 gap-1.5 lg:h-[2.125rem]', unset && 'border-dashed')
644
+ }
645
+
646
+ /**
647
+ * Always-visible control for one heavily-used filter.
648
+ *
649
+ * These write ordinary `FilterRule`s, so a status set here is indistinguishable
650
+ * from one set in the popover — same URL state, same chips, same export. The
651
+ * difference is only how many clicks it took.
652
+ *
653
+ * Unlike the popover, a quick filter applies on change rather than on Apply: a
654
+ * single toggle is cheap to undo, so making the operator confirm it costs more
655
+ * than it protects. The popover keeps its Apply, where several half-edited rows
656
+ * would otherwise each fire a request.
657
+ */
658
+ function QuickEnumFilter({
659
+ column,
660
+ filters,
661
+ onFiltersChange,
662
+ }: {
663
+ column: ColumnDef
664
+ filters: FilterRule[]
665
+ onFiltersChange: (filters: FilterRule[]) => void
666
+ }) {
667
+ const { t } = useTranslation()
668
+ // A boolean column carries no `filterOptions` — its two values are fixed, so
669
+ // the control supplies them. `booleanLabels` names them where the domain has
670
+ // better words than Yes and No.
671
+ const options = useMemo(
672
+ () =>
673
+ column.filterType === 'boolean'
674
+ ? [
675
+ { value: 'true', label: column.booleanLabels?.true ?? t('admin.common.yes') },
676
+ { value: 'false', label: column.booleanLabels?.false ?? t('admin.common.no') },
677
+ ]
678
+ : (column.filterOptions ?? []),
679
+ [column.filterType, column.filterOptions, column.booleanLabels, t],
680
+ )
681
+ const existing = filters.find((f) => f.field === column.key && f.operator === 'in')
682
+
683
+ // No rule means no constraint, which is every option — not none. Showing
684
+ // "0/7" for an unfiltered list would read as "nothing matches".
685
+ const selected = existing ? parseFilterIds(existing.value) : options.map((o) => o.value)
686
+ const allSelected = selected.length === options.length
687
+
688
+ const apply = useCallback(
689
+ (values: string[]) => {
690
+ const others = filters.filter((f) => !(f.field === column.key && f.operator === 'in'))
691
+ // Selecting everything is the same as filtering by nothing, so drop the
692
+ // rule instead of listing every value in the URL.
693
+ if (values.length === 0 || values.length === options.length) {
694
+ onFiltersChange(others)
695
+ return
696
+ }
697
+ onFiltersChange([
698
+ ...others,
699
+ {
700
+ id: existing?.id ?? crypto.randomUUID(),
701
+ field: column.key,
702
+ operator: 'in',
703
+ value: values.join(','),
704
+ },
705
+ ])
706
+ },
707
+ [filters, column.key, options.length, existing?.id, onFiltersChange],
708
+ )
709
+
710
+ return (
711
+ <DropdownMenu>
712
+ <DropdownMenuTrigger asChild>
713
+ <Button variant="outline" size="sm" className={quickFilterTriggerClass(allSelected)}>
714
+ <span className="text-muted-foreground">{column.label}</span>
715
+ <span className="tabular-nums">
716
+ {allSelected ? t('admin.common.all') : `${selected.length}/${options.length}`}
717
+ </span>
718
+ <ChevronDownIcon className="size-3.5 text-muted-foreground" />
719
+ </Button>
720
+ </DropdownMenuTrigger>
721
+ <DropdownMenuContent align="start" className="min-w-52">
722
+ {options.map((option) => (
723
+ <DropdownMenuCheckboxItem
724
+ key={option.value}
725
+ checked={selected.includes(option.value)}
726
+ onCheckedChange={(checked) =>
727
+ apply(
728
+ checked ? [...selected, option.value] : selected.filter((v) => v !== option.value),
729
+ )
730
+ }
731
+ >
732
+ <StatusDot status={option.value} />
733
+ {option.label}
734
+ </DropdownMenuCheckboxItem>
735
+ ))}
736
+ </DropdownMenuContent>
737
+ </DropdownMenu>
738
+ )
739
+ }
740
+
741
+ /**
742
+ * Picks records for one `resource` column — a warehouse, a supplier — without
743
+ * opening the filter panel.
744
+ *
745
+ * Where the config can list the resource whole (`listAll`), this is the same
746
+ * checkbox menu the enum quick filter uses: for a store's four warehouses, a
747
+ * list to tick is faster than a box to type into, and it matches the control
748
+ * next to it. Without `listAll` the column stays in the filter panel, where
749
+ * the search-driven picker belongs.
750
+ *
751
+ * Like the enum control it applies on change: a single pick is cheap to undo.
752
+ */
753
+ function QuickResourceFilter({
754
+ column,
755
+ config,
756
+ filters,
757
+ onFiltersChange,
758
+ }: {
759
+ column: ColumnDef
760
+ config: NonNullable<ColumnDef['filterResource']>
761
+ filters: FilterRule[]
762
+ onFiltersChange: (filters: FilterRule[]) => void
763
+ }) {
764
+ const { t } = useTranslation()
765
+ const tenantId = useTenantId()
766
+ const existing = filters.find((f) => f.field === column.key && f.operator === 'in')
767
+ const existingValue = existing?.value
768
+ const selected = useMemo(
769
+ () => (existingValue ? parseFilterIds(existingValue) : []),
770
+ [existingValue],
771
+ )
772
+
773
+ const { data } = useQuery({
774
+ queryKey: ['quick-filter-all', config.queryKey, tenantId],
775
+ queryFn: () => config.listAll?.() ?? Promise.resolve({ data: [] }),
776
+ enabled: !!config.listAll,
777
+ staleTime: 60_000,
778
+ })
779
+ const options = useMemo(
780
+ () =>
781
+ (data?.data ?? []).map((item) => ({ value: item.id, label: config.getOptionLabel(item) })),
782
+ [data, config.getOptionLabel],
783
+ )
784
+
785
+ const apply = useCallback(
786
+ (values: string[]) => {
787
+ const others = filters.filter((f) => !(f.field === column.key && f.operator === 'in'))
788
+ // Everything selected is the same as no constraint, so drop the rule
789
+ // rather than listing every id in the URL.
790
+ if (values.length === 0 || values.length === options.length) {
791
+ onFiltersChange(others)
792
+ return
793
+ }
794
+ onFiltersChange([
795
+ ...others,
796
+ {
797
+ id: existing?.id ?? crypto.randomUUID(),
798
+ field: column.key,
799
+ operator: 'in',
800
+ value: values.join(','),
801
+ },
802
+ ])
803
+ },
804
+ [filters, column.key, options.length, existing?.id, onFiltersChange],
805
+ )
806
+
807
+ // No rule means no constraint, which is every option — not none.
808
+ const active = selected.length > 0 ? selected : options.map((o) => o.value)
809
+ const summary =
810
+ selected.length === 0
811
+ ? t('admin.common.all')
812
+ : selected.length === 1
813
+ ? (options.find((o) => o.value === selected[0])?.label ?? '1')
814
+ : `${selected.length}/${options.length}`
815
+
816
+ return (
817
+ <DropdownMenu>
818
+ <DropdownMenuTrigger asChild>
819
+ <Button
820
+ variant="outline"
821
+ size="sm"
822
+ className={quickFilterTriggerClass(selected.length === 0)}
823
+ >
824
+ <span className="text-muted-foreground">{column.label}</span>
825
+ <span className="max-w-40 truncate">{summary}</span>
826
+ <ChevronDownIcon className="size-3.5 text-muted-foreground" />
827
+ </Button>
828
+ </DropdownMenuTrigger>
829
+ <DropdownMenuContent align="start" className="min-w-52">
830
+ {options.map((option) => (
831
+ <DropdownMenuCheckboxItem
832
+ key={option.value}
833
+ checked={active.includes(option.value)}
834
+ onCheckedChange={(checked) =>
835
+ apply(checked ? [...active, option.value] : active.filter((v) => v !== option.value))
836
+ }
837
+ >
838
+ {option.label}
839
+ </DropdownMenuCheckboxItem>
840
+ ))}
841
+ </DropdownMenuContent>
842
+ </DropdownMenu>
843
+ )
844
+ }
845
+
846
+ /**
847
+ * Relative date presets for one date column.
848
+ *
849
+ * A preset is a single choice rather than a range builder, which keeps it one
850
+ * `FilterRule` and therefore one chip. Bounded ranges — "last month", or
851
+ * anything picked by hand — need both ends, so those write two rules and read
852
+ * back as two chips, which is honest about what was asked for.
853
+ */
854
+ function QuickDateFilter({
855
+ column,
856
+ filters,
857
+ onFiltersChange,
858
+ }: {
859
+ column: ColumnDef
860
+ filters: FilterRule[]
861
+ onFiltersChange: (filters: FilterRule[]) => void
862
+ }) {
863
+ const { t } = useTranslation()
864
+ const store = useOptionalStore()
865
+ // A panel with no store context (a seller's) falls back to the viewer's zone
866
+ // rather than refusing to offer presets.
867
+ const timezone = store?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
868
+
869
+ const from = filters.find((f) => f.field === column.key && f.operator === 'gteq')
870
+ const to = filters.find((f) => f.field === column.key && f.operator === 'lteq')
871
+ const active = matchDatePreset({ from: from?.value ?? null, to: to?.value ?? null }, timezone)
872
+ // Seeded from what is applied, so reopening on a hand-picked range shows the
873
+ // dates it is filtering by rather than an empty form.
874
+ const [custom, setCustom] = useState<{ from: string; to: string } | null>(null)
875
+ const [open, setOpen] = useState(false)
876
+
877
+ /** Replace this column's bounds. Either end may be null for an open range. */
878
+ const applyRange = useCallback(
879
+ (rangeFrom: string | null, rangeTo: string | null) => {
880
+ const others = filters.filter(
881
+ (f) => !(f.field === column.key && (f.operator === 'gteq' || f.operator === 'lteq')),
882
+ )
883
+ const next: FilterRule[] = [...others]
884
+ if (rangeFrom) {
885
+ next.push({
886
+ id: crypto.randomUUID(),
887
+ field: column.key,
888
+ operator: 'gteq',
889
+ value: rangeFrom,
890
+ })
891
+ }
892
+ if (rangeTo) {
893
+ next.push({ id: crypto.randomUUID(), field: column.key, operator: 'lteq', value: rangeTo })
894
+ }
895
+ onFiltersChange(next)
896
+ },
897
+ [filters, column.key, onFiltersChange],
898
+ )
899
+
900
+ const apply = useCallback(
901
+ (preset: DatePresetKey) => {
902
+ const range = resolveDatePreset(preset, timezone)
903
+ applyRange(range.from, range.to)
904
+ },
905
+ [timezone, applyRange],
906
+ )
907
+
908
+ // A Popover rather than a DropdownMenu: the custom branch holds date pickers
909
+ // and a submit, which a menu's roving focus fights.
910
+ return (
911
+ <Popover
912
+ open={open}
913
+ onOpenChange={(next) => {
914
+ setOpen(next)
915
+ // Reopening starts on the preset list unless a custom range is what is
916
+ // applied — then it opens on the range, so the dates stay editable.
917
+ if (next) {
918
+ setCustom(active === 'custom' ? { from: from?.value ?? '', to: to?.value ?? '' } : null)
919
+ }
920
+ }}
921
+ >
922
+ <PopoverTrigger asChild>
923
+ <Button variant="outline" size="sm" className={quickFilterTriggerClass(active === 'all')}>
924
+ <span className="text-muted-foreground">{column.label}</span>
925
+ <span>{t(`admin.components.table_toolbar.date_presets.${active}`)}</span>
926
+ <ChevronDownIcon className="size-3.5 text-muted-foreground" />
927
+ </Button>
928
+ </PopoverTrigger>
929
+ <PopoverContent align="start" className="w-[min(320px,calc(100vw-1rem))] p-1">
930
+ {custom ? (
931
+ <div data-slot="filter-panel-controls" className="flex flex-col gap-2 p-2">
932
+ <div className="flex flex-col gap-1">
933
+ <span className="text-xs text-muted-foreground">
934
+ {t('admin.components.table_toolbar.date_range_start')}
935
+ </span>
936
+ <StoreDatePicker
937
+ value={custom.from || null}
938
+ onChange={(next) => setCustom({ ...custom, from: next ?? '' })}
939
+ placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
940
+ />
941
+ </div>
942
+ <div className="flex flex-col gap-1">
943
+ <span className="text-xs text-muted-foreground">
944
+ {t('admin.components.table_toolbar.date_range_end')}
945
+ </span>
946
+ <StoreDatePicker
947
+ value={custom.to || null}
948
+ onChange={(next) => setCustom({ ...custom, to: next ?? '' })}
949
+ placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
950
+ />
951
+ </div>
952
+ <div className="flex justify-end gap-1.5">
953
+ <Button variant="ghost" size="sm" onClick={() => setCustom(null)}>
954
+ {t('admin.actions.back')}
955
+ </Button>
956
+ <Button
957
+ size="sm"
958
+ // Either bound alone is a valid filter — "everything since
959
+ // March" is a question people ask.
960
+ disabled={!custom.from && !custom.to}
961
+ onClick={() => {
962
+ applyRange(custom.from || null, custom.to || null)
963
+ setOpen(false)
964
+ }}
965
+ >
966
+ {t('admin.actions.apply')}
967
+ </Button>
968
+ </div>
969
+ </div>
970
+ ) : (
971
+ <div className="flex flex-col">
972
+ {DATE_PRESET_KEYS.map((preset) => (
973
+ <button
974
+ key={preset}
975
+ type="button"
976
+ data-slot="filter-panel-item"
977
+ className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
978
+ onClick={() => {
979
+ apply(preset)
980
+ setOpen(false)
981
+ }}
982
+ >
983
+ {t(`admin.components.table_toolbar.date_presets.${preset}`)}
984
+ {active === preset && <CheckIcon className="size-3.5 shrink-0" />}
985
+ </button>
986
+ ))}
987
+ {/* Without this the quick control is the only route to a date
988
+ filter on tables whose every filterable column is a quick one —
989
+ and it offers presets only, so a hand-picked range would be
990
+ unreachable, and one restored from a URL would show as neither
991
+ selected nor removable. */}
992
+ <button
993
+ type="button"
994
+ data-slot="filter-panel-item"
995
+ className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
996
+ onClick={() => setCustom({ from: from?.value ?? '', to: to?.value ?? '' })}
997
+ >
998
+ {t('admin.components.table_toolbar.date_presets.custom')}
999
+ <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
1000
+ </button>
1001
+ </div>
1002
+ )}
1003
+ </PopoverContent>
1004
+ </Popover>
1005
+ )
1006
+ }
1007
+
518
1008
  // ============================================================================
519
1009
  // Column Selector
520
1010
  // ============================================================================
@@ -536,7 +1026,12 @@ function ColumnSelector({
536
1026
  <Tooltip>
537
1027
  <TooltipTrigger asChild>
538
1028
  <DropdownMenuTrigger asChild>
539
- <Button variant="outline" size="sm" className="h-[2.125rem]">
1029
+ <Button
1030
+ variant="outline"
1031
+ size="sm"
1032
+ className="h-11 lg:h-[2.125rem]"
1033
+ aria-label={t('admin.components.table_toolbar.columns_tooltip')}
1034
+ >
540
1035
  <Columns3Icon className="size-4" />
541
1036
  </Button>
542
1037
  </DropdownMenuTrigger>
@@ -563,16 +1058,14 @@ function ColumnSelector({
563
1058
  </DropdownMenuCheckboxItem>
564
1059
  ))}
565
1060
  <DropdownMenuSeparator />
566
- <div className="px-1 py-1">
567
- <Button
568
- variant="ghost"
569
- size="sm"
570
- className="w-full justify-center"
571
- onClick={() => onVisibleColumnsChange(defaults)}
572
- >
573
- {t('admin.components.table_toolbar.reset_to_default')}
574
- </Button>
575
- </div>
1061
+ <Button
1062
+ variant="ghost"
1063
+ size="sm"
1064
+ className="w-full justify-center"
1065
+ onClick={() => onVisibleColumnsChange(defaults)}
1066
+ >
1067
+ {t('admin.components.table_toolbar.reset_to_default')}
1068
+ </Button>
576
1069
  </DropdownMenuContent>
577
1070
  </DropdownMenu>
578
1071
  )
@@ -581,252 +1074,676 @@ function ColumnSelector({
581
1074
  // ============================================================================
582
1075
  // Filter Drawer
583
1076
  // ============================================================================
1077
+ /**
1078
+ * Inline, searchable, multi-select list of a filter's candidate values.
1079
+ *
1080
+ * Tags and resources previously rendered a combobox, which opened its own
1081
+ * dropdown inside a panel that already had the room to show the options — a
1082
+ * popup over a popup, to display a list. Here the list is simply the panel's
1083
+ * body, filtered by the search box already in its header.
1084
+ *
1085
+ * Both types are "is any of" by nature, so there is no operator to choose:
1086
+ * ticking two tags means either, which is the only reading a set of tags has.
1087
+ */
1088
+ function InlineValueList({
1089
+ column,
1090
+ tenantId,
1091
+ query,
1092
+ selected,
1093
+ onToggle,
1094
+ }: {
1095
+ column: ColumnDef
1096
+ tenantId: string | undefined
1097
+ query: string
1098
+ selected: string[]
1099
+ onToggle: (value: string) => void
1100
+ }) {
1101
+ const { t } = useTranslation()
1102
+ const { isAuthenticated } = useAuth()
1103
+ const tagsClient = getApiClient().tags
1104
+ const isTags = column.filterType === 'tags'
1105
+ const isCurrency = column.filterType === 'currency'
1106
+ // `filterType: 'currency'` is only declared by tables in the operator's
1107
+ // dashboard, which always has a store. Reading it optionally keeps the panel
1108
+ // mountable in one that has none.
1109
+ const currencies = useOptionalStore()?.currencies ?? noCurrencies
1110
+
1111
+ // Tags arrive as one list and are filtered client-side; resources are
1112
+ // searched server-side, since their vocabulary is unbounded.
1113
+ const tags = useQuery({
1114
+ // Tenant-scoped like the resource query below: tag vocabularies are
1115
+ // per-store, and a shared key would serve one store's tags to the next for
1116
+ // as long as the result stays fresh.
1117
+ queryKey: ['panel-tags', tenantId, column.taggableType],
1118
+ queryFn: () => tagsClient?.list({ taggable_type: column.taggableType as TaggableType }),
1119
+ enabled: isTags && isAuthenticated && Boolean(tagsClient),
1120
+ // One list, filtered client-side, and it rarely changes — worth holding
1121
+ // for the session so reopening the panel never refetches.
1122
+ staleTime: 5 * 60_000,
1123
+ gcTime: 30 * 60_000,
1124
+ })
584
1125
 
1126
+ // Deferred so typing stays responsive: the input updates immediately while
1127
+ // the list re-renders at React's convenience, matching the resource picker
1128
+ // sheet. It lowers request volume under load rather than guaranteeing it —
1129
+ // React may still let every keystroke through on a fast machine. What keeps
1130
+ // that cheap is the cache: repeated prefixes are served from it, and only a
1131
+ // genuinely new query reaches the network.
1132
+ const deferredQuery = useDeferredValue(query)
1133
+
1134
+ const resources = useQuery({
1135
+ // Tenant-scoped so a store or seller switch cannot reuse the previous
1136
+ // tenant's results.
1137
+ queryKey: ['panel-resource-filter', column.filterResource?.queryKey, tenantId, deferredQuery],
1138
+ queryFn: () => column.filterResource?.search(deferredQuery),
1139
+ enabled: !isTags && !isCurrency && Boolean(column.filterResource),
1140
+ // These lists are reference data an operator reopens repeatedly while
1141
+ // narrowing one list, and the result is small now that only the displayed
1142
+ // fields are requested. Holding it for the session keeps reopening the
1143
+ // panel instant; `gcTime` outlives the unmount between opens.
1144
+ staleTime: 5 * 60_000,
1145
+ gcTime: 30 * 60_000,
1146
+ // Keeps the previous matches on screen while the next query resolves, so
1147
+ // the list does not blank on every keystroke.
1148
+ //
1149
+ // Only within one field of one tenant: the second argument is the query
1150
+ // those results came from, so a change of store, seller or filter column
1151
+ // drops them instead of briefly offering another tenant's records as
1152
+ // though they were this one's.
1153
+ placeholderData: (previous, previousQuery) => {
1154
+ const [, previousResource, previousTenant] = (previousQuery?.queryKey ?? []) as unknown[]
1155
+ const sameScope =
1156
+ previousResource === column.filterResource?.queryKey && previousTenant === tenantId
1157
+ return sameScope ? previous : undefined
1158
+ },
1159
+ })
1160
+
1161
+ const options = useMemo(() => {
1162
+ if (isCurrency) {
1163
+ const needle = query.trim().toLowerCase()
1164
+ return currencies
1165
+ .filter((code) => !needle || code.toLowerCase().includes(needle))
1166
+ .map((code) => ({ value: code, label: code }))
1167
+ }
1168
+ if (isTags) {
1169
+ const names = tags.data?.data?.map((tag) => tag.name) ?? []
1170
+ const needle = query.trim().toLowerCase()
1171
+ const matching = needle ? names.filter((name) => name.toLowerCase().includes(needle)) : names
1172
+ return matching.map((name) => ({ value: name, label: name }))
1173
+ }
1174
+ const config = column.filterResource
1175
+ if (!config) return []
1176
+ return (resources.data?.data ?? []).map((record) => ({
1177
+ value: record.id,
1178
+ label: config.getOptionLabel(record),
1179
+ }))
1180
+ }, [isCurrency, currencies, isTags, tags.data, resources.data, column.filterResource, query])
1181
+
1182
+ // Selected values ride at the top so a choice made before searching does not
1183
+ // disappear the moment the query stops matching it.
1184
+ const ordered = useMemo(() => {
1185
+ const chosen = options.filter((option) => selected.includes(option.value))
1186
+ const rest = options.filter((option) => !selected.includes(option.value))
1187
+ // Not truncated: a list cut at ten looks like a list of ten, and a
1188
+ // vocabulary of forty tags would silently lose thirty with nothing on
1189
+ // screen to say so. The container scrolls instead, so the scrollbar
1190
+ // reports how much more there is — and searching narrows it.
1191
+ return [...chosen, ...rest]
1192
+ }, [options, selected])
1193
+
1194
+ if (!isCurrency && (tags.isLoading || resources.isLoading)) {
1195
+ return (
1196
+ <p className="px-2 py-6 text-center text-muted-foreground">{t('admin.common.loading')}</p>
1197
+ )
1198
+ }
1199
+
1200
+ if (ordered.length === 0) {
1201
+ return (
1202
+ <p className="px-2 py-6 text-center text-muted-foreground">{t('admin.common.no_results')}</p>
1203
+ )
1204
+ }
1205
+
1206
+ return (
1207
+ <>
1208
+ {ordered.map((option) => (
1209
+ <button
1210
+ key={option.value}
1211
+ type="button"
1212
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1213
+ onClick={() => onToggle(option.value)}
1214
+ >
1215
+ <Checkbox checked={selected.includes(option.value)} className="pointer-events-none" />
1216
+ <span className="truncate">{option.label}</span>
1217
+ </button>
1218
+ ))}
1219
+ </>
1220
+ )
1221
+ }
1222
+
1223
+ /**
1224
+ * The advanced filter panel, in two stages.
1225
+ *
1226
+ * Stage one lists the fields. Stage two commits to one of them and shows its
1227
+ * values, with the field name kept in the header as the way back to stage one.
1228
+ *
1229
+ * One filter at a time, deliberately: filters compose by being applied in
1230
+ * succession, and each one lands as a chip on the toolbar row, so the row is
1231
+ * the list of what is active. A panel that also held several half-built rows
1232
+ * duplicated that list in a second place, in a different notation.
1233
+ */
585
1234
  function FilterPanel({
586
1235
  columns,
587
- filters: initialFilters,
1236
+ allColumns,
1237
+ quickFilterKeys,
1238
+ filters,
588
1239
  onApply,
589
- onClose,
1240
+ onChange,
590
1241
  }: {
1242
+ /** Fields offered first — those with no quick control of their own. */
591
1243
  columns: ColumnDef[]
1244
+ /** Every filterable field. The ones absent from `columns` are listed after
1245
+ * them, for the operators their quick control cannot express. */
1246
+ allColumns: ColumnDef[]
1247
+ /** Which fields have a quick control on the row. Such a field goes to the
1248
+ * operator picker rather than the one-click value list: the list writes
1249
+ * `eq`, and only `in` is the operator a control displays, so a one-click
1250
+ * `eq` would chip while its control still read "All". */
1251
+ quickFilterKeys: Set<string>
592
1252
  filters: FilterRule[]
1253
+ /** Commit and close — for the filters that are finished in one action. */
593
1254
  onApply: (filters: FilterRule[]) => void
594
- onClose: () => void
1255
+ /** Update without closing — for a multi-select the user is still building. */
1256
+ onChange: (filters: FilterRule[]) => void
595
1257
  }) {
596
1258
  const { t } = useTranslation()
597
- const { storeId } = useStore()
598
- const [draft, setDraft] = useState<FilterRule[]>(initialFilters)
599
- const booleanItems = useMemo(
600
- () => [
601
- { value: 'true', label: t('admin.common.yes') },
602
- { value: 'false', label: t('admin.common.no') },
603
- ],
604
- [t],
605
- )
606
- // Stable `{ value, label }` array for the field-picker `<Select items>`.
607
- // Building it inline per render produces a new reference each time, which
608
- // Base UI's Select treats as an `items` change and re-emits state — causing
609
- // a "Maximum update depth exceeded" loop when the value-Select sits inside
610
- // the same panel.
611
- const fieldItems = useMemo(
612
- () => columns.map((c) => ({ value: c.key, label: c.label })),
613
- [columns],
1259
+ const tenantId = useTenantId()
1260
+ const store = useOptionalStore()
1261
+ const timezone = store?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
1262
+ // The field being built, or null while the field list is showing.
1263
+ const [field, setField] = useState<string | null>(null)
1264
+ const [operator, setOperator] = useState('')
1265
+ const [value, setValue] = useState('')
1266
+ const [query, setQuery] = useState('')
1267
+ // Set once the user picks "custom" from the date presets, revealing the two
1268
+ // date fields. Kept separate from `value` so backing out of custom does not
1269
+ // strand a half-entered range.
1270
+ const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null)
1271
+
1272
+ // Resolved against every field, not just the offered ones: the switch
1273
+ // select can land on a quick-filtered field the list deliberately omits.
1274
+ const column = allColumns.find((c) => c.key === field)
1275
+ const type = column?.filterType ?? 'string'
1276
+ const operators = useMemo(
1277
+ () => getOperators(type).map((op) => ({ value: op.value, label: t(op.labelKey) })),
1278
+ [type, t],
614
1279
  )
615
1280
 
616
- const addFilter = useCallback(() => {
617
- const first = columns[0]
618
- if (!first) return
619
- setDraft((prev) => [
620
- ...prev,
621
- {
622
- id: crypto.randomUUID(),
623
- field: first.key,
624
- operator: getOperators(first.filterType ?? 'string')[0].value,
625
- value: '',
626
- },
1281
+ // Types whose values are a fixed list the panel can render inline. Anything
1282
+ // else needs its own control (an autocomplete, a date picker, a text box),
1283
+ // which is what the second branch below draws.
1284
+ // Types whose values are chosen from a list rather than typed. `multi` ones
1285
+ // accumulate a set and need an explicit Apply; the single ones commit on the
1286
+ // click that picks them.
1287
+ // A field whose quick control already covers the straightforward case is
1288
+ // here for the operators that control cannot express, so it skips the
1289
+ // shorthands — the value list and the date presets — and goes to the
1290
+ // operator picker. Those shorthands write exactly what the control already
1291
+ // writes, which is the one thing the panel does not need to offer twice.
1292
+ const quickFiltered = Boolean(field) && quickFilterKeys.has(field as string)
1293
+ const multi = !quickFiltered && (type === 'tags' || type === 'resource' || type === 'currency')
1294
+ // Dates are their own shape: a list of relative presets, with the calendar
1295
+ // behind "custom". Same presets the quick filter offers, so a range means
1296
+ // the same thing wherever it was set.
1297
+ const dated = !quickFiltered && type === 'date'
1298
+ const listed = !quickFiltered && (type === 'enum' || type === 'boolean' || multi)
1299
+ // The header search filters the list below it, so it earns its place only
1300
+ // when there is a list: the fields, or a list-shaped type's values.
1301
+ const searchable = (!field || listed) && !dated
1302
+ const listOptions = useMemo(() => {
1303
+ const options =
1304
+ type === 'boolean'
1305
+ ? [
1306
+ { value: 'true', label: column?.booleanLabels?.true ?? t('admin.common.yes') },
1307
+ { value: 'false', label: column?.booleanLabels?.false ?? t('admin.common.no') },
1308
+ ]
1309
+ : (column?.filterOptions ?? [])
1310
+ if (!query.trim()) return options
1311
+ const needle = query.trim().toLowerCase()
1312
+ return options.filter((o) => o.label.toLowerCase().includes(needle))
1313
+ }, [type, column?.filterOptions, column?.booleanLabels, query, t])
1314
+
1315
+ const visibleFields = useMemo(() => {
1316
+ const needle = query.trim().toLowerCase()
1317
+ const matching = (list: ColumnDef[]) =>
1318
+ needle ? list.filter((c) => c.label.toLowerCase().includes(needle)) : list
1319
+ return matching(columns)
1320
+ }, [columns, query])
1321
+
1322
+ // The fields a quick control already owns, listed under their own heading
1323
+ // below the rest. They are the only route to an operator the control cannot
1324
+ // express, and burying them entirely is what left `status is not active`
1325
+ // unbuildable from anywhere.
1326
+ const visibleQuickFields = useMemo(() => {
1327
+ const offered = allColumns.filter((c) => quickFilterKeys.has(c.key))
1328
+ const needle = query.trim().toLowerCase()
1329
+ return needle ? offered.filter((c) => c.label.toLowerCase().includes(needle)) : offered
1330
+ }, [allColumns, quickFilterKeys, query])
1331
+
1332
+ function chooseField(key: string) {
1333
+ const col = allColumns.find((c) => c.key === key)
1334
+ const firstOperator = getOperators(col?.filterType ?? 'string')[0].value
1335
+ setField(key)
1336
+ setOperator(firstOperator)
1337
+ // Seed from what is already applied, so reopening a multi-select shows the
1338
+ // values it is filtering by. Without this the list reads as empty and the
1339
+ // next tick would replace the existing set rather than extend it.
1340
+ const applied = filters.find((f) => f.field === key && f.operator === 'in')
1341
+ setValue(applied?.value ?? '')
1342
+ setQuery('')
1343
+ setCustomRange(null)
1344
+ }
1345
+
1346
+ /** Back to the field list. Clears the half-built rule with it — the search
1347
+ * box is shared between the two stages, and a value left behind would be
1348
+ * seeded into whichever field is picked next. */
1349
+ function clearField() {
1350
+ setField(null)
1351
+ setOperator('')
1352
+ setValue('')
1353
+ setQuery('')
1354
+ setCustomRange(null)
1355
+ }
1356
+
1357
+ /**
1358
+ * Apply this filter and close.
1359
+ *
1360
+ * Replaces any rule already set on the same field and operator rather than
1361
+ * adding a second: picking "Status is Active", then reopening and picking
1362
+ * "Status is Draft", would otherwise leave two `eq` rules that the server
1363
+ * ANDs together into nothing. Rules on the same field with a *different*
1364
+ * operator survive — `created_at after X` and `created_at before Y` are a
1365
+ * range, not a contradiction.
1366
+ */
1367
+ function commit(nextValue: string, nextOperator = operator) {
1368
+ if (!field) return
1369
+ if (!noValueOperators.includes(nextOperator) && nextValue.trim() === '') return
1370
+ const others = filters.filter((f) => !(f.field === field && f.operator === nextOperator))
1371
+ onApply([
1372
+ ...others,
1373
+ { id: crypto.randomUUID(), field, operator: nextOperator, value: nextValue },
627
1374
  ])
628
- }, [columns])
1375
+ }
629
1376
 
630
- const updateFilter = useCallback((id: string, update: Partial<FilterRule>) => {
631
- setDraft((prev) => prev.map((f) => (f.id === id ? { ...f, ...update } : f)))
632
- }, [])
1377
+ /** Replace this field's bounds with a preset's, and close. */
1378
+ function commitDatePreset(preset: DatePresetKey) {
1379
+ if (!field) return
1380
+ const range = resolveDatePreset(preset, timezone)
1381
+ const others = filters.filter(
1382
+ (f) => !(f.field === field && (f.operator === 'gteq' || f.operator === 'lteq')),
1383
+ )
1384
+ const next = [...others]
1385
+ if (range.from) {
1386
+ next.push({ id: crypto.randomUUID(), field, operator: 'gteq', value: range.from })
1387
+ }
1388
+ if (range.to) {
1389
+ next.push({ id: crypto.randomUUID(), field, operator: 'lteq', value: range.to })
1390
+ }
1391
+ onApply(next)
1392
+ }
633
1393
 
634
- const removeFilter = useCallback((id: string) => {
635
- setDraft((prev) => prev.filter((f) => f.id !== id))
636
- }, [])
1394
+ /**
1395
+ * Apply a multi-select as it is ticked, keeping the panel open.
1396
+ *
1397
+ * Replaces this field's rule rather than appending, so ticking three tags
1398
+ * leaves one rule listing three values instead of three rules — and
1399
+ * unticking the last one removes it, rather than leaving an empty rule that
1400
+ * would match nothing.
1401
+ */
1402
+ function toggleValue(option: string) {
1403
+ if (!field) return
1404
+ const current = parseFilterIds(value)
1405
+ const next = current.includes(option)
1406
+ ? current.filter((v) => v !== option)
1407
+ : [...current, option]
1408
+ setValue(next.join(','))
637
1409
 
638
- function handleApply() {
639
- const valid = draft.filter(
640
- (f) => noValueOperators.includes(f.operator) || f.value.trim() !== '',
1410
+ const others = filters.filter((f) => !(f.field === field && f.operator === 'in'))
1411
+ onChange(
1412
+ next.length === 0
1413
+ ? others
1414
+ : [...others, { id: crypto.randomUUID(), field, operator: 'in', value: next.join(',') }],
641
1415
  )
642
- onApply(valid)
643
1416
  }
644
1417
 
645
1418
  return (
646
- <div className="flex flex-col">
647
- <div className="flex items-center justify-between border-b px-3 py-2">
648
- <span className="text-sm font-medium">
649
- {t('admin.components.table_toolbar.filters_button')}
650
- </span>
651
- <button type="button" onClick={onClose} className="p-1 rounded hover:bg-muted">
652
- <XIcon className="size-3.5" />
653
- </button>
1419
+ // `rounded-xl` to match the popover, and `overflow-hidden` to enforce it:
1420
+ // the panel is mounted with `p-0`, so the header meets the popover's edge
1421
+ // directly and every square background inside it — the back button's hover
1422
+ // fill, the search field's own ground — paints over the rounded corner and
1423
+ // squares it off. Clipping here fixes both top corners once, rather than
1424
+ // asking each child to round the one it happens to touch.
1425
+ <div className="flex flex-col overflow-hidden rounded-xl">
1426
+ {/* Header. Before a field is chosen this is a label and a search box over
1427
+ the field list; after, the label becomes the way back to it. */}
1428
+ {/* The panel reads at the app's base size rather than the 13px the
1429
+ dropdowns beside it use: it is a surface to read a list in, not a
1430
+ menu to flick through, and its rows carry field names an operator has
1431
+ to recognise. Set here and on the body below, so a row only names a
1432
+ size when it departs from it. */}
1433
+ <div className="flex items-stretch border-b text-base">
1434
+ {field ? (
1435
+ // The field name is the way back, not a second picker. It used to
1436
+ // open a dropdown over the value list underneath — a menu on top of
1437
+ // the menu it was covering, offering the same fields the screen
1438
+ // behind it was already showing. Clicking it now simply returns to
1439
+ // that list, which is where changing subject was always going to
1440
+ // end up.
1441
+ <button
1442
+ type="button"
1443
+ className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-none border-r px-3 py-2 font-medium transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
1444
+ onClick={clearField}
1445
+ >
1446
+ {/* Leads the label rather than trailing it: a back arrow points at
1447
+ what it returns to, and after the word it pointed at nothing. */}
1448
+ <ChevronLeftIcon className="size-4 shrink-0 text-muted-foreground" />
1449
+ {column?.label ?? field}
1450
+ </button>
1451
+ ) : (
1452
+ <span className="flex shrink-0 items-center border-r px-3 py-2 font-medium">
1453
+ {t('admin.components.table_toolbar.filter_by')}
1454
+ </span>
1455
+ )}
1456
+ {/* Only where it filters something: a list of fields, or a list of
1457
+ values. The other value types bring their own input — a resource
1458
+ autocomplete, a date picker, a text box — and a second search box
1459
+ above one of those searches nothing. */}
1460
+ {searchable ? (
1461
+ <Input
1462
+ autoFocus
1463
+ value={query}
1464
+ onChange={(event) => setQuery(event.target.value)}
1465
+ placeholder={t('admin.components.table_toolbar.filter_to_placeholder')}
1466
+ className="min-w-0 flex-1 rounded-none border-0 shadow-none focus:shadow-none"
1467
+ />
1468
+ ) : (
1469
+ <span className="flex-1" />
1470
+ )}
1471
+ {/* Clears the search box, and only that. It used to close the whole
1472
+ panel, sitting a few pixels from a text field it looked like it
1473
+ belonged to — so the one gesture that reliably means "empty this
1474
+ field" threw away the filter being built instead. Closing is what
1475
+ Escape and a click outside already do, and neither is ambiguous. */}
1476
+ {searchable && query ? (
1477
+ <Button
1478
+ type="button"
1479
+ onClick={() => setQuery('')}
1480
+ size="icon-sm"
1481
+ variant="ghost"
1482
+ aria-label={t('admin.common.clear')}
1483
+ className="m-1 shrink-0"
1484
+ >
1485
+ <XIcon className="size-4" />
1486
+ </Button>
1487
+ ) : null}
654
1488
  </div>
655
1489
 
656
- <div className="max-h-[320px] overflow-y-auto p-3 space-y-2">
657
- {draft.map((filter) => {
658
- const col = columns.find((c) => c.key === filter.field)
659
- const type = col?.filterType ?? 'string'
660
- const ops = getOperators(type).map((op) => ({
661
- value: op.value,
662
- label: t(op.labelKey),
663
- }))
664
-
665
- return (
666
- <div key={filter.id} className="flex min-w-0 items-center gap-1.5">
667
- <Select
668
- items={fieldItems}
669
- value={filter.field}
670
- onValueChange={(val) => {
671
- const newCol = columns.find((c) => c.key === val)
672
- const newOps = getOperators(newCol?.filterType ?? 'string')
673
- updateFilter(filter.id, {
674
- field: val,
675
- operator: newOps[0].value,
676
- value: '',
677
- })
678
- }}
1490
+ {/* Scrolls, and says so. `overscroll-contain` keeps a flick at the end of
1491
+ the list from scrolling the page behind the panel; the themed
1492
+ scrollbar the popover supplies is what tells the reader there is more
1493
+ below, since the rows themselves end flush at the container's edge. */}
1494
+ <div className="max-h-[320px] overflow-y-auto overscroll-contain p-1 text-base themed-scrollbar">
1495
+ {/* Stage one: pick a subject. */}
1496
+ {!field &&
1497
+ visibleFields.map((col) => (
1498
+ <button
1499
+ key={col.key}
1500
+ type="button"
1501
+ data-slot="filter-panel-item"
1502
+ className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1503
+ onClick={() => chooseField(col.key)}
1504
+ >
1505
+ {col.label}
1506
+ <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
1507
+ </button>
1508
+ ))}
1509
+
1510
+ {/* The fields that already have a control on the row. Set apart under
1511
+ a heading rather than mixed in, because picking one here means
1512
+ something narrower than picking any field above: not "filter by
1513
+ status" — the control does that — but "filter by status in a way
1514
+ the control cannot say". */}
1515
+ {!field && visibleQuickFields.length > 0 && (
1516
+ <>
1517
+ {visibleFields.length > 0 && <div className="my-1 border-t" />}
1518
+ <p className="px-2 py-1.5 text-xs text-muted-foreground">
1519
+ {t('admin.components.table_toolbar.more_conditions')}
1520
+ </p>
1521
+ {visibleQuickFields.map((col) => (
1522
+ <button
1523
+ key={col.key}
1524
+ type="button"
1525
+ data-slot="filter-panel-item"
1526
+ className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1527
+ onClick={() => chooseField(col.key)}
679
1528
  >
680
- <SelectTrigger size="sm" className="flex-1">
681
- <SelectValue />
682
- </SelectTrigger>
683
- <SelectContent>
684
- {columns.map((c) => (
685
- <SelectItem key={c.key} value={c.key}>
686
- {c.label}
687
- </SelectItem>
688
- ))}
689
- </SelectContent>
690
- </Select>
1529
+ {col.label}
1530
+ <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
1531
+ </button>
1532
+ ))}
1533
+ </>
1534
+ )}
691
1535
 
692
- <Select
693
- items={ops}
694
- value={filter.operator}
695
- onValueChange={(val) => updateFilter(filter.id, { operator: val })}
1536
+ {/* Stage two, list-shaped types: the values themselves, one click to
1537
+ apply. No operator control — "is" is the only thing a list of
1538
+ values can mean, and offering "is not" here would double the list's
1539
+ length for a case the panel's other branch already covers. */}
1540
+ {field &&
1541
+ listed &&
1542
+ !multi &&
1543
+ listOptions.map((option) => (
1544
+ <button
1545
+ key={option.value}
1546
+ type="button"
1547
+ data-slot="filter-panel-item"
1548
+ className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1549
+ onClick={() => commit(option.value, 'eq')}
1550
+ >
1551
+ {/* Booleans get one too: their values carry the same
1552
+ green/red reading as any other state. */}
1553
+ {(type === 'enum' || type === 'boolean') && <StatusDot status={option.value} />}
1554
+ {option.label}
1555
+ </button>
1556
+ ))}
1557
+
1558
+ {/* Tags and resources: several values at once. Each tick applies
1559
+ immediately and the list stays open, so narrowing to two sellers is
1560
+ two clicks rather than two clicks and a confirmation — the table
1561
+ behind the panel is already showing the result. */}
1562
+ {field && multi && column && (
1563
+ <InlineValueList
1564
+ column={column}
1565
+ tenantId={tenantId}
1566
+ query={query}
1567
+ selected={parseFilterIds(value)}
1568
+ onToggle={toggleValue}
1569
+ />
1570
+ )}
1571
+
1572
+ {/* Stage two, dates: the same relative presets the quick filter
1573
+ offers, so a range means the same thing wherever it was set.
1574
+ "Custom" swaps in two date fields rather than opening a third
1575
+ popup on top of this one. */}
1576
+ {field && dated && !customRange && (
1577
+ <>
1578
+ {DATE_PRESET_KEYS.map((preset) => (
1579
+ <button
1580
+ key={preset}
1581
+ type="button"
1582
+ data-slot="filter-panel-item"
1583
+ className="flex w-full items-center rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1584
+ onClick={() => commitDatePreset(preset)}
1585
+ >
1586
+ {t(`admin.components.table_toolbar.date_presets.${preset}`)}
1587
+ </button>
1588
+ ))}
1589
+ <button
1590
+ type="button"
1591
+ data-slot="filter-panel-item"
1592
+ className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1593
+ onClick={() => setCustomRange({ from: '', to: '' })}
1594
+ >
1595
+ {t('admin.components.table_toolbar.date_presets.custom')}
1596
+ <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
1597
+ </button>
1598
+ </>
1599
+ )}
1600
+
1601
+ {field && dated && customRange && (
1602
+ <div data-slot="filter-panel-controls" className="flex flex-col gap-2 p-2">
1603
+ <div className="flex flex-col gap-1">
1604
+ <span className="text-xs text-muted-foreground">
1605
+ {t('admin.components.table_toolbar.date_range_start')}
1606
+ </span>
1607
+ <StoreDatePicker
1608
+ value={customRange.from || null}
1609
+ onChange={(next) => setCustomRange({ ...customRange, from: next ?? '' })}
1610
+ placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
1611
+ />
1612
+ </div>
1613
+ <div className="flex flex-col gap-1">
1614
+ <span className="text-xs text-muted-foreground">
1615
+ {t('admin.components.table_toolbar.date_range_end')}
1616
+ </span>
1617
+ <StoreDatePicker
1618
+ value={customRange.to || null}
1619
+ onChange={(next) => setCustomRange({ ...customRange, to: next ?? '' })}
1620
+ placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
1621
+ />
1622
+ </div>
1623
+ <div className="flex justify-end gap-1.5">
1624
+ <Button variant="ghost" size="sm" onClick={() => setCustomRange(null)}>
1625
+ {t('admin.actions.back')}
1626
+ </Button>
1627
+ <Button
1628
+ size="sm"
1629
+ // Either bound alone is a valid filter — "everything since
1630
+ // March" is a question people ask.
1631
+ disabled={!customRange.from && !customRange.to}
1632
+ onClick={() => {
1633
+ if (!field) return
1634
+ const others = filters.filter(
1635
+ (f) => !(f.field === field && (f.operator === 'gteq' || f.operator === 'lteq')),
1636
+ )
1637
+ const next = [...others]
1638
+ if (customRange.from) {
1639
+ next.push({
1640
+ id: crypto.randomUUID(),
1641
+ field,
1642
+ operator: 'gteq',
1643
+ value: customRange.from,
1644
+ })
1645
+ }
1646
+ if (customRange.to) {
1647
+ next.push({
1648
+ id: crypto.randomUUID(),
1649
+ field,
1650
+ operator: 'lteq',
1651
+ value: customRange.to,
1652
+ })
1653
+ }
1654
+ onApply(next)
1655
+ }}
696
1656
  >
697
- <SelectTrigger size="sm" className="w-[120px] shrink-0">
698
- <SelectValue />
1657
+ {t('admin.actions.apply')}
1658
+ </Button>
1659
+ </div>
1660
+ </div>
1661
+ )}
1662
+
1663
+ {/* Stage two, everything else: an operator and a value. The operator
1664
+ lives here rather than in the header because it only exists for
1665
+ these types — a text column's "contains" versus "starts with" is a
1666
+ real choice, where a status has nothing to choose. */}
1667
+ {field && !listed && !dated && (
1668
+ <div data-slot="filter-panel-controls" className="flex flex-col gap-2 p-2">
1669
+ <Select items={operators} value={operator} onValueChange={setOperator}>
1670
+ {/* Default size, not `sm`: this sits directly above the value
1671
+ input, and `sm` pins a fixed 8 height while the input only
1672
+ has that as a floor and grows with the panel's 14px text —
1673
+ so the pair rendered at two different heights. */}
1674
+ <SelectTrigger>
1675
+ <SelectValue />
1676
+ </SelectTrigger>
1677
+ <SelectContent>
1678
+ {operators.map((op) => (
1679
+ <SelectItem key={op.value} value={op.value}>
1680
+ {op.label}
1681
+ </SelectItem>
1682
+ ))}
1683
+ </SelectContent>
1684
+ </Select>
1685
+
1686
+ {/* A quick-filtered enum still knows its values, so it picks from
1687
+ them rather than asking for a slug typed from memory: this
1688
+ branch is reached for `is not`, which the control cannot say,
1689
+ not because the values became unknown. */}
1690
+ {!noValueOperators.includes(operator) && listOptions.length > 0 ? (
1691
+ <Select items={listOptions} value={value} onValueChange={setValue}>
1692
+ <SelectTrigger>
1693
+ <SelectValue
1694
+ placeholder={t('admin.components.table_toolbar.filter_select_placeholder')}
1695
+ />
699
1696
  </SelectTrigger>
700
1697
  <SelectContent>
701
- {ops.map((op) => (
702
- <SelectItem key={op.value} value={op.value}>
703
- {op.label}
1698
+ {listOptions.map((option) => (
1699
+ <SelectItem key={option.value} value={option.value}>
1700
+ {option.label}
704
1701
  </SelectItem>
705
1702
  ))}
706
1703
  </SelectContent>
707
1704
  </Select>
1705
+ ) : null}
708
1706
 
709
- {!noValueOperators.includes(filter.operator) &&
710
- (col?.filterType === 'resource' && col.filterResource ? (
711
- <div className="flex-1 min-w-0">
712
- <ResourceMultiAutocomplete
713
- // Store-scope the cache key so a store switch can't reuse
714
- // the previous store's search/hydrate results.
715
- queryKey={`${col.filterResource.queryKey}-${storeId}`}
716
- value={parseFilterIds(filter.value)}
717
- onChange={(ids) => updateFilter(filter.id, { value: ids.join(',') })}
718
- search={col.filterResource.search}
719
- hydrate={col.filterResource.hydrate}
720
- getOptionLabel={col.filterResource.getOptionLabel}
721
- placeholder={col.filterResource.placeholder}
722
- emptyText={col.filterResource.emptyText}
723
- />
724
- </div>
725
- ) : col?.filterType === 'tags' && col.taggableType ? (
726
- <div className="flex-1 min-w-0">
727
- <TagCombobox
728
- taggableType={col.taggableType}
729
- value={parseFilterIds(filter.value)}
730
- onChange={(names) => updateFilter(filter.id, { value: names.join(',') })}
731
- />
732
- </div>
733
- ) : col?.filterType === 'enum' && col.filterOptions ? (
734
- <Select
735
- items={col.filterOptions}
736
- value={filter.value || undefined}
737
- onValueChange={(val) => updateFilter(filter.id, { value: val })}
738
- >
739
- <SelectTrigger size="sm" className="flex-1">
740
- <SelectValue
741
- placeholder={t('admin.components.table_toolbar.filter_select_placeholder')}
742
- />
743
- </SelectTrigger>
744
- <SelectContent>
745
- {col.filterOptions.map((opt) => (
746
- <SelectItem key={opt.value} value={opt.value}>
747
- {opt.label}
748
- </SelectItem>
749
- ))}
750
- </SelectContent>
751
- </Select>
752
- ) : col?.filterType === 'currency' ? (
753
- <CurrencyFilterPicker
754
- value={filter.value}
755
- operator={filter.operator}
756
- onChange={(val) => updateFilter(filter.id, { value: val })}
757
- />
758
- ) : col?.filterType === 'boolean' ? (
759
- <Select
760
- items={booleanItems}
761
- value={filter.value || undefined}
762
- onValueChange={(val) => updateFilter(filter.id, { value: val })}
763
- >
764
- <SelectTrigger size="sm" className="flex-1">
765
- <SelectValue
766
- placeholder={t('admin.components.table_toolbar.filter_select_placeholder')}
767
- />
768
- </SelectTrigger>
769
- <SelectContent>
770
- <SelectItem value="true">{t('admin.common.yes')}</SelectItem>
771
- <SelectItem value="false">{t('admin.common.no')}</SelectItem>
772
- </SelectContent>
773
- </Select>
774
- ) : col?.filterType === 'date' ? (
775
- // Filter values are stored as plain strings; for dates we
776
- // persist `yyyy-MM-dd`, which Ransack accepts as-is for
777
- // `*_eq`/`*_gt`/`*_lt`. The picker emits `yyyy-MM-dd`
778
- // directly in date-only mode.
779
- <div className="flex-1">
780
- <StoreDatePicker
781
- value={filter.value || null}
782
- onChange={(next) => updateFilter(filter.id, { value: next ?? '' })}
783
- placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
784
- />
785
- </div>
786
- ) : (
787
- <Input
788
- type={col?.filterType === 'number' ? 'number' : 'text'}
789
- className="flex-1 py-1 px-2 text-sm h-7"
790
- placeholder={t('admin.components.table_toolbar.filter_text_placeholder')}
791
- value={filter.value}
792
- onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
793
- />
794
- ))}
1707
+ {/* Everything picked from a list — currency, tags, resources — or
1708
+ from a calendar is handled above; this branch is only the types
1709
+ you type into. */}
1710
+ {!noValueOperators.includes(operator) && listOptions.length === 0 && (
1711
+ <Input
1712
+ type={type === 'number' ? 'number' : 'text'}
1713
+ placeholder={t('admin.components.table_toolbar.filter_text_placeholder')}
1714
+ value={value}
1715
+ onChange={(event) => setValue(event.target.value)}
1716
+ // Enter applies, so a text filter is type-and-go rather than
1717
+ // type-then-reach-for-the-button.
1718
+ onKeyDown={(event) => {
1719
+ if (event.key === 'Enter') {
1720
+ event.preventDefault()
1721
+ commit(value)
1722
+ }
1723
+ }}
1724
+ />
1725
+ )}
795
1726
 
796
- <button
797
- type="button"
798
- className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 shrink-0"
799
- onClick={() => removeFilter(filter.id)}
800
- >
801
- <Trash2Icon className="size-3.5" />
802
- </button>
1727
+ {/* Right-aligned rather than stretched: a full-width black bar
1728
+ reads as the panel's primary purpose, when it is one step in a
1729
+ filter the operator is still composing. */}
1730
+ <div className="flex justify-end">
1731
+ <Button size="sm" onClick={() => commit(value)}>
1732
+ {t('admin.actions.apply')}
1733
+ </Button>
803
1734
  </div>
804
- )
805
- })}
1735
+ </div>
1736
+ )}
806
1737
 
807
- {draft.length === 0 && (
808
- <p className="text-xs text-muted-foreground text-center py-4">
809
- {t('admin.components.table_toolbar.no_filters')}
1738
+ {/* Nothing matched the search — say so rather than showing a blank
1739
+ panel that reads as broken. */}
1740
+ {((!field && visibleFields.length === 0 && visibleQuickFields.length === 0) ||
1741
+ (field && listed && !multi && listOptions.length === 0)) && (
1742
+ <p className="px-2 py-6 text-center text-muted-foreground">
1743
+ {t('admin.common.no_results')}
810
1744
  </p>
811
1745
  )}
812
1746
  </div>
813
-
814
- <div className="flex items-center justify-between border-t px-3 py-2">
815
- <Button variant="ghost" size="sm" onClick={addFilter}>
816
- <PlusIcon className="size-3.5" />
817
- {t('admin.components.table_toolbar.add_filter')}
818
- </Button>
819
- <div className="flex gap-1.5">
820
- {draft.length > 0 && (
821
- <Button variant="ghost" size="sm" onClick={() => setDraft([])}>
822
- {t('admin.actions.clear')}
823
- </Button>
824
- )}
825
- <Button size="sm" onClick={handleApply}>
826
- {t('admin.actions.apply')}
827
- </Button>
828
- </div>
829
- </div>
830
1747
  </div>
831
1748
  )
832
1749
  }