@spree/dashboard-core 6.0.3 → 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.
Files changed (86) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +19 -19
  3. package/src/api-client.ts +7 -1
  4. package/src/client.ts +2 -0
  5. package/src/components/address-form-dialog.tsx +75 -28
  6. package/src/components/app-shell.tsx +5 -4
  7. package/src/components/app-sidebar.tsx +2 -1
  8. package/src/components/bulk-action-bar.tsx +6 -5
  9. package/src/components/country-state-fields.tsx +3 -0
  10. package/src/components/currency-select.tsx +6 -4
  11. package/src/components/developer-inspector-drawer.tsx +322 -0
  12. package/src/components/file-upload-field.tsx +64 -37
  13. package/src/components/image-upload-field.tsx +4 -0
  14. package/src/components/import-wizard-dialog.tsx +14 -12
  15. package/src/components/invitation-acceptance.tsx +15 -11
  16. package/src/components/media-editor-dialog.tsx +131 -0
  17. package/src/components/media-picker-dialog.tsx +866 -0
  18. package/src/components/media-picker-sheet.tsx +13 -300
  19. package/src/components/mobile-top-bar.tsx +1 -1
  20. package/src/components/nav-main.tsx +13 -2
  21. package/src/components/page-header.tsx +57 -13
  22. package/src/components/page-tabs.tsx +1 -1
  23. package/src/components/product-stock-summary.tsx +1 -1
  24. package/src/components/read-row.tsx +38 -0
  25. package/src/components/resource-combobox.tsx +1 -1
  26. package/src/components/resource-image-field.tsx +150 -0
  27. package/src/components/resource-picker-sheet.tsx +2 -2
  28. package/src/components/resource-table.tsx +161 -40
  29. package/src/components/section-heading.tsx +2 -2
  30. package/src/components/settings-container.tsx +19 -0
  31. package/src/components/settings-fields.tsx +165 -0
  32. package/src/components/settings-section.tsx +46 -0
  33. package/src/components/settings-sidebar.tsx +35 -18
  34. package/src/components/sheet-top-bar.tsx +7 -8
  35. package/src/components/sidebar-search.tsx +11 -12
  36. package/src/components/sidebar-user.tsx +39 -44
  37. package/src/components/skip-link.tsx +1 -1
  38. package/src/components/store-switcher.tsx +1 -1
  39. package/src/components/table-toolbar.tsx +189 -323
  40. package/src/components/timezone-combobox.tsx +119 -0
  41. package/src/components/top-bar-breadcrumbs.tsx +1 -1
  42. package/src/hooks/use-stock-locations.ts +6 -2
  43. package/src/hooks/use-table-density.ts +50 -0
  44. package/src/index.ts +19 -2
  45. package/src/lib/curl.test.ts +18 -0
  46. package/src/lib/curl.ts +28 -0
  47. package/src/lib/developer-inspector.test.ts +169 -0
  48. package/src/lib/developer-inspector.ts +134 -0
  49. package/src/lib/docs.ts +3 -2
  50. package/src/lib/fulfillment-items.test.ts +51 -0
  51. package/src/lib/fulfillment-items.ts +53 -0
  52. package/src/lib/request-snippets.ts +41 -0
  53. package/src/lib/table-registry.ts +13 -6
  54. package/src/lib/timestamp-columns.tsx +1 -1
  55. package/src/locales/ar.json +120 -14
  56. package/src/locales/de.json +120 -14
  57. package/src/locales/en.json +120 -14
  58. package/src/locales/es.json +120 -14
  59. package/src/locales/fr.json +120 -14
  60. package/src/locales/ja.json +120 -14
  61. package/src/locales/pl.json +120 -14
  62. package/src/locales/vi.json +120 -14
  63. package/src/locales/zh-CN.json +120 -14
  64. package/src/pages/settings-index.tsx +79 -48
  65. package/src/pages/stock-locations.tsx +14 -6
  66. package/src/products/digital-asset-edit-sheet.tsx +1 -1
  67. package/src/products/digital-asset-provider-sheet.tsx +1 -1
  68. package/src/products/digital-assets-card.tsx +7 -7
  69. package/src/products/inventory-section.tsx +2 -2
  70. package/src/products/media-edit-sheet.tsx +136 -133
  71. package/src/products/product-form-cards.tsx +201 -70
  72. package/src/products/product-form-mapping.ts +1 -0
  73. package/src/products/product-schema.ts +8 -0
  74. package/src/products/product-types.ts +2 -0
  75. package/src/products/use-product-form-data.ts +30 -3
  76. package/src/products/variant-edit-sheet.tsx +3 -3
  77. package/src/products/variants-options-builder.tsx +13 -8
  78. package/src/products/variants-section.tsx +196 -4
  79. package/src/providers/auth-provider.tsx +2 -0
  80. package/src/providers/developer-inspector-provider.tsx +145 -0
  81. package/src/providers/json-link-resolver-provider.tsx +27 -0
  82. package/src/sellers/seller-schema.ts +107 -0
  83. package/src/tables/package-types.tsx +3 -3
  84. package/src/tables/stock-locations.tsx +15 -14
  85. package/src/virtual.d.ts +4 -0
  86. package/src/vite/index.ts +103 -5
