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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -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,12 +51,13 @@ 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 {
@@ -60,8 +67,9 @@ import {
60
67
  getTable,
61
68
  type SortOption,
62
69
  } from '../lib/table-registry'
63
- import { useStore } from '../providers/store-provider'
70
+ import { useTenantId } from '../providers/tenant-provider'
64
71
  import { type BulkAction, BulkActionBar } from './bulk-action-bar'
72
+ import { PageHeader } from './page-header'
65
73
  import { TableToolbar } from './table-toolbar'
66
74
 
67
75
  // ============================================================================
@@ -75,6 +83,39 @@ const filterSchema = z.object({
75
83
  value: z.string(),
76
84
  })
77
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
+
78
119
  export const resourceSearchSchema = z.object({
79
120
  page: z.coerce.number().optional().default(1),
80
121
  limit: z.coerce.number().optional(),
@@ -128,9 +169,9 @@ interface ResourceTableProps<T> {
128
169
  /** Registry key (e.g., 'products') */
129
170
  tableKey: string
130
171
  /**
131
- * TanStack Query key prefix. The table auto-injects the current storeId so
132
- * mutation hooks that invalidate `['<key>', storeId]` always match — pages
133
- * 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
134
175
  * shorthand for `[string]`; pass an array to add further scope (e.g.
135
176
  * `[webhookDeliveriesTableKey(endpointId)]`).
136
177
  */
@@ -141,6 +182,21 @@ interface ResourceTableProps<T> {
141
182
  searchParams: ResourceSearch
142
183
  /** Title displayed in the toolbar header. Overrides the table definition's title. */
143
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
144
200
  /** Default params always sent with every request (e.g., { complete: 1 } for orders) */
145
201
  defaultParams?: Record<string, unknown>
146
202
  /**
@@ -180,6 +236,8 @@ interface ResourceTableProps<T> {
180
236
  * `filter_key` (`cf_*`), which the backend accepts as sort and
181
237
  * filter attributes.
182
238
  */
239
+ customFieldColumns?: ColumnDef<T>[]
240
+ /** @deprecated Use `customFieldColumns` — removed in Spree 6.1. */
183
241
  metafieldColumns?: ColumnDef<T>[]
184
242
  }
185
243
 
@@ -199,11 +257,15 @@ export function ResourceTable<T extends Record<string, any>>({
199
257
  queryFn,
200
258
  searchParams,
201
259
  title,
260
+ hideHeader = false,
261
+ description,
262
+ docsPath,
202
263
  defaultParams,
203
264
  actions,
204
265
  reorder,
205
266
  bulkActions,
206
267
  rowActions,
268
+ customFieldColumns,
207
269
  metafieldColumns,
208
270
  }: ResourceTableProps<T>) {
209
271
  const table = getTable<T>(tableKey)
@@ -215,9 +277,12 @@ export function ResourceTable<T extends Record<string, any>>({
215
277
  const selectionEnabled = !!bulkActions?.length && !reorder
216
278
  const rowActionsEnabled = !!rowActions
217
279
  const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
218
- // Used by +BulkActionBar+ to anchor itself within the table card on
219
- // desktop (instead of free-floating at the viewport bottom).
220
- 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()
221
286
 
222
287
  const {
223
288
  page,
@@ -244,11 +309,12 @@ export function ResourceTable<T extends Record<string, any>>({
244
309
  const [searchInput, setSearchInput] = useState(search ?? '')
245
310
  const deferredSearch = useDeferredValue(searchInput)
246
311
 
247
- // Registry columns + dynamic metafield columns. Memoized so the toolbar's
312
+ // Registry columns + dynamic custom-field columns. Memoized so the toolbar's
248
313
  // internal memos (FilterPanel `items` stability) keep stable deps.
314
+ const extraColumns = customFieldColumns ?? metafieldColumns
249
315
  const allColumns = useMemo(
250
- () => [...table.columns, ...(metafieldColumns ?? [])],
251
- [table, metafieldColumns],
316
+ () => [...table.columns, ...(extraColumns ?? [])],
317
+ [table, extraColumns],
252
318
  )
253
319
  const displayableColumns = useMemo(
254
320
  () => allColumns.filter((c) => c.displayable !== false),
@@ -262,6 +328,10 @@ export function ResourceTable<T extends Record<string, any>>({
262
328
  [displayableColumns, visibleColumnKeys],
263
329
  )
264
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
+
265
335
  // Expands requested by currently-visible columns (e.g. custom field
266
336
  // columns need `expand=custom_fields` to render their values). Sorted so
267
337
  // the query key stays stable regardless of column order.
@@ -273,13 +343,30 @@ export function ResourceTable<T extends Record<string, any>>({
273
343
  // Build API params
274
344
  const sortString = dir === 'desc' ? `-${sort}` : sort
275
345
 
276
- const { storeId } = useStore()
277
- // Auto-inject storeId so every list query — and the matching mutation
278
- // 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.
279
357
  // Goes through +withStoreScope+ so the slot ordering matches +useResourceKey+
280
- // and +useResourceMutation+'s invalidations (storeId at position 1).
358
+ // and +useResourceMutation+'s invalidations (tenant id at position 1).
281
359
  const userPrefix = Array.isArray(queryKey) ? queryKey : [queryKey]
282
- 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])
283
370
 
284
371
  const { data, isLoading } = useQuery({
285
372
  queryKey: [
@@ -453,7 +540,51 @@ export function ResourceTable<T extends Record<string, any>>({
453
540
  : actions
454
541
 
455
542
  return (
456
- <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
+ )}
457
588
  <TableToolbar
458
589
  columns={displayableColumns}
459
590
  visibleColumns={visibleColumnKeys}
@@ -466,11 +597,14 @@ export function ResourceTable<T extends Record<string, any>>({
466
597
  filters={filters as FilterRule[]}
467
598
  onFiltersChange={handleFiltersChange}
468
599
  allColumns={allColumns}
469
- title={title ?? table.title}
470
- actions={resolvedActions}
471
600
  hideSort={reorderActive}
472
601
  />
473
- <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">
474
608
  {reorderActive ? (
475
609
  <DndContext
476
610
  sensors={sensors}
@@ -481,13 +615,13 @@ export function ResourceTable<T extends Record<string, any>>({
481
615
  items={rows.map((r) => (r as any).id)}
482
616
  strategy={verticalListSortingStrategy}
483
617
  >
484
- <Table>
618
+ <Table stickyHeader roundedTop roundedBottom>
485
619
  <TableHeader>
486
- <tr>
620
+ <TableHeaderRow>
487
621
  <TableHead className="w-8" />
488
622
  {headerColumns.map((col) => (
489
623
  <TableHead key={col.key} className={col.headerClassName}>
490
- {col.label}
624
+ <ColumnLabel column={col} />
491
625
  </TableHead>
492
626
  ))}
493
627
  {rowActionsEnabled && (
@@ -495,13 +629,13 @@ export function ResourceTable<T extends Record<string, any>>({
495
629
  <span className="sr-only">{t('admin.row_actions.menu_label')}</span>
496
630
  </TableHead>
497
631
  )}
498
- </tr>
632
+ </TableHeaderRow>
499
633
  </TableHeader>
500
- <TableBody>
634
+ <TableBody className="bg-card">
501
635
  {isLoading ? (
502
- <TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
503
- {t('admin.common.loading')}
504
- </TableEmpty>
636
+ <TableSkeletonRows
637
+ colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}
638
+ />
505
639
  ) : rows.length === 0 ? (
506
640
  <TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
507
641
  <Empty className="border-0 p-0">
@@ -535,112 +669,234 @@ export function ResourceTable<T extends Record<string, any>>({
535
669
  </SortableContext>
536
670
  </DndContext>
537
671
  ) : (
538
- <Table>
539
- <TableHeader>
540
- <tr>
541
- {selectionEnabled && (
542
- <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">
543
721
  <Checkbox
544
722
  checked={allPageSelected}
545
723
  indeterminate={somePageSelected}
546
724
  onCheckedChange={togglePage}
547
725
  aria-label={t('admin.a11y.select_all_rows')}
548
726
  />
549
- </TableHead>
550
- )}
551
- {headerColumns.map((col) => (
552
- <TableHead key={col.key} className={col.headerClassName}>
553
- {col.label}
554
- </TableHead>
555
- ))}
556
- {rowActionsEnabled && (
557
- <TableHead className="w-12">
558
- <span className="sr-only">{t('admin.row_actions.menu_label')}</span>
559
- </TableHead>
560
- )}
561
- </tr>
562
- </TableHeader>
563
- <TableBody>
564
- {isLoading ? (
565
- <TableEmpty
566
- colSpan={
567
- visibleColumns.length + (selectionEnabled ? 1 : 0) + (rowActionsEnabled ? 1 : 0)
568
- }
569
- >
570
- {t('admin.common.loading')}
571
- </TableEmpty>
572
- ) : rows.length === 0 ? (
573
- <TableEmpty
574
- colSpan={
575
- visibleColumns.length + (selectionEnabled ? 1 : 0) + (rowActionsEnabled ? 1 : 0)
576
- }
577
- >
578
- <Empty className="border-0 p-0">
579
- <EmptyHeader>
580
- {table.emptyIcon && <EmptyMedia variant="icon">{table.emptyIcon}</EmptyMedia>}
581
- <EmptyTitle>{table.emptyMessage ?? t('admin.common.no_results')}</EmptyTitle>
582
- {(deferredSearch || (filters as FilterRule[]).length > 0) && (
583
- <EmptyDescription>
584
- {t('admin.common.adjust_search_or_filters')}
585
- </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',
586
769
  )}
587
- </EmptyHeader>
588
- </Empty>
589
- </TableEmpty>
590
- ) : (
591
- rows.map((row, i) => {
592
- const rowId = String((row as any).id ?? i)
593
- const isSelected = selectionEnabled && selectedIds.has(rowId)
594
- return (
595
- <TableRow
596
- key={(row as any).id ?? i}
597
- className={isSelected ? 'bg-muted/40' : undefined}
598
770
  >
599
- {selectionEnabled && (
600
- <TableCell className="w-8">
601
- <Checkbox
602
- checked={isSelected}
603
- onCheckedChange={() => toggleRow(rowId)}
604
- aria-label={t('admin.a11y.select_row')}
605
- />
606
- </TableCell>
607
- )}
608
- {visibleColumns.map((col) => (
609
- <TableCell key={col.key} className={col.className}>
610
- {col.render ? col.render(row) : String((row as any)[col.key] ?? '—')}
611
- </TableCell>
612
- ))}
613
- {rowActionsEnabled && (
614
- <TableCell className="w-12 text-right">{rowActions?.(row)}</TableCell>
615
- )}
616
- </TableRow>
617
- )
618
- })
619
- )}
620
- </TableBody>
621
- </Table>
622
- )}
623
- {meta && (
624
- <Pagination
625
- meta={meta}
626
- onPageChange={(p) => updateSearch({ page: p })}
627
- onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
628
- />
629
- )}
630
- {selectionEnabled && (
631
- <BulkActionBar
632
- selectedIds={Array.from(selectedIds)}
633
- actions={bulkActions!}
634
- anchorRef={cardRef}
635
- onClear={() => setSelectedIds(new Set())}
636
- onDone={() => {
637
- setSelectedIds(new Set())
638
- queryClient.invalidateQueries({ queryKey: queryKeyPrefix })
639
- }}
640
- />
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
+ </>
641
861
  )}
642
- </CardContent>
643
- </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
+ </>
644
900
  )
645
901
  }
646
902
 
@@ -672,7 +928,7 @@ function SortableRow<T extends Record<string, any>>({
672
928
  ref={setNodeRef}
673
929
  style={style}
674
930
  className={cn(
675
- 'group/row hover:bg-muted/60 last:*:border-b-0',
931
+ 'group/row hover:bg-accent-strong/50 last:*:border-b-0',
676
932
  isDragging && 'relative z-10 opacity-70',
677
933
  )}
678
934
  >