@spree/dashboard-core 0.13.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
Badge,
|
|
3
2
|
Button,
|
|
4
|
-
|
|
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
|
-
|
|
31
|
-
PlusIcon,
|
|
32
|
-
SearchIcon,
|
|
33
|
-
Trash2Icon,
|
|
35
|
+
ListFilter,
|
|
34
36
|
XIcon,
|
|
35
|
-
} from '
|
|
36
|
-
import {
|
|
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 {
|
|
45
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
</
|
|
390
|
+
{t('admin.components.table_toolbar.clear_all')}
|
|
391
|
+
</Button>
|
|
208
392
|
)}
|
|
209
393
|
</div>
|
|
210
|
-
</div>
|
|
211
394
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
426
|
+
rules,
|
|
293
427
|
col,
|
|
294
428
|
onRemove,
|
|
295
429
|
}: {
|
|
296
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
|
532
|
+
const tenantId = useTenantId()
|
|
345
533
|
const ids = useMemo(() => parseFilterIds(value), [value])
|
|
346
534
|
|
|
347
535
|
const { data } = useQuery({
|
|
348
|
-
//
|
|
349
|
-
// hydrate results after a store switch.
|
|
350
|
-
queryKey: ['filter-chip', config.queryKey,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
1236
|
+
allColumns,
|
|
1237
|
+
quickFilterKeys,
|
|
1238
|
+
filters,
|
|
588
1239
|
onApply,
|
|
589
|
-
|
|
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
|
-
|
|
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
|
|
598
|
-
const
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
// the
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
}
|
|
1375
|
+
}
|
|
629
1376
|
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
{
|
|
702
|
-
<SelectItem key={
|
|
703
|
-
{
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
>
|
|
801
|
-
|
|
802
|
-
</
|
|
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
|
-
{
|
|
808
|
-
|
|
809
|
-
|
|
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
|
}
|