@spree/dashboard-core 6.0.2 → 6.0.4
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/dist/vite/index.js +81 -5
- package/package.json +24 -24
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +58 -0
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +194 -331
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +20 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +128 -14
- package/src/locales/de.json +128 -14
- package/src/locales/en.json +128 -14
- package/src/locales/es.json +128 -14
- package/src/locales/fr.json +128 -14
- package/src/locales/ja.json +128 -14
- package/src/locales/pl.json +130 -14
- package/src/locales/vi.json +128 -14
- package/src/locales/zh-CN.json +128 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +204 -73
- package/src/products/product-form-mapping.test.ts +42 -1
- package/src/products/product-form-mapping.ts +20 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type FilterPredicate, randomUUID } from '@spree/core'
|
|
2
2
|
import {
|
|
3
3
|
Button,
|
|
4
4
|
Checkbox,
|
|
5
5
|
cn,
|
|
6
|
+
DateRangePanel,
|
|
6
7
|
DropdownMenu,
|
|
7
8
|
DropdownMenuCheckboxItem,
|
|
8
9
|
DropdownMenuContent,
|
|
@@ -29,24 +30,21 @@ import {
|
|
|
29
30
|
import {
|
|
30
31
|
ArrowUpDownIcon,
|
|
31
32
|
CheckIcon,
|
|
32
|
-
ChevronDownIcon,
|
|
33
33
|
ChevronLeftIcon,
|
|
34
34
|
ChevronRightIcon,
|
|
35
|
+
ChevronsUpDownIcon,
|
|
35
36
|
Columns3Icon,
|
|
36
37
|
ListFilter,
|
|
37
38
|
XIcon,
|
|
38
39
|
} from '@spree/dashboard-ui/icons'
|
|
39
40
|
import { useQuery } from '@tanstack/react-query'
|
|
41
|
+
import { format, parse } from 'date-fns'
|
|
40
42
|
import { Fragment, type ReactNode, useCallback, useDeferredValue, useMemo, useState } from 'react'
|
|
41
43
|
import { useTranslation } from 'react-i18next'
|
|
42
44
|
import { getApiClient } from '../api-client'
|
|
43
45
|
import { useAuth } from '../hooks/use-auth'
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
type DatePresetKey,
|
|
47
|
-
matchDatePreset,
|
|
48
|
-
resolveDatePreset,
|
|
49
|
-
} from '../lib/date-presets'
|
|
46
|
+
import { type TableDensity, useTableDensity } from '../hooks/use-table-density'
|
|
47
|
+
import { DATE_PRESET_KEYS, matchDatePreset, resolveDatePreset } from '../lib/date-presets'
|
|
50
48
|
import {
|
|
51
49
|
type ColumnDef,
|
|
52
50
|
type FilterRule,
|
|
@@ -57,7 +55,6 @@ import {
|
|
|
57
55
|
import { useOptionalStore } from '../providers/store-provider'
|
|
58
56
|
import { useTenantId } from '../providers/tenant-provider'
|
|
59
57
|
import { LocaleLabel, useLocaleLabel } from './locale-select'
|
|
60
|
-
import { StoreDatePicker } from './store-date-picker'
|
|
61
58
|
|
|
62
59
|
interface TableToolbarProps {
|
|
63
60
|
/** Displayable columns (for column selector and table headers) */
|
|
@@ -66,6 +63,7 @@ interface TableToolbarProps {
|
|
|
66
63
|
onVisibleColumnsChange: (columns: string[]) => void
|
|
67
64
|
search: string
|
|
68
65
|
onSearchChange: (value: string) => void
|
|
66
|
+
searchable?: boolean
|
|
69
67
|
searchPlaceholder?: string
|
|
70
68
|
sort: SortOption
|
|
71
69
|
onSortChange: (sort: SortOption) => void
|
|
@@ -174,6 +172,7 @@ export function TableToolbar({
|
|
|
174
172
|
onVisibleColumnsChange,
|
|
175
173
|
search,
|
|
176
174
|
onSearchChange,
|
|
175
|
+
searchable = true,
|
|
177
176
|
searchPlaceholder,
|
|
178
177
|
sort,
|
|
179
178
|
onSortChange,
|
|
@@ -287,15 +286,19 @@ export function TableToolbar({
|
|
|
287
286
|
already carries a search field — two search boxes stacked in one
|
|
288
287
|
column is the more confusing cost. A term set on desktop still
|
|
289
288
|
shows as a chip there, so it never filters a list invisibly. */}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
289
|
+
{searchable && (
|
|
290
|
+
<SearchInput
|
|
291
|
+
value={search}
|
|
292
|
+
onValueChange={onSearchChange}
|
|
293
|
+
placeholder={
|
|
294
|
+
searchPlaceholder ?? t('admin.components.table_toolbar.search_placeholder')
|
|
295
|
+
}
|
|
296
|
+
clearLabel={t('admin.common.clear')}
|
|
297
|
+
// Widens while in use (focused, or holding a term) so a longer
|
|
298
|
+
// query stays readable, and settles back once it is let go.
|
|
299
|
+
className="hidden h-[2.125rem] w-[240px] bg-card text-base transition-[color,background-color,border-color,box-shadow,width] duration-200 ease-out focus-within:w-[320px] has-[input:not(:placeholder-shown)]:w-[320px] motion-reduce:transition-none lg:flex"
|
|
300
|
+
/>
|
|
301
|
+
)}
|
|
299
302
|
|
|
300
303
|
{/* Icon only, like sort and columns: the quick controls beside it
|
|
301
304
|
already carry the row's words, and the tooltip names it. */}
|
|
@@ -327,10 +330,10 @@ export function TableToolbar({
|
|
|
327
330
|
</TooltipTrigger>
|
|
328
331
|
<TooltipContent>{t('admin.components.table_toolbar.add_filter')}</TooltipContent>
|
|
329
332
|
</Tooltip>
|
|
330
|
-
{/* `min(480px, …)`
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
<PopoverContent align="start" className="w-[
|
|
333
|
+
{/* The panel sets its own width: `min(480px, …)` for its lists,
|
|
334
|
+
so it never runs off a phone, and wider for a date field's
|
|
335
|
+
calendar. */}
|
|
336
|
+
<PopoverContent align="start" className="w-auto max-w-[calc(100vw-1rem)] p-0">
|
|
334
337
|
<FilterPanel
|
|
335
338
|
key={panelRule?.id ?? ''}
|
|
336
339
|
initialRule={panelRule}
|
|
@@ -375,7 +378,7 @@ export function TableToolbar({
|
|
|
375
378
|
{/* The search term as a chip, mobile only — the field it came from is
|
|
376
379
|
hidden there, so this is the only way to see or clear it. */}
|
|
377
380
|
{search && (
|
|
378
|
-
<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-
|
|
381
|
+
<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-base shadow-xs lg:hidden">
|
|
379
382
|
<span className="text-muted-foreground">
|
|
380
383
|
{t('admin.components.table_toolbar.search_placeholder')}
|
|
381
384
|
</span>
|
|
@@ -434,7 +437,7 @@ export function TableToolbar({
|
|
|
434
437
|
{/* Hidden when the table is drag-reorderable, since free sorting
|
|
435
438
|
would defeat the drag. */}
|
|
436
439
|
{!hideSort && sortableColumns.length > 0 && (
|
|
437
|
-
<
|
|
440
|
+
<SortMenu columns={sortableColumns} sort={sort} onSortChange={onSortChange} />
|
|
438
441
|
)}
|
|
439
442
|
|
|
440
443
|
{/* Hidden on a phone, where too few columns are visible at once for
|
|
@@ -552,9 +555,8 @@ function FilterChip({
|
|
|
552
555
|
// So it wears the outline button's own surface — `bg-card`, `shadow-xs`,
|
|
553
556
|
// `rounded-md` — rather than a `bg-muted` of its own. A filled chip beside
|
|
554
557
|
// outlined controls reads as a different kind of object, when the only
|
|
555
|
-
// real difference is that this one is set
|
|
556
|
-
|
|
557
|
-
<span className="inline-flex h-11 shrink-0 items-center gap-0.5 rounded-md border border-border bg-card pr-1 text-sm shadow-xs lg:h-[2.125rem]">
|
|
558
|
+
// real difference is that this one is set, which its value already says.
|
|
559
|
+
<span className="inline-flex h-11 shrink-0 items-center gap-0.5 rounded-md border border-border bg-card pr-1 text-base shadow-xs lg:h-[2.125rem]">
|
|
558
560
|
{onEdit ? (
|
|
559
561
|
<button
|
|
560
562
|
type="button"
|
|
@@ -644,12 +646,17 @@ function ResourceFilterValue({
|
|
|
644
646
|
// Sort Dropdown
|
|
645
647
|
// ============================================================================
|
|
646
648
|
|
|
647
|
-
|
|
649
|
+
/**
|
|
650
|
+
* The sort control: an icon button opening the fields to sort by and the
|
|
651
|
+
* direction. Exported so other lists — the media picker — sort the same way
|
|
652
|
+
* the resource tables do.
|
|
653
|
+
*/
|
|
654
|
+
export function SortMenu({
|
|
648
655
|
columns,
|
|
649
656
|
sort,
|
|
650
657
|
onSortChange,
|
|
651
658
|
}: {
|
|
652
|
-
columns: ColumnDef[]
|
|
659
|
+
columns: Pick<ColumnDef, 'key' | 'label'>[]
|
|
653
660
|
sort: SortOption
|
|
654
661
|
onSortChange: (sort: SortOption) => void
|
|
655
662
|
}) {
|
|
@@ -717,19 +724,12 @@ function SortDropdown({
|
|
|
717
724
|
// ============================================================================
|
|
718
725
|
|
|
719
726
|
/**
|
|
720
|
-
* Trigger styling shared by the three quick filters
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
* borders read as "this is set", and a row of four identical outlines made the
|
|
724
|
-
* operator check each one's value to find which were actually filtering the
|
|
725
|
-
* list. Dashed is the quieter state, so the ones doing work stand out without
|
|
726
|
-
* having to shout — the chips beside them stay solid for the same reason.
|
|
727
|
-
*
|
|
728
|
-
* `Add filter` keeps its solid border: it is an action, not a filter that
|
|
729
|
-
* happens to be empty.
|
|
727
|
+
* Trigger styling shared by the three quick filters: the standard outline
|
|
728
|
+
* button, sized to the toolbar. Whether a filter is narrowing the list reads
|
|
729
|
+
* from its value — "All" against the chosen name or a count — not its border.
|
|
730
730
|
*/
|
|
731
|
-
function quickFilterTriggerClass(
|
|
732
|
-
return
|
|
731
|
+
function quickFilterTriggerClass() {
|
|
732
|
+
return 'h-11 gap-1.5 lg:h-[2.125rem]'
|
|
733
733
|
}
|
|
734
734
|
|
|
735
735
|
/** "6/7" on a quick filter that narrows the list. */
|
|
@@ -865,7 +865,7 @@ function QuickEnumFilter({
|
|
|
865
865
|
onFiltersChange([
|
|
866
866
|
...others,
|
|
867
867
|
{
|
|
868
|
-
id: existing?.id ??
|
|
868
|
+
id: existing?.id ?? randomUUID(),
|
|
869
869
|
field: column.key,
|
|
870
870
|
operator: 'in',
|
|
871
871
|
value: values.join(','),
|
|
@@ -878,24 +878,18 @@ function QuickEnumFilter({
|
|
|
878
878
|
return (
|
|
879
879
|
<DropdownMenu>
|
|
880
880
|
<DropdownMenuTrigger asChild>
|
|
881
|
-
<Button variant="outline" size="sm" className={quickFilterTriggerClass(
|
|
882
|
-
{/* The colours of what is let through, so a narrowed status reads
|
|
883
|
-
at a glance without opening the menu. */}
|
|
884
|
-
<span aria-hidden className="flex -space-x-1">
|
|
885
|
-
{options
|
|
886
|
-
.filter((o) => selected.includes(o.value))
|
|
887
|
-
.slice(0, 4)
|
|
888
|
-
.map((o) => (
|
|
889
|
-
<StatusDot key={o.value} status={o.value} className="size-2.5 ring-2 ring-card" />
|
|
890
|
-
))}
|
|
891
|
-
</span>
|
|
881
|
+
<Button variant="outline" size="sm" className={quickFilterTriggerClass()}>
|
|
892
882
|
<span className="text-muted-foreground">{column.label}</span>
|
|
893
883
|
{allSelected ? (
|
|
894
884
|
<span>{t('admin.common.all')}</span>
|
|
885
|
+
) : selected.length === 1 ? (
|
|
886
|
+
<span className="max-w-40 truncate">
|
|
887
|
+
{options.find((o) => o.value === selected[0])?.label}
|
|
888
|
+
</span>
|
|
895
889
|
) : (
|
|
896
890
|
<SelectedCount selected={selected.length} total={options.length} />
|
|
897
891
|
)}
|
|
898
|
-
<
|
|
892
|
+
<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
|
|
899
893
|
</Button>
|
|
900
894
|
</DropdownMenuTrigger>
|
|
901
895
|
<DropdownMenuContent align="start" className="min-w-56">
|
|
@@ -956,7 +950,7 @@ function QuickResourceFilter({
|
|
|
956
950
|
onFiltersChange([
|
|
957
951
|
...others,
|
|
958
952
|
{
|
|
959
|
-
id: existing?.id ??
|
|
953
|
+
id: existing?.id ?? randomUUID(),
|
|
960
954
|
field: column.key,
|
|
961
955
|
operator: 'in',
|
|
962
956
|
value: values.join(','),
|
|
@@ -982,14 +976,10 @@ function QuickResourceFilter({
|
|
|
982
976
|
return (
|
|
983
977
|
<DropdownMenu>
|
|
984
978
|
<DropdownMenuTrigger asChild>
|
|
985
|
-
<Button
|
|
986
|
-
variant="outline"
|
|
987
|
-
size="sm"
|
|
988
|
-
className={quickFilterTriggerClass(selected.length === 0)}
|
|
989
|
-
>
|
|
979
|
+
<Button variant="outline" size="sm" className={quickFilterTriggerClass()}>
|
|
990
980
|
<span className="text-muted-foreground">{column.label}</span>
|
|
991
981
|
{summary}
|
|
992
|
-
<
|
|
982
|
+
<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
|
|
993
983
|
</Button>
|
|
994
984
|
</DropdownMenuTrigger>
|
|
995
985
|
<DropdownMenuContent align="start" className="min-w-56">
|
|
@@ -1016,151 +1006,122 @@ function QuickDateFilter({
|
|
|
1016
1006
|
filters: FilterRule[]
|
|
1017
1007
|
onFiltersChange: (filters: FilterRule[]) => void
|
|
1018
1008
|
}) {
|
|
1019
|
-
const { t } = useTranslation()
|
|
1020
|
-
const
|
|
1021
|
-
// A panel with no store context (a seller's) falls back to the viewer's zone
|
|
1022
|
-
// rather than refusing to offer presets.
|
|
1023
|
-
const timezone = store?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
|
|
1024
|
-
|
|
1009
|
+
const { t, i18n } = useTranslation()
|
|
1010
|
+
const timezone = useFilterTimezone()
|
|
1025
1011
|
const from = filters.find((f) => f.field === column.key && f.operator === 'gteq')
|
|
1026
1012
|
const to = filters.find((f) => f.field === column.key && f.operator === 'lteq')
|
|
1027
1013
|
const active = matchDatePreset({ from: from?.value ?? null, to: to?.value ?? null }, timezone)
|
|
1028
|
-
// Seeded from what is applied, so reopening on a hand-picked range shows the
|
|
1029
|
-
// dates it is filtering by rather than an empty form.
|
|
1030
|
-
const [custom, setCustom] = useState<{ from: string; to: string } | null>(null)
|
|
1031
1014
|
const [open, setOpen] = useState(false)
|
|
1032
1015
|
|
|
1033
|
-
/** Replace this column's bounds. Either end may be null for an open range. */
|
|
1034
|
-
const applyRange = useCallback(
|
|
1035
|
-
(rangeFrom: string | null, rangeTo: string | null) => {
|
|
1036
|
-
const others = filters.filter(
|
|
1037
|
-
(f) => !(f.field === column.key && (f.operator === 'gteq' || f.operator === 'lteq')),
|
|
1038
|
-
)
|
|
1039
|
-
const next: FilterRule[] = [...others]
|
|
1040
|
-
if (rangeFrom) {
|
|
1041
|
-
next.push({
|
|
1042
|
-
id: crypto.randomUUID(),
|
|
1043
|
-
field: column.key,
|
|
1044
|
-
operator: 'gteq',
|
|
1045
|
-
value: rangeFrom,
|
|
1046
|
-
})
|
|
1047
|
-
}
|
|
1048
|
-
if (rangeTo) {
|
|
1049
|
-
next.push({ id: crypto.randomUUID(), field: column.key, operator: 'lteq', value: rangeTo })
|
|
1050
|
-
}
|
|
1051
|
-
onFiltersChange(next)
|
|
1052
|
-
},
|
|
1053
|
-
[filters, column.key, onFiltersChange],
|
|
1054
|
-
)
|
|
1055
|
-
|
|
1056
|
-
const apply = useCallback(
|
|
1057
|
-
(preset: DatePresetKey) => {
|
|
1058
|
-
const range = resolveDatePreset(preset, timezone)
|
|
1059
|
-
applyRange(range.from, range.to)
|
|
1060
|
-
},
|
|
1061
|
-
[timezone, applyRange],
|
|
1062
|
-
)
|
|
1063
|
-
|
|
1064
|
-
// A Popover rather than a DropdownMenu: the custom branch holds date pickers
|
|
1065
|
-
// and a submit, which a menu's roving focus fights.
|
|
1066
1016
|
return (
|
|
1067
|
-
<Popover
|
|
1068
|
-
open={open}
|
|
1069
|
-
onOpenChange={(next) => {
|
|
1070
|
-
setOpen(next)
|
|
1071
|
-
// Reopening starts on the preset list unless a custom range is what is
|
|
1072
|
-
// applied — then it opens on the range, so the dates stay editable.
|
|
1073
|
-
if (next) {
|
|
1074
|
-
setCustom(active === 'custom' ? { from: from?.value ?? '', to: to?.value ?? '' } : null)
|
|
1075
|
-
}
|
|
1076
|
-
}}
|
|
1077
|
-
>
|
|
1017
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
1078
1018
|
<PopoverTrigger asChild>
|
|
1079
|
-
<Button variant="outline" size="sm" className={quickFilterTriggerClass(
|
|
1019
|
+
<Button variant="outline" size="sm" className={quickFilterTriggerClass()}>
|
|
1080
1020
|
<span className="text-muted-foreground">{column.label}</span>
|
|
1081
|
-
<span>
|
|
1082
|
-
|
|
1021
|
+
<span>
|
|
1022
|
+
{active === 'custom'
|
|
1023
|
+
? formatDateBounds(from?.value ?? null, to?.value ?? null, i18n.language)
|
|
1024
|
+
: t(`admin.components.table_toolbar.date_presets.${active}`)}
|
|
1025
|
+
</span>
|
|
1026
|
+
<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
|
|
1083
1027
|
</Button>
|
|
1084
1028
|
</PopoverTrigger>
|
|
1085
|
-
<PopoverContent align="start" className="w-[
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
onChange={(next) => setCustom({ ...custom, from: next ?? '' })}
|
|
1095
|
-
placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
|
|
1096
|
-
/>
|
|
1097
|
-
</div>
|
|
1098
|
-
<div className="flex flex-col gap-1">
|
|
1099
|
-
<span className="text-xs text-muted-foreground">
|
|
1100
|
-
{t('admin.components.table_toolbar.date_range_end')}
|
|
1101
|
-
</span>
|
|
1102
|
-
<StoreDatePicker
|
|
1103
|
-
value={custom.to || null}
|
|
1104
|
-
onChange={(next) => setCustom({ ...custom, to: next ?? '' })}
|
|
1105
|
-
placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
|
|
1106
|
-
/>
|
|
1107
|
-
</div>
|
|
1108
|
-
<div className="flex justify-end gap-1.5">
|
|
1109
|
-
<Button variant="ghost" size="sm" onClick={() => setCustom(null)}>
|
|
1110
|
-
{t('admin.actions.back')}
|
|
1111
|
-
</Button>
|
|
1112
|
-
<Button
|
|
1113
|
-
size="sm"
|
|
1114
|
-
// Either bound alone is a valid filter — "everything since
|
|
1115
|
-
// March" is a question people ask.
|
|
1116
|
-
disabled={!custom.from && !custom.to}
|
|
1117
|
-
onClick={() => {
|
|
1118
|
-
applyRange(custom.from || null, custom.to || null)
|
|
1119
|
-
setOpen(false)
|
|
1120
|
-
}}
|
|
1121
|
-
>
|
|
1122
|
-
{t('admin.actions.apply')}
|
|
1123
|
-
</Button>
|
|
1124
|
-
</div>
|
|
1125
|
-
</div>
|
|
1126
|
-
) : (
|
|
1127
|
-
<div className="flex flex-col">
|
|
1128
|
-
{DATE_PRESET_KEYS.map((preset) => (
|
|
1129
|
-
<button
|
|
1130
|
-
key={preset}
|
|
1131
|
-
type="button"
|
|
1132
|
-
data-slot="filter-panel-item"
|
|
1133
|
-
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"
|
|
1134
|
-
onClick={() => {
|
|
1135
|
-
apply(preset)
|
|
1136
|
-
setOpen(false)
|
|
1137
|
-
}}
|
|
1138
|
-
>
|
|
1139
|
-
{t(`admin.components.table_toolbar.date_presets.${preset}`)}
|
|
1140
|
-
{active === preset && <CheckIcon className="size-3.5 shrink-0" />}
|
|
1141
|
-
</button>
|
|
1142
|
-
))}
|
|
1143
|
-
{/* Without this the quick control is the only route to a date
|
|
1144
|
-
filter on tables whose every filterable column is a quick one —
|
|
1145
|
-
and it offers presets only, so a hand-picked range would be
|
|
1146
|
-
unreachable, and one restored from a URL would show as neither
|
|
1147
|
-
selected nor removable. */}
|
|
1148
|
-
<button
|
|
1149
|
-
type="button"
|
|
1150
|
-
data-slot="filter-panel-item"
|
|
1151
|
-
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"
|
|
1152
|
-
onClick={() => setCustom({ from: from?.value ?? '', to: to?.value ?? '' })}
|
|
1153
|
-
>
|
|
1154
|
-
{t('admin.components.table_toolbar.date_presets.custom')}
|
|
1155
|
-
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
1156
|
-
</button>
|
|
1157
|
-
</div>
|
|
1158
|
-
)}
|
|
1029
|
+
<PopoverContent align="start" className="w-auto max-w-[calc(100vw-1rem)] p-0">
|
|
1030
|
+
<ColumnDateRange
|
|
1031
|
+
field={column.key}
|
|
1032
|
+
filters={filters}
|
|
1033
|
+
onApply={(next) => {
|
|
1034
|
+
onFiltersChange(next)
|
|
1035
|
+
setOpen(false)
|
|
1036
|
+
}}
|
|
1037
|
+
/>
|
|
1159
1038
|
</PopoverContent>
|
|
1160
1039
|
</Popover>
|
|
1161
1040
|
)
|
|
1162
1041
|
}
|
|
1163
1042
|
|
|
1043
|
+
/** A panel with no store context (a seller's) falls back to the viewer's zone
|
|
1044
|
+
* rather than refusing to offer presets. */
|
|
1045
|
+
function useFilterTimezone(): string {
|
|
1046
|
+
const store = useOptionalStore()
|
|
1047
|
+
return store?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Presets and a range calendar for one date column, used by its quick filter
|
|
1052
|
+
* and by the filter panel alike, so a range is picked the same way in both.
|
|
1053
|
+
* `onApply` gets the whole filter list with this column's bounds replaced.
|
|
1054
|
+
*/
|
|
1055
|
+
function ColumnDateRange({
|
|
1056
|
+
field,
|
|
1057
|
+
filters,
|
|
1058
|
+
onApply,
|
|
1059
|
+
}: {
|
|
1060
|
+
field: string
|
|
1061
|
+
filters: FilterRule[]
|
|
1062
|
+
onApply: (filters: FilterRule[]) => void
|
|
1063
|
+
}) {
|
|
1064
|
+
const { t } = useTranslation()
|
|
1065
|
+
const timezone = useFilterTimezone()
|
|
1066
|
+
const from = filters.find((f) => f.field === field && f.operator === 'gteq')
|
|
1067
|
+
const to = filters.find((f) => f.field === field && f.operator === 'lteq')
|
|
1068
|
+
const active = matchDatePreset({ from: from?.value ?? null, to: to?.value ?? null }, timezone)
|
|
1069
|
+
|
|
1070
|
+
/** Replace this column's bounds. Either end may be null for an open range. */
|
|
1071
|
+
function applyRange(rangeFrom: string | null, rangeTo: string | null) {
|
|
1072
|
+
const next = filters.filter(
|
|
1073
|
+
(f) => !(f.field === field && (f.operator === 'gteq' || f.operator === 'lteq')),
|
|
1074
|
+
)
|
|
1075
|
+
if (rangeFrom) next.push({ id: randomUUID(), field, operator: 'gteq', value: rangeFrom })
|
|
1076
|
+
if (rangeTo) next.push({ id: randomUUID(), field, operator: 'lteq', value: rangeTo })
|
|
1077
|
+
onApply(next)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return (
|
|
1081
|
+
<DateRangePanel
|
|
1082
|
+
// Reseeds the calendar whenever the applied range changes, even if
|
|
1083
|
+
// the popover reopens before its closing animation unmounts it.
|
|
1084
|
+
key={`${from?.value}:${to?.value}`}
|
|
1085
|
+
presets={DATE_PRESET_KEYS.map((key) => ({
|
|
1086
|
+
key,
|
|
1087
|
+
label: t(`admin.components.table_toolbar.date_presets.${key}`),
|
|
1088
|
+
}))}
|
|
1089
|
+
activePreset={active}
|
|
1090
|
+
onPresetSelect={(preset) => {
|
|
1091
|
+
const range = resolveDatePreset(preset, timezone)
|
|
1092
|
+
applyRange(range.from, range.to)
|
|
1093
|
+
}}
|
|
1094
|
+
// A range restored from a URL may be open on one side; the calendar
|
|
1095
|
+
// highlights the end it has.
|
|
1096
|
+
initialRange={
|
|
1097
|
+
from?.value || to?.value
|
|
1098
|
+
? { from: parseDay(from?.value ?? to?.value), to: parseDay(to?.value) }
|
|
1099
|
+
: undefined
|
|
1100
|
+
}
|
|
1101
|
+
onApply={(range) => applyRange(formatDay(range.from), formatDay(range.to))}
|
|
1102
|
+
/>
|
|
1103
|
+
)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Filter values are store-calendar dates (`yyyy-MM-dd`), read as local days. */
|
|
1107
|
+
function parseDay(value: string | undefined): Date | undefined {
|
|
1108
|
+
return value ? parse(value, 'yyyy-MM-dd', new Date()) : undefined
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function formatDay(date: Date): string {
|
|
1112
|
+
return format(date, 'yyyy-MM-dd')
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** "Nov 1 – Nov 5", or one side alone for an open range. */
|
|
1116
|
+
function formatDateBounds(from: string | null, to: string | null, language: string): string {
|
|
1117
|
+
const label = (value: string | null) =>
|
|
1118
|
+
value
|
|
1119
|
+
? (parseDay(value)?.toLocaleDateString(language, { day: 'numeric', month: 'short' }) ?? '')
|
|
1120
|
+
: ''
|
|
1121
|
+
if (from && to && from === to) return label(from)
|
|
1122
|
+
return `${label(from)} – ${label(to)}`.trim()
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1164
1125
|
// ============================================================================
|
|
1165
1126
|
// Column Selector
|
|
1166
1127
|
// ============================================================================
|
|
@@ -1175,6 +1136,7 @@ function ColumnSelector({
|
|
|
1175
1136
|
onVisibleColumnsChange: (columns: string[]) => void
|
|
1176
1137
|
}) {
|
|
1177
1138
|
const { t } = useTranslation()
|
|
1139
|
+
const [density, setDensity] = useTableDensity()
|
|
1178
1140
|
const defaults = columns.filter((c) => c.default).map((c) => c.key)
|
|
1179
1141
|
|
|
1180
1142
|
return (
|
|
@@ -1214,6 +1176,19 @@ function ColumnSelector({
|
|
|
1214
1176
|
</DropdownMenuCheckboxItem>
|
|
1215
1177
|
))}
|
|
1216
1178
|
<DropdownMenuSeparator />
|
|
1179
|
+
<DropdownMenuLabel>{t('admin.components.table_toolbar.row_density')}</DropdownMenuLabel>
|
|
1180
|
+
<DropdownMenuRadioGroup
|
|
1181
|
+
value={density}
|
|
1182
|
+
onValueChange={(value) => setDensity(value as TableDensity)}
|
|
1183
|
+
>
|
|
1184
|
+
<DropdownMenuRadioItem value="comfortable" closeOnClick={false}>
|
|
1185
|
+
{t('admin.components.table_toolbar.density_comfortable')}
|
|
1186
|
+
</DropdownMenuRadioItem>
|
|
1187
|
+
<DropdownMenuRadioItem value="compact" closeOnClick={false}>
|
|
1188
|
+
{t('admin.components.table_toolbar.density_compact')}
|
|
1189
|
+
</DropdownMenuRadioItem>
|
|
1190
|
+
</DropdownMenuRadioGroup>
|
|
1191
|
+
<DropdownMenuSeparator />
|
|
1217
1192
|
<Button
|
|
1218
1193
|
variant="ghost"
|
|
1219
1194
|
size="sm"
|
|
@@ -1231,8 +1206,10 @@ function ColumnSelector({
|
|
|
1231
1206
|
// Filter Drawer
|
|
1232
1207
|
// ============================================================================
|
|
1233
1208
|
|
|
1209
|
+
// 10px in from a 6px list inset puts row text 16px from the panel's edge,
|
|
1210
|
+
// in line with the header's label.
|
|
1234
1211
|
const panelRowClass =
|
|
1235
|
-
'group/row flex w-full items-center gap-2 rounded-lg px-2 py-
|
|
1212
|
+
'group/row flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50'
|
|
1236
1213
|
|
|
1237
1214
|
/** One value in a multi-select list of the filter panel. */
|
|
1238
1215
|
function CheckRow({
|
|
@@ -1500,8 +1477,6 @@ function FilterPanel({
|
|
|
1500
1477
|
}) {
|
|
1501
1478
|
const { t } = useTranslation()
|
|
1502
1479
|
const tenantId = useTenantId()
|
|
1503
|
-
const store = useOptionalStore()
|
|
1504
|
-
const timezone = store?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
|
|
1505
1480
|
/**
|
|
1506
1481
|
* What picking a field starts from: the rule already set on it, so reopening
|
|
1507
1482
|
* a filter shows the values it is filtering by. Without this a list reads as
|
|
@@ -1536,10 +1511,6 @@ function FilterPanel({
|
|
|
1536
1511
|
const [value, setValue] = useState(initial.value)
|
|
1537
1512
|
const [editing, setEditing] = useState<string | null>(initial.editing)
|
|
1538
1513
|
const [query, setQuery] = useState('')
|
|
1539
|
-
// Set once the user picks "custom" from the date presets, revealing the two
|
|
1540
|
-
// date fields. Kept separate from `value` so backing out of custom does not
|
|
1541
|
-
// strand a half-entered range.
|
|
1542
|
-
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null)
|
|
1543
1514
|
|
|
1544
1515
|
const column = columns.find((c) => c.key === field)
|
|
1545
1516
|
const type = column?.filterType ?? 'string'
|
|
@@ -1595,7 +1566,6 @@ function FilterPanel({
|
|
|
1595
1566
|
setValue(seed.value)
|
|
1596
1567
|
setEditing(seed.editing)
|
|
1597
1568
|
setQuery('')
|
|
1598
|
-
setCustomRange(null)
|
|
1599
1569
|
}
|
|
1600
1570
|
|
|
1601
1571
|
/** Back to the field list. Clears the half-built rule with it — the search
|
|
@@ -1607,7 +1577,6 @@ function FilterPanel({
|
|
|
1607
1577
|
setValue('')
|
|
1608
1578
|
setEditing(null)
|
|
1609
1579
|
setQuery('')
|
|
1610
|
-
setCustomRange(null)
|
|
1611
1580
|
}
|
|
1612
1581
|
|
|
1613
1582
|
/**
|
|
@@ -1626,27 +1595,7 @@ function FilterPanel({
|
|
|
1626
1595
|
const others = filters.filter(
|
|
1627
1596
|
(f) => !(f.field === field && f.operator === nextOperator) && f.id !== editing,
|
|
1628
1597
|
)
|
|
1629
|
-
onApply([
|
|
1630
|
-
...others,
|
|
1631
|
-
{ id: crypto.randomUUID(), field, operator: nextOperator, value: nextValue },
|
|
1632
|
-
])
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
/** Replace this field's bounds with a preset's, and close. */
|
|
1636
|
-
function commitDatePreset(preset: DatePresetKey) {
|
|
1637
|
-
if (!field) return
|
|
1638
|
-
const range = resolveDatePreset(preset, timezone)
|
|
1639
|
-
const others = filters.filter(
|
|
1640
|
-
(f) => !(f.field === field && (f.operator === 'gteq' || f.operator === 'lteq')),
|
|
1641
|
-
)
|
|
1642
|
-
const next = [...others]
|
|
1643
|
-
if (range.from) {
|
|
1644
|
-
next.push({ id: crypto.randomUUID(), field, operator: 'gteq', value: range.from })
|
|
1645
|
-
}
|
|
1646
|
-
if (range.to) {
|
|
1647
|
-
next.push({ id: crypto.randomUUID(), field, operator: 'lteq', value: range.to })
|
|
1648
|
-
}
|
|
1649
|
-
onApply(next)
|
|
1598
|
+
onApply([...others, { id: randomUUID(), field, operator: nextOperator, value: nextValue }])
|
|
1650
1599
|
}
|
|
1651
1600
|
|
|
1652
1601
|
/**
|
|
@@ -1672,7 +1621,7 @@ function FilterPanel({
|
|
|
1672
1621
|
onChange(
|
|
1673
1622
|
next.length === 0
|
|
1674
1623
|
? others
|
|
1675
|
-
: [...others, { id:
|
|
1624
|
+
: [...others, { id: randomUUID(), field, operator: 'in', value: next.join(',') }],
|
|
1676
1625
|
)
|
|
1677
1626
|
}
|
|
1678
1627
|
|
|
@@ -1694,27 +1643,28 @@ function FilterPanel({
|
|
|
1694
1643
|
// fill, the search field's own ground — paints over the rounded corner and
|
|
1695
1644
|
// squares it off. Clipping here fixes both top corners once, rather than
|
|
1696
1645
|
// asking each child to round the one it happens to touch.
|
|
1697
|
-
<div
|
|
1646
|
+
<div
|
|
1647
|
+
className={cn(
|
|
1648
|
+
'flex flex-col overflow-hidden rounded-xl',
|
|
1649
|
+
// A date field widens to fit the presets beside a two-month calendar.
|
|
1650
|
+
field && dated ? 'w-auto' : 'w-[min(480px,calc(100vw-1rem))]',
|
|
1651
|
+
)}
|
|
1652
|
+
>
|
|
1698
1653
|
{/* Header. Before a field is chosen this is a label and a search box over
|
|
1699
1654
|
the field list; after, the label becomes the way back to it. */}
|
|
1700
|
-
{/* The panel reads at the app's base size rather than the 13px the
|
|
1701
|
-
dropdowns beside it use: it is a surface to read a list in, not a
|
|
1702
|
-
menu to flick through, and its rows carry field names an operator has
|
|
1703
|
-
to recognise. Set here and on the body below, so a row only names a
|
|
1704
|
-
size when it departs from it. */}
|
|
1705
1655
|
<div className="flex items-stretch border-b text-base">
|
|
1706
1656
|
{field ? (
|
|
1707
1657
|
// The field name is the way back to the field list.
|
|
1708
1658
|
<button
|
|
1709
1659
|
type="button"
|
|
1710
|
-
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-none border-r px-
|
|
1660
|
+
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-none border-r px-4 py-2.5 font-medium transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
|
|
1711
1661
|
onClick={clearField}
|
|
1712
1662
|
>
|
|
1713
1663
|
<ChevronLeftIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
1714
1664
|
{column?.label ?? field}
|
|
1715
1665
|
</button>
|
|
1716
1666
|
) : (
|
|
1717
|
-
<span className="flex shrink-0 items-center border-r px-
|
|
1667
|
+
<span className="flex shrink-0 items-center border-r px-4 py-2.5 font-medium">
|
|
1718
1668
|
{t('admin.components.table_toolbar.filter_by')}
|
|
1719
1669
|
</span>
|
|
1720
1670
|
)}
|
|
@@ -1728,7 +1678,7 @@ function FilterPanel({
|
|
|
1728
1678
|
value={query}
|
|
1729
1679
|
onChange={(event) => setQuery(event.target.value)}
|
|
1730
1680
|
placeholder={t('admin.components.table_toolbar.filter_to_placeholder')}
|
|
1731
|
-
className="min-w-0 flex-1 rounded-none border-0 shadow-none focus:shadow-none"
|
|
1681
|
+
className="min-w-0 flex-1 rounded-none border-0 px-3 shadow-none focus:shadow-none"
|
|
1732
1682
|
/>
|
|
1733
1683
|
) : (
|
|
1734
1684
|
<span className="flex-1" />
|
|
@@ -1756,7 +1706,14 @@ function FilterPanel({
|
|
|
1756
1706
|
the list from scrolling the page behind the panel; the themed
|
|
1757
1707
|
scrollbar the popover supplies is what tells the reader there is more
|
|
1758
1708
|
below, since the rows themselves end flush at the container's edge. */}
|
|
1759
|
-
<div
|
|
1709
|
+
<div
|
|
1710
|
+
className={cn(
|
|
1711
|
+
'text-base',
|
|
1712
|
+
// The calendar sizes itself; the lists scroll inside a capped body.
|
|
1713
|
+
!(field && dated) &&
|
|
1714
|
+
'max-h-[320px] overflow-y-auto overscroll-contain p-1.5 themed-scrollbar',
|
|
1715
|
+
)}
|
|
1716
|
+
>
|
|
1760
1717
|
{/* Stage one: pick a subject. */}
|
|
1761
1718
|
{!field &&
|
|
1762
1719
|
visibleFields.map((col) => (
|
|
@@ -1764,7 +1721,7 @@ function FilterPanel({
|
|
|
1764
1721
|
key={col.key}
|
|
1765
1722
|
type="button"
|
|
1766
1723
|
data-slot="filter-panel-item"
|
|
1767
|
-
className=
|
|
1724
|
+
className={cn(panelRowClass, 'justify-between')}
|
|
1768
1725
|
onClick={() => chooseField(col.key)}
|
|
1769
1726
|
>
|
|
1770
1727
|
{col.label}
|
|
@@ -1836,110 +1793,16 @@ function FilterPanel({
|
|
|
1836
1793
|
/>
|
|
1837
1794
|
)}
|
|
1838
1795
|
|
|
1839
|
-
{/* Stage two, dates: the
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
popup on top of this one. */}
|
|
1843
|
-
{field && dated && !customRange && (
|
|
1844
|
-
<>
|
|
1845
|
-
{DATE_PRESET_KEYS.map((preset) => (
|
|
1846
|
-
<button
|
|
1847
|
-
key={preset}
|
|
1848
|
-
type="button"
|
|
1849
|
-
data-slot="filter-panel-item"
|
|
1850
|
-
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"
|
|
1851
|
-
onClick={() => commitDatePreset(preset)}
|
|
1852
|
-
>
|
|
1853
|
-
{t(`admin.components.table_toolbar.date_presets.${preset}`)}
|
|
1854
|
-
</button>
|
|
1855
|
-
))}
|
|
1856
|
-
<button
|
|
1857
|
-
type="button"
|
|
1858
|
-
data-slot="filter-panel-item"
|
|
1859
|
-
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"
|
|
1860
|
-
// Seeded with the bounds applied, so editing one end keeps the other.
|
|
1861
|
-
onClick={() =>
|
|
1862
|
-
setCustomRange({
|
|
1863
|
-
from:
|
|
1864
|
-
filters.find((f) => f.field === field && f.operator === 'gteq')?.value ?? '',
|
|
1865
|
-
to: filters.find((f) => f.field === field && f.operator === 'lteq')?.value ?? '',
|
|
1866
|
-
})
|
|
1867
|
-
}
|
|
1868
|
-
>
|
|
1869
|
-
{t('admin.components.table_toolbar.date_presets.custom')}
|
|
1870
|
-
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
1871
|
-
</button>
|
|
1872
|
-
</>
|
|
1873
|
-
)}
|
|
1874
|
-
|
|
1875
|
-
{field && dated && customRange && (
|
|
1876
|
-
<div data-slot="filter-panel-controls" className="flex flex-col gap-2 p-2">
|
|
1877
|
-
<div className="flex flex-col gap-1">
|
|
1878
|
-
<span className="text-xs text-muted-foreground">
|
|
1879
|
-
{t('admin.components.table_toolbar.date_range_start')}
|
|
1880
|
-
</span>
|
|
1881
|
-
<StoreDatePicker
|
|
1882
|
-
value={customRange.from || null}
|
|
1883
|
-
onChange={(next) => setCustomRange({ ...customRange, from: next ?? '' })}
|
|
1884
|
-
placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
|
|
1885
|
-
/>
|
|
1886
|
-
</div>
|
|
1887
|
-
<div className="flex flex-col gap-1">
|
|
1888
|
-
<span className="text-xs text-muted-foreground">
|
|
1889
|
-
{t('admin.components.table_toolbar.date_range_end')}
|
|
1890
|
-
</span>
|
|
1891
|
-
<StoreDatePicker
|
|
1892
|
-
value={customRange.to || null}
|
|
1893
|
-
onChange={(next) => setCustomRange({ ...customRange, to: next ?? '' })}
|
|
1894
|
-
placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
|
|
1895
|
-
/>
|
|
1896
|
-
</div>
|
|
1897
|
-
<div className="flex justify-end gap-1.5">
|
|
1898
|
-
<Button variant="ghost" size="sm" onClick={() => setCustomRange(null)}>
|
|
1899
|
-
{t('admin.actions.back')}
|
|
1900
|
-
</Button>
|
|
1901
|
-
<Button
|
|
1902
|
-
size="sm"
|
|
1903
|
-
// Either bound alone is a valid filter — "everything since
|
|
1904
|
-
// March" is a question people ask.
|
|
1905
|
-
disabled={!customRange.from && !customRange.to}
|
|
1906
|
-
onClick={() => {
|
|
1907
|
-
if (!field) return
|
|
1908
|
-
const others = filters.filter(
|
|
1909
|
-
(f) => !(f.field === field && (f.operator === 'gteq' || f.operator === 'lteq')),
|
|
1910
|
-
)
|
|
1911
|
-
const next = [...others]
|
|
1912
|
-
if (customRange.from) {
|
|
1913
|
-
next.push({
|
|
1914
|
-
id: crypto.randomUUID(),
|
|
1915
|
-
field,
|
|
1916
|
-
operator: 'gteq',
|
|
1917
|
-
value: customRange.from,
|
|
1918
|
-
})
|
|
1919
|
-
}
|
|
1920
|
-
if (customRange.to) {
|
|
1921
|
-
next.push({
|
|
1922
|
-
id: crypto.randomUUID(),
|
|
1923
|
-
field,
|
|
1924
|
-
operator: 'lteq',
|
|
1925
|
-
value: customRange.to,
|
|
1926
|
-
})
|
|
1927
|
-
}
|
|
1928
|
-
onApply(next)
|
|
1929
|
-
}}
|
|
1930
|
-
>
|
|
1931
|
-
{t('admin.actions.apply')}
|
|
1932
|
-
</Button>
|
|
1933
|
-
</div>
|
|
1934
|
-
</div>
|
|
1935
|
-
)}
|
|
1796
|
+
{/* Stage two, dates: the presets and range calendar the quick filter
|
|
1797
|
+
uses, so a range is picked the same way wherever it is set. */}
|
|
1798
|
+
{field && dated && <ColumnDateRange field={field} filters={filters} onApply={onApply} />}
|
|
1936
1799
|
|
|
1937
1800
|
{/* Stage two, everything else: an operator and a value. The operator
|
|
1938
1801
|
lives here rather than in the header because it only exists for
|
|
1939
1802
|
these types — a text column's "contains" versus "starts with" is a
|
|
1940
1803
|
real choice, where a status has nothing to choose. */}
|
|
1941
1804
|
{field && !listed && !dated && (
|
|
1942
|
-
<div data-slot="filter-panel-controls" className="flex flex-col gap-
|
|
1805
|
+
<div data-slot="filter-panel-controls" className="flex flex-col gap-3 p-3">
|
|
1943
1806
|
<Select items={operators} value={operator} onValueChange={setOperator}>
|
|
1944
1807
|
{/* Default size, not `sm`: this sits directly above the value
|
|
1945
1808
|
input, and `sm` pins a fixed 8 height while the input only
|