@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.
- package/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- 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
|
+
}
|