@spree/dashboard-core 0.1.0

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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,652 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors,
9
+ } from '@dnd-kit/core'
10
+ import {
11
+ arrayMove,
12
+ SortableContext,
13
+ sortableKeyboardCoordinates,
14
+ useSortable,
15
+ verticalListSortingStrategy,
16
+ } from '@dnd-kit/sortable'
17
+ import { CSS } from '@dnd-kit/utilities'
18
+ import {
19
+ Card,
20
+ CardContent,
21
+ Checkbox,
22
+ cn,
23
+ DragHandle,
24
+ Empty,
25
+ EmptyDescription,
26
+ EmptyHeader,
27
+ EmptyMedia,
28
+ EmptyTitle,
29
+ Pagination,
30
+ type PaginationMeta,
31
+ Table,
32
+ TableBody,
33
+ TableCell,
34
+ TableEmpty,
35
+ TableHead,
36
+ TableHeader,
37
+ TableRow,
38
+ } from '@spree/dashboard-ui'
39
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
40
+ import { useNavigate } from '@tanstack/react-router'
41
+ import {
42
+ type CSSProperties,
43
+ type ReactNode,
44
+ useCallback,
45
+ useDeferredValue,
46
+ useEffect,
47
+ useMemo,
48
+ useRef,
49
+ useState,
50
+ } from 'react'
51
+ import { useTranslation } from 'react-i18next'
52
+ import { z } from 'zod/v4'
53
+ import { useAuth } from '../hooks/use-auth'
54
+ import { filtersToRansack } from '../lib/filters-to-ransack'
55
+ import { withStoreScope } from '../lib/query-keys'
56
+ import {
57
+ type ColumnDef,
58
+ type FilterRule,
59
+ getDefaultColumnKeys,
60
+ getDisplayableColumns,
61
+ getTable,
62
+ type SortOption,
63
+ } from '../lib/table-registry'
64
+ import { useStore } from '../providers/store-provider'
65
+ import { type BulkAction, BulkActionBar } from './bulk-action-bar'
66
+ import { TableToolbar } from './table-toolbar'
67
+
68
+ // ============================================================================
69
+ // Search schema — shared by all resource table routes
70
+ // ============================================================================
71
+
72
+ const filterSchema = z.object({
73
+ id: z.string(),
74
+ field: z.string(),
75
+ operator: z.string(),
76
+ value: z.string(),
77
+ })
78
+
79
+ export const resourceSearchSchema = z.object({
80
+ page: z.coerce.number().optional().default(1),
81
+ limit: z.coerce.number().optional(),
82
+ sort: z.string().optional(),
83
+ dir: z.enum(['asc', 'desc']).optional(),
84
+ search: z.string().optional(),
85
+ filters: z
86
+ .preprocess((val) => {
87
+ if (typeof val === 'string') {
88
+ try {
89
+ return JSON.parse(val)
90
+ } catch {
91
+ return []
92
+ }
93
+ }
94
+ return val ?? []
95
+ }, z.array(filterSchema))
96
+ .optional()
97
+ .default([]),
98
+ columns: z
99
+ .preprocess((val) => {
100
+ if (typeof val === 'string') return val.split(',')
101
+ return val
102
+ }, z.array(z.string()))
103
+ .optional(),
104
+ })
105
+
106
+ export type ResourceSearch = z.infer<typeof resourceSearchSchema>
107
+
108
+ // ============================================================================
109
+ // Props
110
+ // ============================================================================
111
+
112
+ /**
113
+ * Per-render context handed to the `actions` render-prop. Gives toolbar
114
+ * actions enough state to act on the *current* table view — needed for
115
+ * features like "export filtered records" that have to mirror what the user
116
+ * is looking at.
117
+ */
118
+ export interface ResourceActionsContext {
119
+ filters: FilterRule[]
120
+ search: string
121
+ searchParam: string
122
+ /** All columns (incl. filter-only) — needed by `filtersToRansack`. */
123
+ columns: ColumnDef[]
124
+ /** Total record count for the active filter, or `undefined` while loading. */
125
+ totalCount: number | undefined
126
+ }
127
+
128
+ interface ResourceTableProps<T> {
129
+ /** Registry key (e.g., 'products') */
130
+ tableKey: string
131
+ /**
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
135
+ * shorthand for `[string]`; pass an array to add further scope (e.g.
136
+ * `[webhookDeliveriesTableKey(endpointId)]`).
137
+ */
138
+ queryKey: string | readonly unknown[]
139
+ /** Function that calls the SDK to fetch data */
140
+ queryFn: (params: Record<string, unknown>) => Promise<{ data: T[]; meta: PaginationMeta }>
141
+ /** Current search params from the route */
142
+ searchParams: ResourceSearch
143
+ /** Title displayed in the toolbar header. Overrides the table definition's title. */
144
+ title?: string
145
+ /** Default params always sent with every request (e.g., { complete: 1 } for orders) */
146
+ defaultParams?: Record<string, unknown>
147
+ /**
148
+ * Actions to render in the toolbar (e.g., "Add Product" button). Either a
149
+ * static node, or a function that receives the current table state — use
150
+ * the function form for actions that depend on filters/search (e.g. CSV
151
+ * export of the active view).
152
+ */
153
+ actions?: ReactNode | ((ctx: ResourceActionsContext) => ReactNode)
154
+ /**
155
+ * When set, rows can be drag-reordered. The handler receives the moved
156
+ * row's id and its new 1-indexed position; the server is expected to
157
+ * handle the rest via `acts_as_list` (other rows shift around). Drag
158
+ * is only active when the table is sorted ascending by `positionField`
159
+ * (default: `position`) — otherwise reordering would be meaningless.
160
+ */
161
+ reorder?: ReorderConfig<T>
162
+ /**
163
+ * Bulk operations available against selected rows. When present, the
164
+ * table renders a leading checkbox column and a sticky action bar
165
+ * appears once any row is selected. Mutually exclusive with `reorder`
166
+ * — the leading column is already taken by the drag handle.
167
+ */
168
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
169
+ bulkActions?: BulkAction<any>[]
170
+ /**
171
+ * Per-row action menu. When set, a trailing cell renders whatever the
172
+ * function returns (typically a `<RowActions>` kebab). Works alongside
173
+ * `reorder` — the drag handle sits on the leading edge, the actions
174
+ * menu on the trailing edge.
175
+ */
176
+ rowActions?: (row: T) => ReactNode
177
+ }
178
+
179
+ export interface ReorderConfig<T> {
180
+ onReorder: (id: string, position: number, row: T) => Promise<unknown> | unknown
181
+ /** Defaults to `'position'`. Override for resources that sort on a different column. */
182
+ positionField?: string
183
+ }
184
+
185
+ // ============================================================================
186
+ // Component
187
+ // ============================================================================
188
+
189
+ export function ResourceTable<T extends Record<string, any>>({
190
+ tableKey,
191
+ queryKey,
192
+ queryFn,
193
+ searchParams,
194
+ title,
195
+ defaultParams,
196
+ actions,
197
+ reorder,
198
+ bulkActions,
199
+ rowActions,
200
+ }: ResourceTableProps<T>) {
201
+ const table = getTable<T>(tableKey)
202
+ const { t } = useTranslation()
203
+ const { token } = useAuth()
204
+ const navigate = useNavigate()
205
+ const queryClient = useQueryClient()
206
+
207
+ const selectionEnabled = !!bulkActions?.length && !reorder
208
+ const rowActionsEnabled = !!rowActions
209
+ 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)
213
+
214
+ const {
215
+ page,
216
+ limit,
217
+ sort: urlSort,
218
+ dir: urlDir,
219
+ search,
220
+ filters,
221
+ columns: urlColumns,
222
+ } = searchParams
223
+
224
+ // When the table is reorderable, sort is locked to the position field
225
+ // ascending — anything else makes drag-and-drop meaningless. Otherwise
226
+ // fall through to the URL params, then the table's declared default,
227
+ // then a sensible fallback.
228
+ const positionField = reorder?.positionField ?? 'position'
229
+ const defaultSort = table.defaultSort ?? {
230
+ field: 'updated_at',
231
+ direction: 'desc' as const,
232
+ }
233
+ const sort = reorder ? positionField : (urlSort ?? defaultSort.field)
234
+ const dir: 'asc' | 'desc' = reorder ? 'asc' : (urlDir ?? defaultSort.direction)
235
+
236
+ const [searchInput, setSearchInput] = useState(search ?? '')
237
+ const deferredSearch = useDeferredValue(searchInput)
238
+
239
+ const displayableColumns = getDisplayableColumns(table)
240
+ const defaultColumnKeys = getDefaultColumnKeys(table)
241
+ const visibleColumnKeys = urlColumns ?? defaultColumnKeys
242
+
243
+ const visibleColumns = displayableColumns.filter((c) => visibleColumnKeys.includes(c.key))
244
+
245
+ // Build API params
246
+ const sortString = dir === 'desc' ? `-${sort}` : sort
247
+
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.
251
+ // Goes through +withStoreScope+ so the slot ordering matches +useResourceKey+
252
+ // and +useResourceMutation+'s invalidations (storeId at position 1).
253
+ const userPrefix = Array.isArray(queryKey) ? queryKey : [queryKey]
254
+ const queryKeyPrefix = withStoreScope(userPrefix, storeId)
255
+
256
+ const { data, isLoading } = useQuery({
257
+ queryKey: [
258
+ ...queryKeyPrefix,
259
+ { page, limit, sort: sortString, search: deferredSearch, filters },
260
+ ],
261
+ queryFn: () => {
262
+ const params: Record<string, unknown> = {
263
+ page,
264
+ sort: sortString,
265
+ ...defaultParams,
266
+ }
267
+
268
+ if (limit) {
269
+ params.limit = limit
270
+ }
271
+
272
+ if (deferredSearch) {
273
+ const searchParam = table.searchParam ?? 'name_cont'
274
+ params[searchParam] = deferredSearch
275
+ }
276
+
277
+ Object.assign(params, filtersToRansack(filters as FilterRule[], table.columns))
278
+
279
+ return queryFn(params)
280
+ },
281
+ enabled: !!token,
282
+ })
283
+
284
+ const fetchedRows = data?.data ?? []
285
+ const meta = data?.meta
286
+
287
+ // Selection is per-page and ephemeral. Wipe it whenever the user pages,
288
+ // re-sorts, filters, or searches — keeping IDs from prior pages would let
289
+ // them silently apply a bulk op to rows the user can no longer see.
290
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset on visible-set change
291
+ useEffect(() => {
292
+ if (selectionEnabled) {
293
+ setSelectedIds((prev) => (prev.size === 0 ? prev : new Set()))
294
+ }
295
+ }, [page, sortString, deferredSearch, filters, selectionEnabled])
296
+
297
+ const reorderActive = !!reorder
298
+
299
+ // Mirror the fetched rows in local state while reordering so we can swap
300
+ // them optimistically on drop. Only allocated when reorder is active —
301
+ // otherwise the table reads `fetchedRows` straight through. The mirror
302
+ // tracks the upstream cache by identity; TanStack returns a new array
303
+ // reference on every refetch, so this useEffect runs whenever data updates.
304
+ const [localRows, setLocalRows] = useState<T[]>(fetchedRows)
305
+ useEffect(() => {
306
+ if (reorderActive) setLocalRows(fetchedRows)
307
+ }, [fetchedRows, reorderActive])
308
+
309
+ const rows = reorderActive ? localRows : fetchedRows
310
+
311
+ // dnd-kit sensors: pointer for mouse/touch (5px activation distance keeps
312
+ // brief clicks on row content from starting a drag), keyboard for a11y.
313
+ const sensors = useSensors(
314
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
315
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
316
+ )
317
+
318
+ const handleDragEnd = useCallback(
319
+ async (event: DragEndEvent) => {
320
+ const { active, over } = event
321
+ if (!over || active.id === over.id || !reorder) return
322
+
323
+ const fromIndex = localRows.findIndex((r) => r.id === active.id)
324
+ const toIndex = localRows.findIndex((r) => r.id === over.id)
325
+ if (fromIndex === -1 || toIndex === -1) return
326
+
327
+ const moved = localRows[fromIndex]
328
+ const next = arrayMove(localRows, fromIndex, toIndex)
329
+ setLocalRows(next)
330
+
331
+ try {
332
+ // 1-indexed position; acts_as_list shifts the rest server-side.
333
+ await reorder.onReorder(String(active.id), toIndex + 1, moved)
334
+ } catch {
335
+ // Rollback on failure — the next refetch will reconcile anyway.
336
+ setLocalRows(localRows)
337
+ }
338
+ },
339
+ [localRows, reorder],
340
+ )
341
+
342
+ // Navigation helpers
343
+ function updateSearch(updates: Record<string, unknown>) {
344
+ navigate({
345
+ search: (prev: Record<string, unknown>) => ({ ...prev, ...updates }) as never,
346
+ })
347
+ }
348
+
349
+ function handleSearchChange(value: string) {
350
+ setSearchInput(value)
351
+ updateSearch({ search: value || undefined, page: 1 })
352
+ }
353
+
354
+ function handleSortChange(s: SortOption) {
355
+ updateSearch({ sort: s.field, dir: s.direction, page: 1 })
356
+ }
357
+
358
+ function handleFiltersChange(f: FilterRule[]) {
359
+ updateSearch({
360
+ filters: f.length > 0 ? JSON.stringify(f) : undefined,
361
+ page: 1,
362
+ })
363
+ }
364
+
365
+ function handleColumnsChange(cols: string[]) {
366
+ const isDefault =
367
+ cols.length === defaultColumnKeys.length && cols.every((c) => defaultColumnKeys.includes(c))
368
+ updateSearch({ columns: isDefault ? undefined : cols.join(',') })
369
+ }
370
+
371
+ const pageIds = useMemo(() => (data?.data ?? []).map((r) => String((r as any).id)), [data])
372
+ const allPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.has(id))
373
+ const somePageSelected = pageIds.some((id) => selectedIds.has(id)) && !allPageSelected
374
+
375
+ function toggleRow(id: string) {
376
+ setSelectedIds((prev) => {
377
+ const next = new Set(prev)
378
+ if (next.has(id)) next.delete(id)
379
+ else next.add(id)
380
+ return next
381
+ })
382
+ }
383
+
384
+ function togglePage() {
385
+ setSelectedIds((prev) => {
386
+ if (allPageSelected) {
387
+ const next = new Set(prev)
388
+ for (const id of pageIds) next.delete(id)
389
+ return next
390
+ }
391
+ const next = new Set(prev)
392
+ for (const id of pageIds) next.add(id)
393
+ return next
394
+ })
395
+ }
396
+
397
+ // Header columns for price-like right-aligned columns
398
+ const headerColumns = visibleColumns.map((col) => {
399
+ const isRightAligned = col.className?.includes('text-right')
400
+ return {
401
+ ...col,
402
+ headerClassName: isRightAligned ? 'text-right' : undefined,
403
+ }
404
+ })
405
+
406
+ const resolvedActions =
407
+ typeof actions === 'function'
408
+ ? actions({
409
+ filters: filters as FilterRule[],
410
+ search: deferredSearch,
411
+ searchParam: table.searchParam ?? 'name_cont',
412
+ columns: table.columns,
413
+ totalCount: meta?.count,
414
+ })
415
+ : actions
416
+
417
+ return (
418
+ <Card ref={cardRef} className="rounded-xl">
419
+ <TableToolbar
420
+ columns={displayableColumns}
421
+ visibleColumns={visibleColumnKeys}
422
+ onVisibleColumnsChange={handleColumnsChange}
423
+ search={searchInput}
424
+ onSearchChange={handleSearchChange}
425
+ searchPlaceholder={table.searchPlaceholder ?? t('admin.common.search_placeholder')}
426
+ sort={{ field: sort, direction: dir }}
427
+ onSortChange={handleSortChange}
428
+ filters={filters as FilterRule[]}
429
+ onFiltersChange={handleFiltersChange}
430
+ allColumns={table.columns}
431
+ title={title ?? table.title}
432
+ actions={resolvedActions}
433
+ hideSort={reorderActive}
434
+ />
435
+ <CardContent className="p-0">
436
+ {reorderActive ? (
437
+ <DndContext
438
+ sensors={sensors}
439
+ collisionDetection={closestCenter}
440
+ onDragEnd={handleDragEnd}
441
+ >
442
+ <SortableContext
443
+ items={rows.map((r) => (r as any).id)}
444
+ strategy={verticalListSortingStrategy}
445
+ >
446
+ <Table>
447
+ <TableHeader>
448
+ <tr>
449
+ <TableHead className="w-8" />
450
+ {headerColumns.map((col) => (
451
+ <TableHead key={col.key} className={col.headerClassName}>
452
+ {col.label}
453
+ </TableHead>
454
+ ))}
455
+ {rowActionsEnabled && (
456
+ <TableHead className="w-12">
457
+ <span className="sr-only">{t('admin.row_actions.menu_label')}</span>
458
+ </TableHead>
459
+ )}
460
+ </tr>
461
+ </TableHeader>
462
+ <TableBody>
463
+ {isLoading ? (
464
+ <TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
465
+ {t('admin.common.loading')}
466
+ </TableEmpty>
467
+ ) : rows.length === 0 ? (
468
+ <TableEmpty colSpan={visibleColumns.length + 1 + (rowActionsEnabled ? 1 : 0)}>
469
+ <Empty className="border-0 p-0">
470
+ <EmptyHeader>
471
+ {table.emptyIcon && (
472
+ <EmptyMedia variant="icon">{table.emptyIcon}</EmptyMedia>
473
+ )}
474
+ <EmptyTitle>
475
+ {table.emptyMessage ?? t('admin.common.no_results')}
476
+ </EmptyTitle>
477
+ {(deferredSearch || (filters as FilterRule[]).length > 0) && (
478
+ <EmptyDescription>
479
+ {t('admin.common.adjust_search_or_filters')}
480
+ </EmptyDescription>
481
+ )}
482
+ </EmptyHeader>
483
+ </Empty>
484
+ </TableEmpty>
485
+ ) : (
486
+ rows.map((row) => (
487
+ <SortableRow
488
+ key={(row as any).id}
489
+ row={row}
490
+ columns={visibleColumns}
491
+ rowActions={rowActions}
492
+ />
493
+ ))
494
+ )}
495
+ </TableBody>
496
+ </Table>
497
+ </SortableContext>
498
+ </DndContext>
499
+ ) : (
500
+ <Table>
501
+ <TableHeader>
502
+ <tr>
503
+ {selectionEnabled && (
504
+ <TableHead className="w-8">
505
+ <Checkbox
506
+ checked={allPageSelected}
507
+ indeterminate={somePageSelected}
508
+ onCheckedChange={togglePage}
509
+ aria-label={t('admin.a11y.select_all_rows')}
510
+ />
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>
548
+ )}
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
+ >
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
+ />
603
+ )}
604
+ </CardContent>
605
+ </Card>
606
+ )
607
+ }
608
+
609
+ // ============================================================================
610
+ // SortableRow — internal row that wires dnd-kit's listeners to a leading
611
+ // drag-handle cell. Mirrors the styling of <TableRow> from data-table.tsx.
612
+ // ============================================================================
613
+
614
+ function SortableRow<T extends Record<string, any>>({
615
+ row,
616
+ columns,
617
+ rowActions,
618
+ }: {
619
+ row: T
620
+ columns: ColumnDef[]
621
+ rowActions?: (row: T) => ReactNode
622
+ }) {
623
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
624
+ id: row.id,
625
+ })
626
+
627
+ const style: CSSProperties = {
628
+ transform: CSS.Transform.toString(transform),
629
+ transition,
630
+ }
631
+
632
+ return (
633
+ <tr
634
+ ref={setNodeRef}
635
+ style={style}
636
+ className={cn(
637
+ 'group/row hover:bg-muted/60 last:*:border-b-0',
638
+ isDragging && 'relative z-10 opacity-70',
639
+ )}
640
+ >
641
+ <TableCell className="w-8 touch-none">
642
+ <DragHandle attributes={attributes} listeners={listeners} />
643
+ </TableCell>
644
+ {columns.map((col) => (
645
+ <TableCell key={col.key} className={col.className}>
646
+ {col.render ? col.render(row) : String((row as any)[col.key] ?? '—')}
647
+ </TableCell>
648
+ ))}
649
+ {rowActions && <TableCell className="w-12 text-right">{rowActions(row)}</TableCell>}
650
+ </tr>
651
+ )
652
+ }