@innertia-solutions/innertia-nuxt 0.1.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 (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
@@ -0,0 +1,974 @@
1
+ <script setup>
2
+ import { useVueTable, getCoreRowModel } from '@tanstack/vue-table'
3
+ import {
4
+ IconSelector,
5
+ IconChevronUp,
6
+ IconChevronDown,
7
+ IconReload,
8
+ IconBolt,
9
+ IconPin,
10
+ } from '@tabler/icons-vue'
11
+
12
+ const props = defineProps({
13
+ endpoint: { type: String, required: true },
14
+ columns: { type: Array, required: true }, // [{ key, label, sortable?, filterable?, class? }]
15
+ params: { type: Object, default: () => ({}) },
16
+ checkable: { type: Boolean, default: false },
17
+ search: { type: String, default: '' },
18
+ name: { type: String, required: true },
19
+ cached: { type: Boolean, default: false },
20
+ showReloadButton: { type: Boolean, default: true },
21
+ viewMode: { type: String, default: 'table' }, // 'table' | 'grid'
22
+ gridClass: { type: String, default: 'grid grid-cols-2 lg:grid-cols-3 gap-4' },
23
+ clickRowToOpen: { type: Boolean, default: false },
24
+ previewRowId: { type: [String, Number], default: null },
25
+ previewMode: { type: Boolean, default: false },
26
+ pinnedColumns: { type: Object, default: null }, // { left?: string[], right?: string[] }
27
+ })
28
+
29
+ const emit = defineEmits(['update:search', 'row-click', 'loaded', 'page-change', 'per-page-change'])
30
+ const instance = getCurrentInstance()
31
+
32
+ // ─── API / toast ─────────────────────────────────────────────────────────────
33
+ const api = useApi()
34
+ const toast = useToast()
35
+
36
+ // ─── Local data ───────────────────────────────────────────────────────────────
37
+ const tableData = ref([])
38
+ const rowCount = ref(0)
39
+ const loading = ref(false)
40
+ const isDataFromCache = ref(false)
41
+ const lastDataLength = ref(-1)
42
+ const lastRowHeight = ref(48)
43
+ const tableBodyRef = ref(null)
44
+ const paginationBarRef = ref(null)
45
+ const skeletonRows = computed(() => {
46
+ const count = lastDataLength.value < 0 ? pagination.value.pageSize : lastDataLength.value
47
+ return Array.from({ length: count })
48
+ })
49
+ const isGridView = computed(() => props.viewMode === 'grid')
50
+
51
+ // ─── TanStack state ───────────────────────────────────────────────────────────
52
+ const pagination = ref({ pageIndex: 0, pageSize: 10 })
53
+ const sorting = ref([])
54
+ const columnFilters = ref([])
55
+ const columnVisibility = ref({})
56
+ const columnOrder = ref([])
57
+ const columnSizing = ref({})
58
+ const columnSizingInfo = ref({})
59
+ const rowSelection = ref({})
60
+ const columnPinning = ref({ left: [], right: [] })
61
+ const isCustomPerPage = ref(false)
62
+
63
+ const makeUpdater = (stateRef) => (updater) => {
64
+ stateRef.value = typeof updater === 'function' ? updater(stateRef.value) : updater
65
+ }
66
+
67
+ // ─── Column definitions ───────────────────────────────────────────────────────
68
+ const buildColumnDefs = () => {
69
+ const defs = []
70
+ if (props.checkable) {
71
+ defs.push({
72
+ id: 'select',
73
+ header: 'select',
74
+ enableSorting: false,
75
+ enableColumnFilter: false,
76
+ enableResizing: false,
77
+ size: 48,
78
+ minSize: 48,
79
+ maxSize: 48,
80
+ })
81
+ }
82
+ for (const col of props.columns) {
83
+ defs.push({
84
+ id: col.key,
85
+ accessorKey: col.key,
86
+ header: col.label,
87
+ enableSorting: col.sortable ?? false,
88
+ enableColumnFilter: col.filterable ?? false,
89
+ enableResizing: col.resizable !== false,
90
+ size: col.size ?? 200,
91
+ minSize: 60,
92
+ maxSize: 800,
93
+ meta: { class: col.class ?? '', label: col.label },
94
+ })
95
+ }
96
+ return defs
97
+ }
98
+
99
+ const columnDefs = buildColumnDefs()
100
+
101
+ const hasFilterableColumns = computed(() => props.columns.some(c => c.filterable))
102
+
103
+ // ─── TanStack table instance ──────────────────────────────────────────────────
104
+ const table = useVueTable({
105
+ get data() { return tableData.value },
106
+ get rowCount() { return rowCount.value },
107
+ columns: columnDefs,
108
+ state: {
109
+ get pagination() { return pagination.value },
110
+ get sorting() { return sorting.value },
111
+ get columnFilters() { return columnFilters.value },
112
+ get columnVisibility() { return columnVisibility.value },
113
+ get columnOrder() { return columnOrder.value },
114
+ get columnSizing() { return columnSizing.value },
115
+ get columnSizingInfo() { return columnSizingInfo.value },
116
+ get rowSelection() { return rowSelection.value },
117
+ get columnPinning() { return columnPinning.value },
118
+ },
119
+ onPaginationChange: makeUpdater(pagination),
120
+ onSortingChange: makeUpdater(sorting),
121
+ onColumnFiltersChange: makeUpdater(columnFilters),
122
+ onColumnVisibilityChange: makeUpdater(columnVisibility),
123
+ onColumnOrderChange: makeUpdater(columnOrder),
124
+ onColumnSizingChange: makeUpdater(columnSizing),
125
+ onColumnSizingInfoChange: makeUpdater(columnSizingInfo),
126
+ onRowSelectionChange: makeUpdater(rowSelection),
127
+ onColumnPinningChange: makeUpdater(columnPinning),
128
+ getCoreRowModel: getCoreRowModel(),
129
+ columnResizeMode: 'onChange',
130
+ enableColumnResizing: true,
131
+ enableColumnPinning: true,
132
+ manualPagination: true,
133
+ manualSorting: true,
134
+ manualFiltering: true,
135
+ enableMultiSort: true,
136
+ enableSortingRemoval: true,
137
+ enableRowSelection: true,
138
+ })
139
+
140
+ // ─── Column pinning helpers ───────────────────────────────────────────────────
141
+ const pinColumn = (key, position) => {
142
+ table.getColumn(key)?.pin(position)
143
+ }
144
+
145
+ const getPinnedStyles = (column, isHeader = false) => {
146
+ const pinned = column.getIsPinned()
147
+ if (!pinned) return {}
148
+ const z = isHeader ? 2 : 1
149
+ const w = column.getSize() + 'px'
150
+ // Headers always use the solid card background.
151
+ // Body cells use --row-bg, which is set to solid colors only (normal + hover) via <style scoped>.
152
+ // Selected/preview rows intentionally don't set --row-bg so sticky cells fall back to --card,
153
+ // which prevents semi-transparent tints from bleeding through the sticky cell.
154
+ const bg = isHeader ? 'var(--card, #fff)' : 'var(--row-bg, var(--card, #fff))'
155
+ const base = {
156
+ position: 'sticky',
157
+ zIndex: z,
158
+ background: bg,
159
+ width: w,
160
+ minWidth: w,
161
+ maxWidth: w,
162
+ }
163
+ // inset box-shadow: paints inside the cell so it can't be covered by adjacent cells
164
+ // and always follows the visual sticky position (unlike border or outset box-shadow)
165
+ if (pinned === 'left') return { ...base, left: column.getStart('left') + 'px', boxShadow: 'inset -1px 0 0 0 var(--card-line, #e5e7eb)' }
166
+ if (pinned === 'right') return { ...base, right: column.getAfter('right') + 'px', boxShadow: 'inset 1px 0 0 0 var(--card-line, #e5e7eb)' }
167
+ return {}
168
+ }
169
+
170
+
171
+ // Initialize pinning from prop if provided
172
+ onMounted(() => {
173
+ if (props.pinnedColumns) {
174
+ columnPinning.value = {
175
+ left: props.pinnedColumns.left ?? [],
176
+ right: props.pinnedColumns.right ?? [],
177
+ }
178
+ }
179
+ })
180
+
181
+ // Keep 'select' always first in the left pinning array.
182
+ // Fires synchronously so the colgroup/headers never render in wrong order.
183
+ watch(() => columnPinning.value.left, (left) => {
184
+ if (props.checkable && left.includes('select') && left[0] !== 'select') {
185
+ columnPinning.value = {
186
+ ...columnPinning.value,
187
+ left: ['select', ...left.filter(id => id !== 'select')],
188
+ }
189
+ }
190
+ }, { flush: 'sync' })
191
+
192
+ // ─── Fetch ────────────────────────────────────────────────────────────────────
193
+ const buildRequestParams = () => {
194
+ const { sort, ...otherParams } = props.params
195
+ return {
196
+ search: props.search,
197
+ page: pagination.value.pageIndex + 1,
198
+ perPage: pagination.value.pageSize,
199
+ sortColumns: sorting.value.map(s => ({ column: s.id, direction: s.desc ? 'desc' : 'asc' })),
200
+ columnFilters: Object.fromEntries(columnFilters.value.map(f => [f.id, f.value])),
201
+ ...otherParams,
202
+ }
203
+ }
204
+
205
+ const fetchData = async () => {
206
+ if (tableData.value.length > 0) {
207
+ lastDataLength.value = tableData.value.length
208
+ }
209
+
210
+ tableData.value = []
211
+ loading.value = true
212
+ isDataFromCache.value = false
213
+
214
+ try {
215
+ const res = await api.get(props.endpoint, { params: buildRequestParams() })
216
+ if (!res) return
217
+
218
+ tableData.value = Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : [])
219
+ const m = res.meta ?? (res.current_page !== undefined ? res : null)
220
+ rowCount.value = m?.total ?? tableData.value.length
221
+
222
+ if (props.cached) saveToCache()
223
+ emit('loaded', res)
224
+ } catch (e) {
225
+ console.error('[FullTable] Fetch error:', e)
226
+ } finally {
227
+ loading.value = false
228
+ }
229
+ }
230
+
231
+ // ─── Scheduled fetch (deduplicates concurrent state changes) ─────────────────
232
+ let fetchTimeout = null
233
+ const scheduleFetch = (delay = 0) => {
234
+ if (fetchTimeout) clearTimeout(fetchTimeout)
235
+ fetchTimeout = setTimeout(() => fetchData(), delay)
236
+ }
237
+
238
+ // ─── Cache ────────────────────────────────────────────────────────────────────
239
+ const cacheKey = computed(() => {
240
+ if (!props.cached || !props.name) return null
241
+ const base = `full_table_${props.name}`
242
+ if (!Object.keys(props.params).length) return base
243
+ try { return base + '_' + btoa(JSON.stringify(props.params)) } catch { return base }
244
+ })
245
+
246
+ const saveToCache = () => {
247
+ if (!cacheKey.value || !tableData.value.length) return
248
+ try {
249
+ sessionStorage.setItem(cacheKey.value, JSON.stringify({
250
+ data: tableData.value,
251
+ rowCount: rowCount.value,
252
+ pagination: pagination.value,
253
+ sorting: sorting.value,
254
+ columnFilters: columnFilters.value,
255
+ columnVisibility: columnVisibility.value,
256
+ columnOrder: columnOrder.value,
257
+ search: props.search,
258
+ timestamp: Date.now(),
259
+ }))
260
+ } catch (e) {
261
+ console.warn('[FullTable] Cache save error:', e)
262
+ }
263
+ }
264
+
265
+ const loadFromCache = () => {
266
+ if (!cacheKey.value) return null
267
+ try {
268
+ const raw = sessionStorage.getItem(cacheKey.value)
269
+ if (!raw) return null
270
+ const cached = JSON.parse(raw)
271
+ if (Date.now() - cached.timestamp > 10 * 60 * 1000) {
272
+ sessionStorage.removeItem(cacheKey.value)
273
+ return null
274
+ }
275
+ if (cached.search !== props.search) return null
276
+ return cached
277
+ } catch { return null }
278
+ }
279
+
280
+ const clearCache = () => {
281
+ if (cacheKey.value) sessionStorage.removeItem(cacheKey.value)
282
+ }
283
+
284
+ // ─── Restore guard (prevents watchers from triggering fetch during restore) ───
285
+ const isRestoring = ref(false)
286
+
287
+ const loadFromCacheOnMount = async () => {
288
+ const cached = loadFromCache()
289
+ if (!cached) return false
290
+
291
+ isRestoring.value = true
292
+ tableData.value = cached.data
293
+ rowCount.value = cached.rowCount
294
+ pagination.value = cached.pagination
295
+ sorting.value = cached.sorting
296
+ columnFilters.value = cached.columnFilters
297
+ columnVisibility.value = cached.columnVisibility
298
+ if (cached.columnOrder?.length) {
299
+ const order = cached.columnOrder.filter(id => id !== 'select')
300
+ if (props.checkable) order.unshift('select')
301
+ columnOrder.value = order
302
+ }
303
+ lastDataLength.value = cached.data.length
304
+ isDataFromCache.value = true
305
+
306
+ if (cached.search !== props.search) emit('update:search', cached.search)
307
+
308
+ await nextTick()
309
+ isRestoring.value = false
310
+ return true
311
+ }
312
+
313
+ // ─── Watchers ─────────────────────────────────────────────────────────────────
314
+ watch(tableData, (newData) => {
315
+ if (newData.length > 0 && tableBodyRef.value) {
316
+ const firstDataRow = Array.from(tableBodyRef.value.children).find(el => el.dataset.rowType === 'data')
317
+ if (firstDataRow) {
318
+ const h = firstDataRow.getBoundingClientRect().height
319
+ if (h > 0) lastRowHeight.value = h
320
+ lastDataLength.value = newData.length
321
+ }
322
+ }
323
+ }, { flush: 'post' })
324
+
325
+ watch(pagination, () => { if (!isRestoring.value) scheduleFetch(0) }, { deep: true })
326
+
327
+ watch(() => pagination.value.pageIndex, (val, old) => {
328
+ if (!isRestoring.value && val !== old) emit('page-change', val)
329
+ })
330
+ watch(() => pagination.value.pageSize, (val, old) => {
331
+ if (!isRestoring.value && val !== old) emit('per-page-change', val)
332
+ })
333
+ watch(sorting, () => { if (!isRestoring.value) scheduleFetch(0) }, { deep: true })
334
+ watch(columnFilters, () => { if (!isRestoring.value) scheduleFetch(300) }, { deep: true })
335
+
336
+ watch(() => props.search, () => {
337
+ if (isRestoring.value) return
338
+ pagination.value = { ...pagination.value, pageIndex: 0 }
339
+ scheduleFetch(500)
340
+ })
341
+
342
+ watch(() => props.params, () => {
343
+ if (isRestoring.value) return
344
+ pagination.value = { ...pagination.value, pageIndex: 0 }
345
+ scheduleFetch(0)
346
+ }, { deep: true })
347
+
348
+ // ─── Lifecycle ────────────────────────────────────────────────────────────────
349
+ const initColumnOrder = () => {
350
+ const ids = props.checkable ? ['select'] : []
351
+ for (const col of props.columns) ids.push(col.key)
352
+ columnOrder.value = ids
353
+ }
354
+
355
+ onMounted(async () => {
356
+ initColumnOrder()
357
+ try {
358
+ const fromCache = await loadFromCacheOnMount()
359
+ if (!fromCache) await fetchData()
360
+ } catch (e) {
361
+ console.error('[FullTable] Mount error:', e)
362
+ }
363
+ })
364
+
365
+ onBeforeUnmount(() => {
366
+ if (fetchTimeout) clearTimeout(fetchTimeout)
367
+ if (props.cached && tableData.value.length > 0) saveToCache()
368
+ })
369
+
370
+ // ─── Column settings panel ────────────────────────────────────────────────────
371
+ const setColumnOrder = (order) => { columnOrder.value = order }
372
+
373
+ // ─── Header drag reorder ──────────────────────────────────────────────────────
374
+ let draggedHeaderId = null
375
+ const dragOverHeaderId = ref(null)
376
+ const resizeHoverId = ref(null)
377
+
378
+ // ─── Column auto-size on double click ─────────────────────────────────────────
379
+ const _canvas = typeof document !== 'undefined' ? document.createElement('canvas') : null
380
+ const _ctx = _canvas?.getContext('2d')
381
+
382
+ const measureText = (text, font) => {
383
+ if (!_ctx) return 0
384
+ _ctx.font = font
385
+ return _ctx.measureText(String(text ?? '')).width
386
+ }
387
+
388
+ const autoSizeColumn = (header) => {
389
+ const colId = header.column.id
390
+ const pad = 32
391
+
392
+ const label = header.column.columnDef.meta?.label ?? header.id
393
+ let max = measureText(label, '500 12px ui-sans-serif,system-ui,sans-serif') + pad + 20
394
+
395
+ if (tableBodyRef.value) {
396
+ tableBodyRef.value.querySelectorAll(`td[data-col-id="${colId}"]`).forEach(td => {
397
+ const w = measureText(td.textContent?.trim(), '14px ui-sans-serif,system-ui,sans-serif') + pad
398
+ if (w > max) max = w
399
+ })
400
+ }
401
+
402
+ table.setColumnSizing(prev => ({ ...prev, [colId]: Math.ceil(max) }))
403
+ }
404
+
405
+ const onHeaderDragStart = (colId) => { draggedHeaderId = colId }
406
+ const onHeaderDragOver = (e, colId) => { e.preventDefault(); dragOverHeaderId.value = colId }
407
+ const onHeaderDragLeave = () => { dragOverHeaderId.value = null }
408
+ const onHeaderDrop = (colId) => {
409
+ if (!draggedHeaderId || draggedHeaderId === colId) return
410
+ if (colId === 'select') return
411
+ const order = [...columnOrder.value]
412
+ const from = order.indexOf(draggedHeaderId)
413
+ const to = order.indexOf(colId)
414
+ if (from < 0 || to < 0) return
415
+ order.splice(from, 1)
416
+ order.splice(to, 0, draggedHeaderId)
417
+ // keep 'select' pinned first
418
+ const selIdx = order.indexOf('select')
419
+ if (selIdx > 0) { order.splice(selIdx, 1); order.unshift('select') }
420
+ columnOrder.value = order
421
+ draggedHeaderId = null
422
+ dragOverHeaderId.value = null
423
+ }
424
+
425
+ // ─── Row selection ────────────────────────────────────────────────────────────
426
+ const getSelectedRows = () => {
427
+ const selected = table.getSelectedRowModel().rows.map(r => r.original)
428
+ return table.getIsAllRowsSelected()
429
+ ? { meta: { all: true }, rows: [] }
430
+ : { meta: { all: false }, rows: selected }
431
+ }
432
+
433
+ // ─── Export ───────────────────────────────────────────────────────────────────
434
+ const exportTable = async (format, exportAllPages, exportFilteredRows, selectedIds = null) => {
435
+ const { download } = useDownload()
436
+ const id = crypto.randomUUID()
437
+ toast.show({
438
+ id, type: 'process', title: 'Descargando archivo...',
439
+ progress: 0, progressLabel: 'Iniciando descarga', message: '', position: 'top-right',
440
+ })
441
+
442
+ const validFormats = ['csv', 'xlsx', 'pdf', 'json']
443
+ const params = {
444
+ ...buildRequestParams(),
445
+ exportType: validFormats.includes(format) ? format : 'csv',
446
+ exportAllPages,
447
+ exportFilteredRows,
448
+ ...(selectedIds?.length ? { selectedIds } : {}),
449
+ }
450
+
451
+ try {
452
+ const { blob, headers } = await download(props.endpoint, params, {
453
+ method: 'GET',
454
+ onProgress: (p) => toast.update(id, { progress: p, progressLabel: `Descargando... ${p}%` }),
455
+ })
456
+
457
+ let fileName = 'export.' + format
458
+ const cd = headers['content-disposition']
459
+ if (cd) {
460
+ const m = cd.match(/filename="(.+)"/)
461
+ if (m?.[1]) fileName = m[1]
462
+ }
463
+
464
+ const url = window.URL.createObjectURL(blob)
465
+ const a = document.createElement('a')
466
+ a.href = url; a.setAttribute('download', fileName)
467
+ document.body.appendChild(a); a.click(); a.remove()
468
+ window.URL.revokeObjectURL(url)
469
+
470
+ toast.update(id, { progress: 100, progressLabel: '¡Descarga completada!', message: 'El archivo se descargó correctamente.' })
471
+ setTimeout(() => toast.remove(id), 2000)
472
+ } catch (e) {
473
+ toast.update(id, { progressLabel: 'Error en la descarga', message: e.message, severity: 'danger' })
474
+ setTimeout(() => toast.remove(id), 3000)
475
+ }
476
+ }
477
+
478
+ // ─── Per-page ─────────────────────────────────────────────────────────────────
479
+ const handlePerPageChange = (val) => {
480
+ if (val === 'custom') { isCustomPerPage.value = true; return }
481
+ table.setPageSize(parseInt(val))
482
+ }
483
+
484
+ const resetPerPage = () => {
485
+ isCustomPerPage.value = false
486
+ if (![10, 25, 50, 100].includes(pagination.value.pageSize)) table.setPageSize(10)
487
+ }
488
+
489
+ // ─── Row click ────────────────────────────────────────────────────────────────
490
+ const hasRowClickListener = computed(() => !!instance?.vnode?.props?.onRowClick)
491
+ const isRowClickEnabled = computed(() => props.clickRowToOpen || props.previewMode || hasRowClickListener.value)
492
+
493
+ const interactiveSelector = [
494
+ 'a', 'button', 'input', 'select', 'textarea', 'label', 'summary',
495
+ "[role='button']", "[role='link']", "[contenteditable='true']",
496
+ '[data-row-click-ignore]', '[data-no-row-click]', '.hs-dropdown', '.dropdown',
497
+ ].join(',')
498
+
499
+ const shouldIgnoreRowClick = (e) => {
500
+ const t = e?.target
501
+ if (!(t instanceof Element)) return false
502
+ const el = t.closest(interactiveSelector)
503
+ return !!el && e.currentTarget?.contains(el)
504
+ }
505
+
506
+ const handleRowClick = (row, e) => {
507
+ if (!isRowClickEnabled.value || shouldIgnoreRowClick(e)) return
508
+ emit('row-click', row.original, e)
509
+ }
510
+
511
+ const handleRowKeydown = (row, e) => {
512
+ if (!isRowClickEnabled.value || shouldIgnoreRowClick(e)) return
513
+ if (!['Enter', ' '].includes(e.key)) return
514
+ e.preventDefault()
515
+ emit('row-click', row.original, e)
516
+ }
517
+
518
+ // Compute --row-bg for pinned (sticky) cells.
519
+ // Non-pinned cells get background from Tailwind classes on <tr>.
520
+ // Pinned cells inherit --row-bg which must be a solid opaque color (no transparency → no bleed-through).
521
+ // color-mix() blends the Tailwind tint with the card color to produce an opaque equivalent.
522
+ // For normal/hover rows the <style scoped> CSS rule handles it; selected/preview override via inline style.
523
+ const pinnedRowStyle = (row) => {
524
+ if (props.previewRowId && row.original.id === props.previewRowId) {
525
+ // !bg-indigo-50 → solid indigo-50
526
+ return { '--row-bg': 'color-mix(in srgb, #eef2ff 100%, var(--card, #fff))' }
527
+ }
528
+ if (row.getIsSelected()) {
529
+ // bg-indigo-50/40 → 40% indigo-50 blended with card
530
+ return { '--row-bg': 'color-mix(in srgb, #eef2ff 40%, var(--card, #fff))' }
531
+ }
532
+ return {}
533
+ }
534
+
535
+ // ─── Expose ───────────────────────────────────────────────────────────────────
536
+ const reloadTable = () => {
537
+ clearCache()
538
+ isDataFromCache.value = false
539
+ fetchData()
540
+ }
541
+
542
+ defineExpose({
543
+ getSelectedRows,
544
+ loading,
545
+ exportTable,
546
+ reload: reloadTable,
547
+ clearCache,
548
+ table,
549
+ setColumnOrder,
550
+ isDataFromCache,
551
+ cached: computed(() => props.cached),
552
+ paginationBarRef,
553
+ columnPinning,
554
+ pinColumn,
555
+ })
556
+ </script>
557
+
558
+ <template>
559
+ <div class="relative">
560
+
561
+ <!-- Table view -->
562
+ <div v-if="!isGridView" class="overflow-x-auto relative">
563
+ <table
564
+ class="relative divide-y divide-card-line"
565
+ :style="{ tableLayout: 'fixed', width: table.getTotalSize() + 'px', minWidth: '100%' }"
566
+ >
567
+ <colgroup>
568
+ <!-- Must use pinning order (left|center|right) — same as getHeaderGroups() and row.getVisibleCells() -->
569
+ <col
570
+ v-for="col in [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getRightVisibleLeafColumns()]"
571
+ :key="col.id"
572
+ :style="{ width: col.getSize() + 'px' }"
573
+ >
574
+ </colgroup>
575
+ <thead class="relative z-20 bg-card">
576
+ <template v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
577
+ <!-- Main header row -->
578
+ <tr class="bg-card">
579
+ <th
580
+ v-for="header in headerGroup.headers"
581
+ :key="header.id"
582
+ scope="col"
583
+ :draggable="header.id !== 'select' && resizeHoverId !== header.id && !header.column.getIsPinned()"
584
+ @dragstart="header.id !== 'select' && resizeHoverId !== header.id && !header.column.getIsPinned() && onHeaderDragStart(header.id)"
585
+ @dragover="header.id !== 'select' && onHeaderDragOver($event, header.id)"
586
+ @dragleave="onHeaderDragLeave"
587
+ @drop="header.id !== 'select' && onHeaderDrop(header.id)"
588
+ class="relative"
589
+ :class="[
590
+ header.id === 'select' ? 'text-center' : '',
591
+ dragOverHeaderId === header.id ? 'bg-indigo-50 dark:bg-indigo-900/20' : '',
592
+ header.column.getCanSort() ? 'cursor-pointer select-none' : '',
593
+ ]"
594
+ :style="getPinnedStyles(header.column, true)"
595
+ @click="header.column.getCanSort() && header.column.toggleSorting()"
596
+ >
597
+ <!-- Select all checkbox -->
598
+ <template v-if="header.id === 'select'">
599
+ <input
600
+ type="checkbox"
601
+ :checked="table.getIsAllRowsSelected()"
602
+ :indeterminate="table.getIsSomeRowsSelected()"
603
+ @change="table.getToggleAllRowsSelectedHandler()($event)"
604
+ class="mx-2 shrink-0 border-card-line rounded-sm text-blue-900 focus:ring-0 focus:ring-offset-0 dark:bg-card"
605
+ />
606
+ </template>
607
+ <!-- Regular column header -->
608
+ <template v-else>
609
+ <div
610
+ class="px-4 py-3 flex items-center gap-x-1 text-xs font-medium w-full overflow-hidden"
611
+ :class="header.column.getIsPinned() ? 'text-foreground' : 'text-muted-foreground'"
612
+ >
613
+ <IconPin v-if="header.column.getIsPinned()" class="size-3 shrink-0 text-indigo-400 dark:text-indigo-500" />
614
+ <span class="truncate">{{ header.column.columnDef.meta?.label ?? header.id }}</span>
615
+ <span v-if="header.column.getCanSort()">
616
+ <IconSelector v-if="!header.column.getIsSorted()" class="size-4 opacity-40" />
617
+ <IconChevronDown v-else-if="header.column.getIsSorted() === 'desc'" class="size-4" />
618
+ <IconChevronUp v-else class="size-4" />
619
+ </span>
620
+ </div>
621
+ <!-- Resize handle -->
622
+ <div
623
+ v-if="header.column.getCanResize()"
624
+ class="absolute right-0 top-0 h-full w-3 cursor-col-resize group/rz flex items-center justify-center select-none touch-none"
625
+ @mouseenter="resizeHoverId = header.id"
626
+ @mouseleave="resizeHoverId = null"
627
+ @mousedown.stop="header.getResizeHandler()?.($event)"
628
+ @touchstart.passive.stop="header.getResizeHandler()?.($event)"
629
+ @dblclick.stop="autoSizeColumn(header)"
630
+ @dragstart.stop.prevent
631
+ @click.stop
632
+ >
633
+ <div
634
+ class="h-4 w-px transition-all"
635
+ :class="header.column.getIsResizing()
636
+ ? 'bg-indigo-400 dark:bg-indigo-500 !w-0.5'
637
+ : 'bg-surface-1 group-hover/rz:bg-indigo-300 dark:group-hover/rz:bg-indigo-600 group-hover/rz:w-0.5'"
638
+ />
639
+ </div>
640
+ </template>
641
+ </th>
642
+ </tr>
643
+
644
+ <!-- Column filter row -->
645
+ <tr
646
+ v-if="hasFilterableColumns"
647
+ class="border-b border-card-line bg-muted/50"
648
+ >
649
+ <th
650
+ v-for="header in headerGroup.headers"
651
+ :key="'f-' + header.id"
652
+ :class="[
653
+ header.id === 'select' ? 'w-12' : 'px-3 py-1.5',
654
+ ]"
655
+ :style="getPinnedStyles(header.column, true)"
656
+ >
657
+ <input
658
+ v-if="header.column.getCanFilter()"
659
+ :value="header.column.getFilterValue() ?? ''"
660
+ @input="(e) => header.column.setFilterValue(e.target.value || undefined)"
661
+ :placeholder="`Filtrar ${header.column.columnDef.meta?.label ?? ''}...`"
662
+ class="w-full bg-card border border-card-line rounded-lg text-xs text-muted-foreground-1 px-2.5 py-1 focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-400 dark:focus:border-indigo-500 outline-none transition-all"
663
+ />
664
+ </th>
665
+ </tr>
666
+ </template>
667
+ </thead>
668
+
669
+ <tbody ref="tableBodyRef" class="divide-y divide-card-line">
670
+ <!-- Loading skeleton rows -->
671
+ <tr
672
+ v-if="loading"
673
+ v-for="(_, i) in skeletonRows"
674
+ :key="'sk-' + i"
675
+ class="animate-pulse bg-card"
676
+ >
677
+ <td
678
+ v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
679
+ :key="'skc-' + header.id"
680
+ :class="[
681
+ header.id === 'select' ? 'text-center w-12' : 'px-4 overflow-hidden',
682
+ ]"
683
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
684
+ >
685
+ <div v-if="header.id === 'select'" class="w-4 h-4 bg-surface-1 rounded mx-auto"></div>
686
+ <div v-else class="h-4 w-[50%] rounded bg-surface-1"></div>
687
+ </td>
688
+ </tr>
689
+
690
+ <!-- Loading filler rows: pad to pageSize so table height doesn't change -->
691
+ <tr
692
+ v-if="loading && skeletonRows.length < pagination.pageSize"
693
+ v-for="i in (pagination.pageSize - skeletonRows.length)"
694
+ :key="'lf-' + i"
695
+ class="bg-card"
696
+ >
697
+ <td
698
+ v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
699
+ :key="'lfc-' + header.id"
700
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
701
+ />
702
+ </tr>
703
+
704
+ <!-- Empty filler rows: maintain table height when no results -->
705
+ <tr
706
+ v-if="!loading && tableData.length === 0"
707
+ v-for="i in pagination.pageSize"
708
+ :key="'esk-' + i"
709
+ class="bg-card"
710
+ >
711
+ <td
712
+ v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
713
+ :key="'eskc-' + header.id"
714
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
715
+ />
716
+ </tr>
717
+
718
+ <!-- Data rows -->
719
+ <tr
720
+ v-else
721
+ v-for="row in table.getRowModel().rows"
722
+ :key="row.id"
723
+ data-row-type="data"
724
+ @click="(e) => handleRowClick(row, e)"
725
+ @keydown="(e) => handleRowKeydown(row, e)"
726
+ :tabindex="isRowClickEnabled ? 0 : undefined"
727
+ class="bg-card hover:bg-layer-hover transition-colors"
728
+ :class="{
729
+ 'cursor-pointer': isRowClickEnabled,
730
+ 'bg-indigo-50/40 dark:bg-indigo-900/10 hover:bg-indigo-50/60': row.getIsSelected(),
731
+ '!bg-indigo-50 dark:!bg-indigo-900/20 ring-1 ring-inset ring-indigo-200 dark:ring-indigo-700': previewRowId && row.original.id === previewRowId,
732
+ }"
733
+ :style="pinnedRowStyle(row)"
734
+ >
735
+ <td
736
+ v-for="cell in row.getVisibleCells()"
737
+ :key="cell.id"
738
+ :data-col-id="cell.column.id"
739
+ :class="[
740
+ cell.column.id === 'select'
741
+ ? 'text-center w-12 overflow-hidden'
742
+ : 'px-4 py-3 text-sm text-muted-foreground-1 overflow-hidden',
743
+ cell.column.id !== 'select' ? cell.column.columnDef.meta?.class ?? '' : '',
744
+ ]"
745
+ :style="getPinnedStyles(cell.column)"
746
+ >
747
+ <!-- Select checkbox -->
748
+ <template v-if="cell.column.id === 'select'">
749
+ <div @click.stop>
750
+ <input
751
+ type="checkbox"
752
+ :checked="row.getIsSelected()"
753
+ :disabled="!row.getCanSelect()"
754
+ @change="row.getToggleSelectedHandler()($event)"
755
+ class="rounded border-card-line focus:ring-0 focus:ring-offset-0 dark:bg-card"
756
+ />
757
+ </div>
758
+ </template>
759
+ <!-- Data cell with slot -->
760
+ <template v-else>
761
+ <slot :name="cell.column.id" :row="row.original" :value="cell.getValue()">
762
+ {{ cell.getValue() }}
763
+ </slot>
764
+ </template>
765
+ </td>
766
+ </tr>
767
+
768
+ <!-- Filler rows: pad table to full page height when data < perPage -->
769
+ <tr
770
+ v-if="!loading && tableData.length > 0 && tableData.length < pagination.pageSize"
771
+ v-for="i in (pagination.pageSize - tableData.length)"
772
+ :key="'fill-' + i"
773
+ class="bg-card"
774
+ >
775
+ <td
776
+ v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
777
+ :key="'fillc-' + header.id"
778
+ :style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
779
+ />
780
+ </tr>
781
+ </tbody>
782
+ </table>
783
+
784
+ <!-- Empty state overlays -->
785
+ <div
786
+ v-if="!loading && tableData.length === 0 && !search && !columnFilters.length"
787
+ class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-card/60 rounded-xl"
788
+ >
789
+ <slot name="empty">
790
+ <p class="text-muted-foreground text-lg font-medium italic">No hay registros</p>
791
+ </slot>
792
+ </div>
793
+
794
+ <div
795
+ v-if="!loading && tableData.length === 0 && (search || columnFilters.length)"
796
+ class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-card/60 rounded-xl"
797
+ >
798
+ <slot name="empty-search">
799
+ <p class="text-muted-foreground text-lg font-medium italic">No hay registros en la búsqueda</p>
800
+ </slot>
801
+ </div>
802
+ </div>
803
+
804
+ <!-- Grid view -->
805
+ <div v-else class="relative">
806
+ <div v-if="loading" :class="gridClass">
807
+ <div v-for="(_, i) in skeletonRows" :key="'gsk-' + i" class="animate-pulse">
808
+ <slot name="grid-skeleton">
809
+ <div class="bg-card rounded-lg border border-card-line p-4">
810
+ <div class="space-y-3">
811
+ <div class="h-4 bg-surface-1 rounded w-3/4"></div>
812
+ <div class="h-4 bg-surface-1 rounded w-1/2"></div>
813
+ <div class="h-6 bg-surface-1 rounded w-1/4"></div>
814
+ </div>
815
+ </div>
816
+ </slot>
817
+ </div>
818
+ </div>
819
+
820
+ <div v-else-if="tableData.length > 0" :class="gridClass">
821
+ <slot
822
+ name="grid-item"
823
+ v-for="row in table.getRowModel().rows"
824
+ :key="row.id"
825
+ :row="row.original"
826
+ :tanstack-row="row"
827
+ :is-selected="row.getIsSelected()"
828
+ :checkable="checkable"
829
+ :toggle-row="() => row.toggleSelected()"
830
+ >
831
+ <div class="bg-card rounded-lg border border-card-line p-4 hover:shadow-md transition-shadow relative"
832
+ :class="{ 'ring-2 ring-indigo-400 dark:ring-indigo-600': row.getIsSelected() }">
833
+ <div v-if="checkable" class="absolute top-2 left-2 z-10">
834
+ <input type="checkbox" :checked="row.getIsSelected()" @change="row.toggleSelected()"
835
+ class="rounded border-card-line dark:bg-card" />
836
+ </div>
837
+ <div class="space-y-2" :class="{ 'pt-6': checkable }">
838
+ <div v-for="cell in row.getVisibleCells().filter(c => c.column.id !== 'select')" :key="cell.id" class="flex justify-between">
839
+ <span class="text-sm text-muted-foreground">{{ cell.column.columnDef.meta?.label ?? cell.column.id }}:</span>
840
+ <span class="text-sm text-foreground">
841
+ <slot :name="cell.column.id" :row="row.original" :value="cell.getValue()">{{ cell.getValue() }}</slot>
842
+ </span>
843
+ </div>
844
+ </div>
845
+ </div>
846
+ </slot>
847
+ </div>
848
+
849
+ <div v-else class="flex items-center justify-center py-12">
850
+ <slot v-if="!search && !columnFilters.length" name="empty">
851
+ <p class="text-muted-foreground text-lg">No hay registros</p>
852
+ </slot>
853
+ <slot v-else name="empty-search">
854
+ <p class="text-muted-foreground text-lg">No hay registros en la búsqueda</p>
855
+ </slot>
856
+ </div>
857
+ </div>
858
+
859
+ <!-- Pagination & controls bar -->
860
+ <div ref="paginationBarRef" class="flex flex-col sm:flex-row items-center justify-between gap-y-4 sm:gap-y-0 px-4 py-3 border-t border-card-line">
861
+ <!-- Left: reload, total, cache, columns button -->
862
+ <div class="flex items-center gap-x-4 flex-wrap gap-y-2">
863
+ <!-- Reload button -->
864
+ <div v-if="showReloadButton" class="flex items-center gap-x-2">
865
+ <IconReload
866
+ v-if="!loading"
867
+ class="size-4 cursor-pointer text-muted-foreground hover:text-muted-foreground-1 transition-colors"
868
+ @click="reloadTable"
869
+ />
870
+ <div v-else>
871
+ <svg class="animate-spin size-4 text-muted-foreground-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
872
+ <circle cx="12" cy="12" r="10" opacity=".25" />
873
+ <path d="M22 12a10 10 0 0 1-10 10" />
874
+ </svg>
875
+ </div>
876
+ </div>
877
+
878
+ <!-- Total records -->
879
+ <p class="text-sm text-foreground font-medium">{{ rowCount }} registros</p>
880
+
881
+ <!-- Cache badge -->
882
+ <div v-if="isDataFromCache && cached" class="group relative flex items-center">
883
+ <div class="flex items-center gap-x-1.5 py-1 px-2.5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 rounded-lg cursor-help hover:bg-emerald-500/20 transition-colors">
884
+ <IconBolt class="size-3.5 fill-current" />
885
+ <span class="text-[10px] font-bold uppercase tracking-wider">Instant</span>
886
+ </div>
887
+ <div class="absolute bottom-full mb-2 left-0 hidden group-hover:block w-48 p-2.5 bg-slate-900 text-white text-[11px] leading-relaxed rounded-xl shadow-2xl z-50">
888
+ <div class="font-bold mb-1 flex items-center gap-x-1.5 text-emerald-400">
889
+ <IconBolt class="size-3" /> Datos en Caché
890
+ </div>
891
+ Los datos se cargaron instantáneamente desde la memoria local. Actualice para sincronizar con el servidor.
892
+ <div class="absolute top-full left-4 -mt-1 border-4 border-transparent border-t-slate-900"></div>
893
+ </div>
894
+ </div>
895
+
896
+ </div>
897
+
898
+ <!-- Right: per-page + pagination -->
899
+ <div class="flex items-center gap-x-8">
900
+ <!-- Per page selector -->
901
+ <div class="flex items-center gap-x-2">
902
+ <label class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Filas:</label>
903
+ <select
904
+ v-if="!isCustomPerPage"
905
+ :value="pagination.pageSize"
906
+ @change="(e) => handlePerPageChange(e.target.value)"
907
+ class="bg-surface border-none text-[11px] font-bold text-muted-foreground-1 rounded-lg focus:ring-0 cursor-pointer py-1 pl-2 pr-8"
908
+ >
909
+ <option :value="10">10</option>
910
+ <option :value="25">25</option>
911
+ <option :value="50">50</option>
912
+ <option :value="100">100</option>
913
+ <option value="custom">Otro...</option>
914
+ </select>
915
+ <div v-else class="flex items-center gap-x-1">
916
+ <input
917
+ type="number"
918
+ :value="pagination.pageSize"
919
+ @change="(e) => table.setPageSize(parseInt(e.target.value) || 10)"
920
+ min="1" max="500"
921
+ class="w-14 bg-surface border-none text-[11px] font-bold text-muted-foreground-1 rounded-lg focus:ring-2 focus:ring-indigo-500/20 py-1 px-2"
922
+ />
923
+ <button @click="resetPerPage" class="text-[10px] text-indigo-500 font-bold hover:underline">Volver</button>
924
+ </div>
925
+ </div>
926
+
927
+ <!-- Pagination nav -->
928
+ <nav class="flex justify-end items-center gap-x-1" aria-label="Pagination">
929
+ <button
930
+ type="button"
931
+ class="size-8 flex items-center justify-center rounded-lg text-foreground hover:bg-muted-hover disabled:opacity-30"
932
+ :disabled="!table.getCanPreviousPage()"
933
+ @click="table.previousPage()"
934
+ >
935
+ <svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
936
+ <path d="m15 18-6-6 6-6" />
937
+ </svg>
938
+ </button>
939
+ <div class="flex items-center gap-x-1 mx-2">
940
+ <span class="size-8 flex items-center justify-center text-xs font-bold rounded-lg bg-surface text-foreground">
941
+ {{ pagination.pageIndex + 1 }}
942
+ </span>
943
+ <span class="text-[10px] font-bold text-muted-foreground uppercase mx-1">de</span>
944
+ <span class="text-[10px] font-bold text-muted-foreground">{{ table.getPageCount() }}</span>
945
+ </div>
946
+ <button
947
+ type="button"
948
+ class="size-8 flex items-center justify-center rounded-lg text-foreground hover:bg-muted-hover disabled:opacity-30"
949
+ :disabled="!table.getCanNextPage()"
950
+ @click="table.nextPage()"
951
+ >
952
+ <svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
953
+ <path d="m9 18 6-6-6-6" />
954
+ </svg>
955
+ </button>
956
+ </nav>
957
+ </div>
958
+ </div>
959
+ </div>
960
+ </template>
961
+
962
+ <style scoped>
963
+ /* --row-bg drives the background of sticky (pinned) body cells.
964
+ Only solid, opaque values here — semi-transparent tints for selected/preview
965
+ rows intentionally do NOT override --row-bg, so pinned cells stay opaque
966
+ and text from scrolling content can't bleed through. */
967
+ tbody tr {
968
+ --row-bg: var(--card, #fff);
969
+ }
970
+ tbody tr:hover {
971
+ --row-bg: var(--layer-hover, #f8fafc);
972
+ }
973
+ </style>
974
+