@@ -3,6 +3,7 @@ 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
- DATE_PRESET_KEYS,
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
- <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
- className="hidden h-[2.125rem] w-[240px] bg-card text-sm lg:flex"
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, …)` rather than a flat 480px: the panel is wider
331
- than a phone, so a fixed width runs its right edge and the
332
- Apply button off the screen. */}
333
- <PopoverContent align="start" className="w-[min(480px,calc(100vw-1rem))] p-0">
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-sm shadow-xs lg:hidden">
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
- <SortDropdown columns={sortableColumns} sort={sort} onSortChange={onSortChange} />
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 and they are not: the solid
556
- // border already says that, against the dashed one an unused control wears.
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
- function SortDropdown({
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
- * A control that is not narrowing anything wears a dashed border. Solid
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(unset: boolean) {
732
- return cn('h-11 gap-1.5 lg:h-[2.125rem]', unset && 'border-dashed')
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. */
@@ -878,24 +878,18 @@ function QuickEnumFilter({
878
878
  return (
879
879
  <DropdownMenu>
880
880
  <DropdownMenuTrigger asChild>
881
- <Button variant="outline" size="sm" className={quickFilterTriggerClass(allSelected)}>
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
- <ChevronDownIcon className="size-3.5 text-muted-foreground" />
892
+ <ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
899
893
  </Button>
900
894
  </DropdownMenuTrigger>
901
895
  <DropdownMenuContent align="start" className="min-w-56">
@@ -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
- <ChevronDownIcon className="size-3.5 text-muted-foreground" />
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 store = useOptionalStore()
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: randomUUID(),
1043
- field: column.key,
1044
- operator: 'gteq',
1045
- value: rangeFrom,
1046
- })
1047
- }
1048
- if (rangeTo) {
1049
- next.push({ id: 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(active === 'all')}>
1019
+ <Button variant="outline" size="sm" className={quickFilterTriggerClass()}>
1080
1020
  <span className="text-muted-foreground">{column.label}</span>
1081
- <span>{t(`admin.components.table_toolbar.date_presets.${active}`)}</span>
1082
- <ChevronDownIcon className="size-3.5 text-muted-foreground" />
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-[min(320px,calc(100vw-1rem))] p-1">
1086
- {custom ? (
1087
- <div data-slot="filter-panel-controls" className="flex flex-col gap-2 p-2">
1088
- <div className="flex flex-col gap-1">
1089
- <span className="text-xs text-muted-foreground">
1090
- {t('admin.components.table_toolbar.date_range_start')}
1091
- </span>
1092
- <StoreDatePicker
1093
- value={custom.from || null}
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-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50'
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
  /**
@@ -1629,23 +1598,6 @@ function FilterPanel({
1629
1598
  onApply([...others, { id: randomUUID(), field, operator: nextOperator, value: nextValue }])
1630
1599
  }
1631
1600
 
1632
- /** Replace this field's bounds with a preset's, and close. */
1633
- function commitDatePreset(preset: DatePresetKey) {
1634
- if (!field) return
1635
- const range = resolveDatePreset(preset, timezone)
1636
- const others = filters.filter(
1637
- (f) => !(f.field === field && (f.operator === 'gteq' || f.operator === 'lteq')),
1638
- )
1639
- const next = [...others]
1640
- if (range.from) {
1641
- next.push({ id: randomUUID(), field, operator: 'gteq', value: range.from })
1642
- }
1643
- if (range.to) {
1644
- next.push({ id: randomUUID(), field, operator: 'lteq', value: range.to })
1645
- }
1646
- onApply(next)
1647
- }
1648
-
1649
1601
  /**
1650
1602
  * Apply a multi-select as it is ticked, keeping the panel open.
1651
1603
  *
@@ -1691,27 +1643,28 @@ function FilterPanel({
1691
1643
  // fill, the search field's own ground — paints over the rounded corner and
1692
1644
  // squares it off. Clipping here fixes both top corners once, rather than
1693
1645
  // asking each child to round the one it happens to touch.
1694
- <div className="flex flex-col overflow-hidden rounded-xl">
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
+ >
1695
1653
  {/* Header. Before a field is chosen this is a label and a search box over
1696
1654
  the field list; after, the label becomes the way back to it. */}
1697
- {/* The panel reads at the app's base size rather than the 13px the
1698
- dropdowns beside it use: it is a surface to read a list in, not a
1699
- menu to flick through, and its rows carry field names an operator has
1700
- to recognise. Set here and on the body below, so a row only names a
1701
- size when it departs from it. */}
1702
1655
  <div className="flex items-stretch border-b text-base">
1703
1656
  {field ? (
1704
1657
  // The field name is the way back to the field list.
1705
1658
  <button
1706
1659
  type="button"
1707
- 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"
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"
1708
1661
  onClick={clearField}
1709
1662
  >
1710
1663
  <ChevronLeftIcon className="size-4 shrink-0 text-muted-foreground" />
1711
1664
  {column?.label ?? field}
1712
1665
  </button>
1713
1666
  ) : (
1714
- <span className="flex shrink-0 items-center border-r px-3 py-2 font-medium">
1667
+ <span className="flex shrink-0 items-center border-r px-4 py-2.5 font-medium">
1715
1668
  {t('admin.components.table_toolbar.filter_by')}
1716
1669
  </span>
1717
1670
  )}
@@ -1725,7 +1678,7 @@ function FilterPanel({
1725
1678
  value={query}
1726
1679
  onChange={(event) => setQuery(event.target.value)}
1727
1680
  placeholder={t('admin.components.table_toolbar.filter_to_placeholder')}
1728
- 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"
1729
1682
  />
1730
1683
  ) : (
1731
1684
  <span className="flex-1" />
@@ -1753,7 +1706,14 @@ function FilterPanel({
1753
1706
  the list from scrolling the page behind the panel; the themed
1754
1707
  scrollbar the popover supplies is what tells the reader there is more
1755
1708
  below, since the rows themselves end flush at the container's edge. */}
1756
- <div className="max-h-[320px] overflow-y-auto overscroll-contain p-1 text-base themed-scrollbar">
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
+ >
1757
1717
  {/* Stage one: pick a subject. */}
1758
1718
  {!field &&
1759
1719
  visibleFields.map((col) => (
@@ -1761,7 +1721,7 @@ function FilterPanel({
1761
1721
  key={col.key}
1762
1722
  type="button"
1763
1723
  data-slot="filter-panel-item"
1764
- 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"
1724
+ className={cn(panelRowClass, 'justify-between')}
1765
1725
  onClick={() => chooseField(col.key)}
1766
1726
  >
1767
1727
  {col.label}
@@ -1833,110 +1793,16 @@ function FilterPanel({
1833
1793
  />
1834
1794
  )}
1835
1795
 
1836
- {/* Stage two, dates: the same relative presets the quick filter
1837
- offers, so a range means the same thing wherever it was set.
1838
- "Custom" swaps in two date fields rather than opening a third
1839
- popup on top of this one. */}
1840
- {field && dated && !customRange && (
1841
- <>
1842
- {DATE_PRESET_KEYS.map((preset) => (
1843
- <button
1844
- key={preset}
1845
- type="button"
1846
- data-slot="filter-panel-item"
1847
- 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"
1848
- onClick={() => commitDatePreset(preset)}
1849
- >
1850
- {t(`admin.components.table_toolbar.date_presets.${preset}`)}
1851
- </button>
1852
- ))}
1853
- <button
1854
- type="button"
1855
- data-slot="filter-panel-item"
1856
- 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"
1857
- // Seeded with the bounds applied, so editing one end keeps the other.
1858
- onClick={() =>
1859
- setCustomRange({
1860
- from:
1861
- filters.find((f) => f.field === field && f.operator === 'gteq')?.value ?? '',
1862
- to: filters.find((f) => f.field === field && f.operator === 'lteq')?.value ?? '',
1863
- })
1864
- }
1865
- >
1866
- {t('admin.components.table_toolbar.date_presets.custom')}
1867
- <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
1868
- </button>
1869
- </>
1870
- )}
1871
-
1872
- {field && dated && customRange && (
1873
- <div data-slot="filter-panel-controls" className="flex flex-col gap-2 p-2">
1874
- <div className="flex flex-col gap-1">
1875
- <span className="text-xs text-muted-foreground">
1876
- {t('admin.components.table_toolbar.date_range_start')}
1877
- </span>
1878
- <StoreDatePicker
1879
- value={customRange.from || null}
1880
- onChange={(next) => setCustomRange({ ...customRange, from: next ?? '' })}
1881
- placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
1882
- />
1883
- </div>
1884
- <div className="flex flex-col gap-1">
1885
- <span className="text-xs text-muted-foreground">
1886
- {t('admin.components.table_toolbar.date_range_end')}
1887
- </span>
1888
- <StoreDatePicker
1889
- value={customRange.to || null}
1890
- onChange={(next) => setCustomRange({ ...customRange, to: next ?? '' })}
1891
- placeholder={t('admin.components.table_toolbar.filter_date_placeholder')}
1892
- />
1893
- </div>
1894
- <div className="flex justify-end gap-1.5">
1895
- <Button variant="ghost" size="sm" onClick={() => setCustomRange(null)}>
1896
- {t('admin.actions.back')}
1897
- </Button>
1898
- <Button
1899
- size="sm"
1900
- // Either bound alone is a valid filter — "everything since
1901
- // March" is a question people ask.
1902
- disabled={!customRange.from && !customRange.to}
1903
- onClick={() => {
1904
- if (!field) return
1905
- const others = filters.filter(
1906
- (f) => !(f.field === field && (f.operator === 'gteq' || f.operator === 'lteq')),
1907
- )
1908
- const next = [...others]
1909
- if (customRange.from) {
1910
- next.push({
1911
- id: randomUUID(),
1912
- field,
1913
- operator: 'gteq',
1914
- value: customRange.from,
1915
- })
1916
- }
1917
- if (customRange.to) {
1918
- next.push({
1919
- id: randomUUID(),
1920
- field,
1921
- operator: 'lteq',
1922
- value: customRange.to,
1923
- })
1924
- }
1925
- onApply(next)
1926
- }}
1927
- >
1928
- {t('admin.actions.apply')}
1929
- </Button>
1930
- </div>
1931
- </div>
1932
- )}
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} />}
1933
1799
 
1934
1800
  {/* Stage two, everything else: an operator and a value. The operator
1935
1801
  lives here rather than in the header because it only exists for
1936
1802
  these types — a text column's "contains" versus "starts with" is a
1937
1803
  real choice, where a status has nothing to choose. */}
1938
1804
  {field && !listed && !dated && (
1939
- <div data-slot="filter-panel-controls" className="flex flex-col gap-2 p-2">
1805
+ <div data-slot="filter-panel-controls" className="flex flex-col gap-3 p-3">
1940
1806
  <Select items={operators} value={operator} onValueChange={setOperator}>
1941
1807
  {/* Default size, not `sm`: this sits directly above the value
1942
1808
  input, and `sm` pins a fixed 8 height while the input only