@spree/dashboard-core 0.13.0 → 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.
Files changed (126) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +97 -36
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +425 -131
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +261 -14
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -17,7 +17,6 @@ import {
17
17
  import { CSS } from '@dnd-kit/utilities'
18
18
  import {
19
19
  Card,
20
- CardContent,
21
20
  Checkbox,
22
21
  cn,
23
22
  DragHandle,
@@ -28,14 +27,21 @@ import {
28
27
  EmptyTitle,
29
28
  Pagination,
30
29
  type PaginationMeta,
30
+ Skeleton,
31
31
  Table,
32
32
  TableBody,
33
33
  TableCell,
34
34
  TableEmpty,
35
35
  TableHead,
36
36
  TableHeader,
37
+ TableHeaderRow,
37
38
  TableRow,
39
+ Tooltip,
40
+ TooltipContent,
41
+ TooltipTrigger,
42
+ useIsMobile,
38
43
  } from '@spree/dashboard-ui'
44
+ import { InfoIcon } from '@spree/dashboard-ui/icons'
39
45
  import { useQuery, useQueryClient } from '@tanstack/react-query'
40
46
  import { useNavigate } from '@tanstack/react-router'
41
47
  import {
@@ -45,24 +51,25 @@ import {
45
51
  useDeferredValue,
46
52
  useEffect,
47
53
  useMemo,
48
- useRef,
49
54
  useState,
50
55
  } from 'react'
56
+ import { createPortal } from 'react-dom'
51
57
  import { useTranslation } from 'react-i18next'
52
58
  import { z } from 'zod/v4'
53
59
  import { useAuth } from '../hooks/use-auth'
60
+ import { useVisualViewportOffset } from '../hooks/use-visual-viewport-offset'
54
61
  import { filtersToRansack } from '../lib/filters-to-ransack'
55
62
  import { withStoreScope } from '../lib/query-keys'
56
63
  import {
57
64
  type ColumnDef,
58
65
  type FilterRule,
59
66
  getDefaultColumnKeys,
60
- getDisplayableColumns,
61
67
  getTable,
62
68
  type SortOption,
63
69
  } from '../lib/table-registry'
64
- import { useStore } from '../providers/store-provider'
70
+ import { useTenantId } from '../providers/tenant-provider'
65
71
  import { type BulkAction, BulkActionBar } from './bulk-action-bar'
72
+ import { PageHeader } from './page-header'
66
73
  import { TableToolbar } from './table-toolbar'
67
74
 
68
75
  // ============================================================================
@@ -76,6 +83,39 @@ const filterSchema = z.object({
76
83
  value: z.string(),
77
84
  })
78
85
 
86
+ /**
87
+ * A column header, with its explanation behind an info icon when it has one.
88
+ *
89
+ * Figures whose meaning is a definition rather than a word — available,
90
+ * reserved, incoming — need somewhere to say what they count. A tooltip keeps
91
+ * that out of the header row until it is asked for.
92
+ */
93
+ function ColumnLabel({ column, hideHint }: { column: ColumnDef; hideHint?: boolean }) {
94
+ if (!column.labelHint || hideHint) return column.label
95
+
96
+ return (
97
+ <span className="inline-flex items-center gap-1">
98
+ {column.label}
99
+ <Tooltip>
100
+ <TooltipTrigger asChild>
101
+ {/* Focusable and named, so the explanation opens on focus as well as
102
+ hover and reaches a screen reader the same way. */}
103
+ <button
104
+ type="button"
105
+ className="cursor-help rounded-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"
106
+ aria-label={column.labelHint}
107
+ >
108
+ <InfoIcon className="size-3.5" />
109
+ </button>
110
+ </TooltipTrigger>
111
+ <TooltipContent className="max-w-xs font-normal normal-case">
112
+ {column.labelHint}
113
+ </TooltipContent>
114
+ </Tooltip>
115
+ </span>
116
+ )
117
+ }
118
+
79
119
  export const resourceSearchSchema = z.object({
80
120
  page: z.coerce.number().optional().default(1),
81
121
  limit: z.coerce.number().optional(),
@@ -129,9 +169,9 @@ interface ResourceTableProps<T> {
129
169
  /** Registry key (e.g., 'products') */
130
170
  tableKey: string
131
171
  /**
132
- * TanStack Query key prefix. The table auto-injects the current storeId so
133
- * mutation hooks that invalidate `['<key>', storeId]` always match — pages
134
- * never need to thread storeId through themselves. A plain string is
172
+ * TanStack Query key prefix. The table auto-injects the current tenant id so
173
+ * mutation hooks that invalidate `['<key>', tenantId]` always match — pages
174
+ * never need to thread it through themselves. A plain string is
135
175
  * shorthand for `[string]`; pass an array to add further scope (e.g.
136
176
  * `[webhookDeliveriesTableKey(endpointId)]`).
137
177
  */
@@ -142,6 +182,21 @@ interface ResourceTableProps<T> {
142
182
  searchParams: ResourceSearch
143
183
  /** Title displayed in the toolbar header. Overrides the table definition's title. */
144
184
  title?: string
185
+ /**
186
+ * Set when the PAGE already renders its own `PageHeader` and this table is a
187
+ * panel inside it, so the table renders no heading of its own.
188
+ *
189
+ * Declared rather than detected. The shell tracks how many headers are
190
+ * mounted, but during a route change the outgoing page's header has not
191
+ * unregistered by the time the incoming table first renders — so a table
192
+ * reading that count saw a header that was on its way out and silently
193
+ * dropped its own. Only the page knows this, and it knows it statically.
194
+ */
195
+ hideHeader?: boolean
196
+ /** One line under the title. Overrides the table definition's description. */
197
+ description?: string
198
+ /** Docs for the feature, linked after the description. Overrides the table definition's. */
199
+ docsPath?: string
145
200
  /** Default params always sent with every request (e.g., { complete: 1 } for orders) */
146
201
  defaultParams?: Record<string, unknown>
147
202
  /**
@@ -174,6 +229,16 @@ interface ResourceTableProps<T> {
174
229
  * menu on the trailing edge.
175
230
  */
176
231
  rowActions?: (row: T) => ReactNode
232
+ /**
233
+ * Dynamic per-store columns derived from custom field definitions,
234
+ * merged into the registry columns for display (column selector +
235
+ * cells), sorting, and filtering. Keys are the definitions'
236
+ * `filter_key` (`cf_*`), which the backend accepts as sort and
237
+ * filter attributes.
238
+ */
239
+ customFieldColumns?: ColumnDef<T>[]
240
+ /** @deprecated Use `customFieldColumns` — removed in Spree 6.1. */
241
+ metafieldColumns?: ColumnDef<T>[]
177
242
  }
178
243
 
179
244
  export interface ReorderConfig<T> {
@@ -192,11 +257,16 @@ export function ResourceTable<T extends Record<string, any>>({
192
257
  queryFn,
193
258
  searchParams,
194
259
  title,
260
+ hideHeader = false,
261
+ description,
262
+ docsPath,
195
263
  defaultParams,
196
264
  actions,
197
265
  reorder,
198
266
  bulkActions,
199
267
  rowActions,
268
+ customFieldColumns,
269
+ metafieldColumns,
200
270
  }: ResourceTableProps<T>) {
201
271
  const table = getTable<T>(tableKey)
202
272
  const { t } = useTranslation()
@@ -207,9 +277,12 @@ export function ResourceTable<T extends Record<string, any>>({
207
277
  const selectionEnabled = !!bulkActions?.length && !reorder
208
278
  const rowActionsEnabled = !!rowActions
209
279
  const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
210
- // Used by +BulkActionBar+ to anchor itself within the table card on
211
- // desktop (instead of free-floating at the viewport bottom).
212
- const cardRef = useRef<HTMLDivElement | null>(null)
280
+ // Whether the bulk bar is covering the column labels.
281
+ const bulkActive = selectionEnabled && selectedIds.size > 0
282
+ // Only one bulk bar is mounted at a time. Rendering both and hiding one
283
+ // with `md:` leaves its text in the accessibility tree, so "2 selected"
284
+ // and every action label match twice.
285
+ const isMobile = useIsMobile()
213
286
 
214
287
  const {
215
288
  page,
@@ -236,27 +309,69 @@ export function ResourceTable<T extends Record<string, any>>({
236
309
  const [searchInput, setSearchInput] = useState(search ?? '')
237
310
  const deferredSearch = useDeferredValue(searchInput)
238
311
 
239
- const displayableColumns = getDisplayableColumns(table)
312
+ // Registry columns + dynamic custom-field columns. Memoized so the toolbar's
313
+ // internal memos (FilterPanel `items` stability) keep stable deps.
314
+ const extraColumns = customFieldColumns ?? metafieldColumns
315
+ const allColumns = useMemo(
316
+ () => [...table.columns, ...(extraColumns ?? [])],
317
+ [table, extraColumns],
318
+ )
319
+ const displayableColumns = useMemo(
320
+ () => allColumns.filter((c) => c.displayable !== false),
321
+ [allColumns],
322
+ )
240
323
  const defaultColumnKeys = getDefaultColumnKeys(table)
241
324
  const visibleColumnKeys = urlColumns ?? defaultColumnKeys
242
325
 
243
- const visibleColumns = displayableColumns.filter((c) => visibleColumnKeys.includes(c.key))
326
+ const visibleColumns = useMemo(
327
+ () => displayableColumns.filter((c) => visibleColumnKeys.includes(c.key)),
328
+ [displayableColumns, visibleColumnKeys],
329
+ )
330
+
331
+ // Built from every displayable column, not the visible subset: the column
332
+ // picker is a desktop control (it is hidden on a phone), so the card should
333
+ // not inherit a selection the merchant cannot see or change there.
334
+
335
+ // Expands requested by currently-visible columns (e.g. custom field
336
+ // columns need `expand=custom_fields` to render their values). Sorted so
337
+ // the query key stays stable regardless of column order.
338
+ const columnExpand = useMemo(
339
+ () => [...new Set(visibleColumns.flatMap((c) => (c.expand ? [c.expand] : [])))].sort(),
340
+ [visibleColumns],
341
+ )
244
342
 
245
343
  // Build API params
246
344
  const sortString = dir === 'desc' ? `-${sort}` : sort
247
345
 
248
- const { storeId } = useStore()
249
- // Auto-inject storeId so every list query — and the matching mutation
250
- // invalidation — is store-scoped without each page re-implementing it.
346
+ const tenantId = useTenantId()
347
+ // Whether the PAGE mounted a header of its own, in which case this table is
348
+ // a panel inside it and renders no heading.
349
+ //
350
+ // Latched from the first render, before this component's own `PageHeader`
351
+ // can register: the flag counts every mounted header, so reading it live
352
+ // would see the one rendered below, conclude the page already had one, and
353
+ // unmount it — which re-clears the flag and mounts it again, forever.
354
+ const pageOwnsHeader = hideHeader
355
+ // Auto-inject the tenant id so every list query — and the matching mutation
356
+ // invalidation — is tenant-scoped without each page re-implementing it.
251
357
  // Goes through +withStoreScope+ so the slot ordering matches +useResourceKey+
252
- // and +useResourceMutation+'s invalidations (storeId at position 1).
358
+ // and +useResourceMutation+'s invalidations (tenant id at position 1).
253
359
  const userPrefix = Array.isArray(queryKey) ? queryKey : [queryKey]
254
- const queryKeyPrefix = withStoreScope(userPrefix, storeId)
360
+ const queryKeyPrefix = withStoreScope(userPrefix, tenantId)
361
+
362
+ // Both bulk bars (desktop overlay, mobile bottom bar) reset through here so
363
+ // they cannot drift apart.
364
+ const visualViewportOffset = useVisualViewportOffset(bulkActive)
365
+
366
+ const clearSelection = useCallback(() => {
367
+ setSelectedIds(new Set())
368
+ queryClient.invalidateQueries({ queryKey: queryKeyPrefix })
369
+ }, [queryClient, queryKeyPrefix])
255
370
 
256
371
  const { data, isLoading } = useQuery({
257
372
  queryKey: [
258
373
  ...queryKeyPrefix,
259
- { page, limit, sort: sortString, search: deferredSearch, filters },
374
+ { page, limit, sort: sortString, search: deferredSearch, filters, expand: columnExpand },
260
375
  ],
261
376
  queryFn: () => {
262
377
  const params: Record<string, unknown> = {
@@ -269,12 +384,22 @@ export function ResourceTable<T extends Record<string, any>>({
269
384
  params.limit = limit
270
385
  }
271
386
 
387
+ if (columnExpand.length) {
388
+ const base = defaultParams?.expand
389
+ const baseList = Array.isArray(base)
390
+ ? base
391
+ : typeof base === 'string'
392
+ ? base.split(',')
393
+ : []
394
+ params.expand = [...new Set([...baseList, ...columnExpand])]
395
+ }
396
+
272
397
  if (deferredSearch) {
273
398
  const searchParam = table.searchParam ?? 'name_cont'
274
399
  params[searchParam] = deferredSearch
275
400
  }
276
401
 
277
- Object.assign(params, filtersToRansack(filters as FilterRule[], table.columns))
402
+ Object.assign(params, filtersToRansack(filters as FilterRule[], allColumns))
278
403
 
279
404
  return queryFn(params)
280
405
  },
@@ -409,13 +534,57 @@ export function ResourceTable<T extends Record<string, any>>({
409
534
  filters: filters as FilterRule[],
410
535
  search: deferredSearch,
411
536
  searchParam: table.searchParam ?? 'name_cont',
412
- columns: table.columns,
537
+ columns: allColumns,
413
538
  totalCount: meta?.count,
414
539
  })
415
540
  : actions
416
541
 
417
542
  return (
418
- <Card ref={cardRef} className="rounded-xl">
543
+ // Below `sm` the table spans the full width: the page gutter plus the
544
+ // card's own border spends ~34px of a 390px screen on chrome, and a table
545
+ // is the one place that width is worth reclaiming. Negative margins cancel
546
+ // The frame belongs to the ROWS, not to the screen. The toolbar sits above
547
+ // it and the pagination below it, both on the sheet itself — so the border
548
+ // encloses exactly the tabular data, and the controls that act on it read
549
+ // as chrome rather than as its first and last rows.
550
+ //
551
+ // Below `sm` the table loses its frame and spans the gutter the page
552
+ // container applies, so narrow screens spend no width on a box.
553
+ //
554
+ // No shadow at any width: the sheet under it is already a raised surface,
555
+ // so the table is a region drawn on it rather than a second card floating
556
+ // above one. Its hairline is `--border`, the same weight as the rules
557
+ // between its own rows, so the frame and its contents read as one table.
558
+ <div className="flex flex-col gap-3">
559
+ {/* The page's own header, not a title bar of the table's own: a list page
560
+ IS this table, so its title and actions are the page's. Rendering
561
+ `PageHeader` here rather than a parallel implementation means a list
562
+ page and a detail page share one heading — the same type, spacing,
563
+ sticky behaviour and action slot — and the `page.actions` slot a
564
+ plugin registers into reaches both.
565
+
566
+ Skipped via `hideHeader` when the page already mounts its own
567
+ `PageHeader` (a table embedded as a panel), which would otherwise
568
+ give the page two. */}
569
+ {!pageOwnsHeader && (
570
+ <PageHeader
571
+ // Not pinned: on a list page the table's own column row is the band
572
+ // worth keeping on screen, and stacking a sticky title above it
573
+ // spends a third of a short viewport on chrome.
574
+ sticky={false}
575
+ title={title ?? table.title}
576
+ description={description ?? table.description}
577
+ docsPath={docsPath ?? table.docsPath}
578
+ actions={resolvedActions}
579
+ />
580
+ )}
581
+ {/* A page that owns its header still passes `actions` here, and they
582
+ would otherwise vanish with the suppressed `PageHeader` — taking the
583
+ orders export and the policies Add button with them. Render them
584
+ above the toolbar so the control keeps a home either way. */}
585
+ {pageOwnsHeader && resolvedActions && (
586
+ <div className="flex flex-wrap items-center justify-end gap-2">{resolvedActions}</div>
587
+ )}
419
588
  <TableToolbar
420
589
  columns={displayableColumns}
421
590
  visibleColumns={visibleColumnKeys}
@@ -427,12 +596,15 @@ export function ResourceTable<T extends Record<string, any>>({
427
596
  onSortChange={handleSortChange}
428
597
  filters={filters as FilterRule[]}
429
598
  onFiltersChange={handleFiltersChange}
430
- allColumns={table.columns}
431
- title={title ?? table.title}
432
- actions={resolvedActions}
599
+ allColumns={allColumns}
433
600
  hideSort={reorderActive}
434
601
  />
435
- <CardContent className="p-0">
602
+ {/* `overflow-x-clip`, never `overflow-hidden`: the rows clip to the
603
+ rounded frame horizontally, but a `hidden` overflow on either axis
604
+ makes this a scroll container, and the pinned column row inside
605
+ would then stick to THIS box rather than to the page — which is to
606
+ say, scroll away with it. */}
607
+ <Card className="-mx-4 gap-0 overflow-x-clip rounded-none border-0 bg-transparent py-0 shadow-none sm:mx-0 sm:rounded-xl sm:border">
436
608
  {reorderActive ? (
437
609
  <DndContext
438
610
  sensors={sensors}
@@ -443,13 +615,13 @@ export function ResourceTable<T extends Record<string, any>>({
443
615
  items={rows.map((r) => (r as any).id)}
444
616
  strategy={verticalListSortingStrategy}
445
617
  >
446
- <Table>
618
+ <Table stickyHeader roundedTop roundedBottom>
447
619
  <TableHeader>
448
- <tr>
620
+ <TableHeaderRow>
449
621
  <TableHead className="w-8" />
450
622
  {headerColumns.map((col) => (
451
623
  <TableHead key={col.key} className={col.headerClassName}>
452
- {col.label}
624
+ <ColumnLabel column={col} />
453
625
  </TableHead>
454
626
  ))}
455
627
  {rowActionsEnabled && (
@@ -457,13 +629,13 @@ export function ResourceTable<T extends Record<string, any>>({
457
629
  <span className="sr-only">{t('admin.row_actions.menu_label')}</span>
458
630
  </TableHead>
459
631
  )}
460
- </tr>
632
+ </TableHeaderRow>
461
633
  </TableHeader>
462
- <TableBody>
634
+ <TableBody className="bg-card">
463
635
  {isLoading ? (
464
- <TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
465
- {t('admin.common.loading')}
466
- </TableEmpty>
636
+ <TableSkeletonRows
637
+ colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}
638
+ />
467
639
  ) : rows.length === 0 ? (
468
640
  <TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
469
641
  <Empty className="border-0 p-0">
@@ -497,112 +669,234 @@ export function ResourceTable<T extends Record<string, any>>({
497
669
  </SortableContext>
498
670
  </DndContext>
499
671
  ) : (
500
- <Table>
501
- <TableHeader>
502
- <tr>
503
- {selectionEnabled && (
504
- <TableHead className="w-8">
672
+ <>
673
+ {/* The bulk actions overlay the header row but live OUTSIDE the
674
+ table's horizontal scroller: out there their width is the
675
+ visible card rather than the table's full scroll width, so the
676
+ overflow-into-⋯ budget is right and the controls hold still
677
+ while the columns scroll sideways. A zero-height wrapper with
678
+ the same sticky offset as the pinned header keeps the two
679
+ travelling together; the start padding clears the select-all
680
+ checkbox, which stays visible and interactive underneath. */}
681
+ {bulkActive && !isMobile && (
682
+ // `pointer-events-none` on the overlay, re-enabled only on the
683
+ // bar itself: the start-padding gutter is part of this div and
684
+ // would otherwise swallow every click aimed at the select-all
685
+ // checkbox sitting beneath it — checking once, then never
686
+ // unchecking.
687
+ <div className="pointer-events-none sticky top-header-height z-30 h-0">
688
+ <div className="flex h-[2.1875rem] items-center ps-10 pe-4">
689
+ <div className="pointer-events-auto min-w-0 flex-1">
690
+ <BulkActionBar
691
+ selectedIds={Array.from(selectedIds)}
692
+ actions={bulkActions!}
693
+ onDone={clearSelection}
694
+ />
695
+ </div>
696
+ </div>
697
+ </div>
698
+ )}
699
+
700
+ {/* On a phone the header-row overlay is the wrong home for this: it
701
+ scrolls sideways with a wide table and sits at the top, far from
702
+ the thumb. A fixed bottom bar stays put and in reach while the
703
+ merchant scrolls the list picking rows. */}
704
+ {bulkActive &&
705
+ isMobile &&
706
+ // Portalled to `document.body`: `SidebarInset` is `relative z-0`,
707
+ // which opens a stacking context, and a `fixed` child of it can
708
+ // only stack against its siblings there — page content painted
709
+ // later covered the bar until the merchant scrolled past it.
710
+ createPortal(
711
+ <section
712
+ className="fixed inset-x-0 z-50 border-t border-border bg-card px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-lg"
713
+ // `bottom` anchors to the *layout* viewport. Pinch-zoom (and
714
+ // a mobile URL bar) makes the visual viewport a smaller
715
+ // window onto it, leaving the bar off-screen until the
716
+ // merchant pans down — the offset lifts it back into view.
717
+ style={{ bottom: visualViewportOffset }}
718
+ aria-label={t('admin.a11y.bulk_actions')}
719
+ >
720
+ <div className="flex items-center gap-3">
505
721
  <Checkbox
506
722
  checked={allPageSelected}
507
723
  indeterminate={somePageSelected}
508
724
  onCheckedChange={togglePage}
509
725
  aria-label={t('admin.a11y.select_all_rows')}
510
726
  />
511
- </TableHead>
512
- )}
513
- {headerColumns.map((col) => (
514
- <TableHead key={col.key} className={col.headerClassName}>
515
- {col.label}
516
- </TableHead>
517
- ))}
518
- {rowActionsEnabled && (
519
- <TableHead className="w-12">
520
- <span className="sr-only">{t('admin.row_actions.menu_label')}</span>
521
- </TableHead>
522
- )}
523
- </tr>
524
- </TableHeader>
525
- <TableBody>
526
- {isLoading ? (
527
- <TableEmpty
528
- colSpan={
529
- visibleColumns.length + (selectionEnabled ? 1 : 0) + (rowActionsEnabled ? 1 : 0)
530
- }
531
- >
532
- {t('admin.common.loading')}
533
- </TableEmpty>
534
- ) : rows.length === 0 ? (
535
- <TableEmpty
536
- colSpan={
537
- visibleColumns.length + (selectionEnabled ? 1 : 0) + (rowActionsEnabled ? 1 : 0)
538
- }
539
- >
540
- <Empty className="border-0 p-0">
541
- <EmptyHeader>
542
- {table.emptyIcon && <EmptyMedia variant="icon">{table.emptyIcon}</EmptyMedia>}
543
- <EmptyTitle>{table.emptyMessage ?? t('admin.common.no_results')}</EmptyTitle>
544
- {(deferredSearch || (filters as FilterRule[]).length > 0) && (
545
- <EmptyDescription>
546
- {t('admin.common.adjust_search_or_filters')}
547
- </EmptyDescription>
727
+ <div className="min-w-0 flex-1">
728
+ <BulkActionBar
729
+ selectedIds={Array.from(selectedIds)}
730
+ actions={bulkActions!}
731
+ onDone={clearSelection}
732
+ />
733
+ </div>
734
+ </div>
735
+ </section>,
736
+ document.body,
737
+ )}
738
+ <Table stickyHeader roundedTop roundedBottom>
739
+ <TableHeader>
740
+ {/* Column headers stay mounted with rows selected, and the bulk
741
+ bar is laid over them. Swapping them for one `colSpan` cell
742
+ instead would drop the width constraints they impose on an
743
+ auto-layout table, and every column would resize the moment a
744
+ row was ticked. `bulkActive` only hides the labels. */}
745
+ <TableHeaderRow>
746
+ {selectionEnabled && (
747
+ <TableHead className="w-8">
748
+ <Checkbox
749
+ checked={allPageSelected}
750
+ indeterminate={somePageSelected}
751
+ onCheckedChange={togglePage}
752
+ aria-label={t('admin.a11y.select_all_rows')}
753
+ />
754
+ </TableHead>
755
+ )}
756
+ {headerColumns.map((col) => (
757
+ <TableHead
758
+ key={col.key}
759
+ className={cn(
760
+ col.headerClassName,
761
+ 'transition-colors duration-150 ease-out',
762
+ // Hide the text, not the cell: `opacity-0` would erase
763
+ // the cell's muted background and bottom rule too,
764
+ // leaving rows visible through the band behind the
765
+ // bulk actions overlay. Only where that overlay is
766
+ // drawn — on a phone the bar is at the foot of the
767
+ // screen and the column labels stay readable.
768
+ bulkActive && !isMobile && 'text-transparent select-none',
548
769
  )}
549
- </EmptyHeader>
550
- </Empty>
551
- </TableEmpty>
552
- ) : (
553
- rows.map((row, i) => {
554
- const rowId = String((row as any).id ?? i)
555
- const isSelected = selectionEnabled && selectedIds.has(rowId)
556
- return (
557
- <TableRow
558
- key={(row as any).id ?? i}
559
- className={isSelected ? 'bg-muted/40' : undefined}
560
770
  >
561
- {selectionEnabled && (
562
- <TableCell className="w-8">
563
- <Checkbox
564
- checked={isSelected}
565
- onCheckedChange={() => toggleRow(rowId)}
566
- aria-label={t('admin.a11y.select_row')}
567
- />
568
- </TableCell>
569
- )}
570
- {visibleColumns.map((col) => (
571
- <TableCell key={col.key} className={col.className}>
572
- {col.render ? col.render(row) : String((row as any)[col.key] ?? '—')}
573
- </TableCell>
574
- ))}
575
- {rowActionsEnabled && (
576
- <TableCell className="w-12 text-right">{rowActions?.(row)}</TableCell>
577
- )}
578
- </TableRow>
579
- )
580
- })
581
- )}
582
- </TableBody>
583
- </Table>
584
- )}
585
- {meta && (
586
- <Pagination
587
- meta={meta}
588
- onPageChange={(p) => updateSearch({ page: p })}
589
- onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
590
- />
591
- )}
592
- {selectionEnabled && (
593
- <BulkActionBar
594
- selectedIds={Array.from(selectedIds)}
595
- actions={bulkActions!}
596
- anchorRef={cardRef}
597
- onClear={() => setSelectedIds(new Set())}
598
- onDone={() => {
599
- setSelectedIds(new Set())
600
- queryClient.invalidateQueries({ queryKey: queryKeyPrefix })
601
- }}
602
- />
771
+ {/* The hint hides with the label: an info icon floating
772
+ over the bulk-actions band would be the only thing
773
+ left in the row. */}
774
+ <ColumnLabel column={col} hideHint={bulkActive && !isMobile} />
775
+ </TableHead>
776
+ ))}
777
+ {rowActionsEnabled && (
778
+ <TableHead className="w-12">
779
+ <span className="sr-only">{t('admin.row_actions.menu_label')}</span>
780
+ </TableHead>
781
+ )}
782
+ </TableHeaderRow>
783
+ </TableHeader>
784
+ <TableBody className="bg-card">
785
+ {isLoading ? (
786
+ <TableSkeletonRows
787
+ colSpan={
788
+ visibleColumns.length +
789
+ (selectionEnabled ? 1 : 0) +
790
+ (rowActionsEnabled ? 1 : 0)
791
+ }
792
+ />
793
+ ) : rows.length === 0 ? (
794
+ <TableEmpty
795
+ colSpan={
796
+ visibleColumns.length +
797
+ (selectionEnabled ? 1 : 0) +
798
+ (rowActionsEnabled ? 1 : 0)
799
+ }
800
+ >
801
+ <Empty className="border-0 p-0">
802
+ <EmptyHeader>
803
+ {table.emptyIcon && (
804
+ <EmptyMedia variant="icon">{table.emptyIcon}</EmptyMedia>
805
+ )}
806
+ <EmptyTitle>
807
+ {table.emptyMessage ?? t('admin.common.no_results')}
808
+ </EmptyTitle>
809
+ {(deferredSearch || (filters as FilterRule[]).length > 0) && (
810
+ <EmptyDescription>
811
+ {t('admin.common.adjust_search_or_filters')}
812
+ </EmptyDescription>
813
+ )}
814
+ </EmptyHeader>
815
+ </Empty>
816
+ </TableEmpty>
817
+ ) : (
818
+ rows.map((row, i) => {
819
+ const rowId = String((row as any).id ?? i)
820
+ const isSelected = selectionEnabled && selectedIds.has(rowId)
821
+ return (
822
+ <TableRow
823
+ key={(row as any).id ?? i}
824
+ // Selected rows carry the hover tint so the selection is
825
+ // visible at rest; hovering one goes a step deeper so the
826
+ // row still answers the pointer.
827
+ //
828
+ // A row holding an open popover is shaded the same way:
829
+ // the panel floats over its neighbours, and without this
830
+ // a merchant editing a figure has to count rows to see
831
+ // which record they are editing.
832
+ className={cn(
833
+ 'has-data-[popup-open]:bg-accent-strong/40',
834
+ isSelected && 'bg-accent-strong/75 hover:bg-accent-strong-hover',
835
+ )}
836
+ >
837
+ {selectionEnabled && (
838
+ <TableCell className="w-8">
839
+ <Checkbox
840
+ checked={isSelected}
841
+ onCheckedChange={() => toggleRow(rowId)}
842
+ aria-label={t('admin.a11y.select_row')}
843
+ />
844
+ </TableCell>
845
+ )}
846
+ {visibleColumns.map((col) => (
847
+ <TableCell key={col.key} className={col.className}>
848
+ {col.render ? col.render(row) : String((row as any)[col.key] ?? '—')}
849
+ </TableCell>
850
+ ))}
851
+ {rowActionsEnabled && (
852
+ <TableCell className="w-12 text-right">{rowActions?.(row)}</TableCell>
853
+ )}
854
+ </TableRow>
855
+ )
856
+ })
857
+ )}
858
+ </TableBody>
859
+ </Table>
860
+ </>
603
861
  )}
604
- </CardContent>
605
- </Card>
862
+ </Card>
863
+ {meta && (
864
+ <Pagination
865
+ meta={meta}
866
+ onPageChange={(p) => updateSearch({ page: p })}
867
+ onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
868
+ />
869
+ )}
870
+ {/* Clears the fixed bottom bar so it can't cover the pagination or the
871
+ last row while a selection is active. Carries the same safe-area
872
+ inset the bar pads itself by, or the home indicator's worth of bar
873
+ still overlaps the last row. Sits after the pagination, which is now
874
+ the last thing the bar can cover. */}
875
+ {bulkActive && isMobile && (
876
+ <div className="h-[calc(4rem+max(0px,env(safe-area-inset-bottom)-0.75rem))]" aria-hidden />
877
+ )}
878
+ </div>
879
+ )
880
+ }
881
+
882
+ // ============================================================================
883
+ // TableSkeletonRows — placeholder rows while the first page loads. Rows rather
884
+ // than a "Loading…" line so the table keeps its height and the page doesn't
885
+ // jump when data lands.
886
+ // ============================================================================
887
+
888
+ function TableSkeletonRows({ colSpan, rows = 8 }: { colSpan: number; rows?: number }) {
889
+ return (
890
+ <>
891
+ {Array.from({ length: rows }, (_, i) => (
892
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length placeholder list
893
+ <tr key={i} className="last:*:border-b-0">
894
+ <TableCell colSpan={colSpan}>
895
+ <Skeleton className="h-5 w-full" />
896
+ </TableCell>
897
+ </tr>
898
+ ))}
899
+ </>
606
900
  )
607
901
  }
608
902
 
@@ -634,7 +928,7 @@ function SortableRow<T extends Record<string, any>>({
634
928
  ref={setNodeRef}
635
929
  style={style}
636
930
  className={cn(
637
- 'group/row hover:bg-muted/60 last:*:border-b-0',
931
+ 'group/row hover:bg-accent-strong/50 last:*:border-b-0',
638
932
  isDragging && 'relative z-10 opacity-70',
639
933
  )}
640
934
  >