@innertia-solutions/nuxt-theme-spark 0.1.15 → 0.1.17

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.
@@ -0,0 +1,826 @@
1
+ <script setup>
2
+ import { useVueTable, getCoreRowModel } from '@tanstack/vue-table'
3
+ import {
4
+ IconArrowsSort,
5
+ IconSortAscendingSmallBig,
6
+ IconSortDescendingSmallBig,
7
+ IconReload,
8
+ IconBolt,
9
+ IconLayoutColumns,
10
+ IconGripVertical,
11
+ } from '@tabler/icons-vue'
12
+
13
+ const props = defineProps({
14
+ endpoint: { type: String, required: true },
15
+ columns: { type: Array, required: true }, // [{ key, label, sortable?, filterable?, class? }]
16
+ params: { type: Object, default: () => ({}) },
17
+ checkable: { type: Boolean, default: false },
18
+ search: { type: String, default: '' },
19
+ name: { type: String, required: true },
20
+ cached: { type: Boolean, default: false },
21
+ showReloadButton: { type: Boolean, default: true },
22
+ viewMode: { type: String, default: 'table' }, // 'table' | 'grid'
23
+ gridClass: { type: String, default: 'grid grid-cols-2 lg:grid-cols-3 gap-4' },
24
+ clickRowToOpen: { type: Boolean, default: false },
25
+ })
26
+
27
+ const emit = defineEmits(['update:search', 'row-click', 'loaded'])
28
+ const instance = getCurrentInstance()
29
+
30
+ // ─── API / toast ─────────────────────────────────────────────────────────────
31
+ const api = useApi()
32
+ const toast = useToast()
33
+
34
+ // ─── Local data ───────────────────────────────────────────────────────────────
35
+ const tableData = ref([])
36
+ const rowCount = ref(0)
37
+ const loading = ref(false)
38
+ const isDataFromCache = ref(false)
39
+ const lastDataLength = ref(10)
40
+ const lastRowHeight = ref(48)
41
+ const tableBodyRef = ref(null)
42
+ const skeletonRows = computed(() => Array.from({ length: lastDataLength.value }))
43
+ const isGridView = computed(() => props.viewMode === 'grid')
44
+
45
+ // ─── TanStack state ───────────────────────────────────────────────────────────
46
+ const pagination = ref({ pageIndex: 0, pageSize: 10 })
47
+ const sorting = ref([])
48
+ const columnFilters = ref([])
49
+ const columnVisibility = ref({})
50
+ const columnOrder = ref([])
51
+ const rowSelection = ref({})
52
+ const isCustomPerPage = ref(false)
53
+
54
+ const makeUpdater = (stateRef) => (updater) => {
55
+ stateRef.value = typeof updater === 'function' ? updater(stateRef.value) : updater
56
+ }
57
+
58
+ // ─── Column definitions ───────────────────────────────────────────────────────
59
+ const buildColumnDefs = () => {
60
+ const defs = []
61
+ if (props.checkable) {
62
+ defs.push({
63
+ id: 'select',
64
+ header: 'select',
65
+ enableSorting: false,
66
+ enableColumnFilter: false,
67
+ size: 48,
68
+ })
69
+ }
70
+ for (const col of props.columns) {
71
+ defs.push({
72
+ id: col.key,
73
+ accessorKey: col.key,
74
+ header: col.label,
75
+ enableSorting: col.sortable ?? false,
76
+ enableColumnFilter: col.filterable ?? false,
77
+ meta: { class: col.class ?? '', label: col.label },
78
+ })
79
+ }
80
+ return defs
81
+ }
82
+
83
+ const columnDefs = buildColumnDefs()
84
+
85
+ const hasFilterableColumns = computed(() => props.columns.some(c => c.filterable))
86
+
87
+ // ─── TanStack table instance ──────────────────────────────────────────────────
88
+ const table = useVueTable({
89
+ get data() { return tableData.value },
90
+ get rowCount() { return rowCount.value },
91
+ columns: columnDefs,
92
+ state: {
93
+ get pagination() { return pagination.value },
94
+ get sorting() { return sorting.value },
95
+ get columnFilters() { return columnFilters.value },
96
+ get columnVisibility() { return columnVisibility.value },
97
+ get columnOrder() { return columnOrder.value },
98
+ get rowSelection() { return rowSelection.value },
99
+ },
100
+ onPaginationChange: makeUpdater(pagination),
101
+ onSortingChange: makeUpdater(sorting),
102
+ onColumnFiltersChange: makeUpdater(columnFilters),
103
+ onColumnVisibilityChange: makeUpdater(columnVisibility),
104
+ onColumnOrderChange: makeUpdater(columnOrder),
105
+ onRowSelectionChange: makeUpdater(rowSelection),
106
+ getCoreRowModel: getCoreRowModel(),
107
+ manualPagination: true,
108
+ manualSorting: true,
109
+ manualFiltering: true,
110
+ enableMultiSort: true,
111
+ enableSortingRemoval: true,
112
+ enableRowSelection: true,
113
+ })
114
+
115
+ // ─── Fetch ────────────────────────────────────────────────────────────────────
116
+ const buildRequestParams = () => {
117
+ const { sort, ...otherParams } = props.params
118
+ return {
119
+ search: props.search,
120
+ page: pagination.value.pageIndex + 1,
121
+ perPage: pagination.value.pageSize,
122
+ sortColumns: sorting.value.map(s => ({ column: s.id, direction: s.desc ? 'desc' : 'asc' })),
123
+ columnFilters: Object.fromEntries(columnFilters.value.map(f => [f.id, f.value])),
124
+ ...otherParams,
125
+ }
126
+ }
127
+
128
+ const fetchData = async () => {
129
+ if (tableData.value.length > 0) {
130
+ lastDataLength.value = tableData.value.length
131
+ if (tableBodyRef.value?.children[0]) {
132
+ const h = tableBodyRef.value.children[0].getBoundingClientRect().height
133
+ if (h > 0) lastRowHeight.value = h
134
+ }
135
+ }
136
+
137
+ tableData.value = []
138
+ loading.value = true
139
+ isDataFromCache.value = false
140
+
141
+ try {
142
+ const res = await api.post(props.endpoint, buildRequestParams())
143
+ if (!res) return
144
+
145
+ tableData.value = Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : [])
146
+ const m = res.meta ?? (res.current_page !== undefined ? res : null)
147
+ rowCount.value = m?.total ?? tableData.value.length
148
+
149
+ if (props.cached) saveToCache()
150
+ emit('loaded', res)
151
+ } catch (e) {
152
+ console.error('[FullTable] Fetch error:', e)
153
+ } finally {
154
+ loading.value = false
155
+ }
156
+ }
157
+
158
+ // ─── Scheduled fetch (deduplicates concurrent state changes) ─────────────────
159
+ let fetchTimeout = null
160
+ const scheduleFetch = (delay = 0) => {
161
+ if (fetchTimeout) clearTimeout(fetchTimeout)
162
+ fetchTimeout = setTimeout(() => fetchData(), delay)
163
+ }
164
+
165
+ // ─── Cache ────────────────────────────────────────────────────────────────────
166
+ const cacheKey = computed(() =>
167
+ props.cached && props.name ? `full_table_${props.name}` : null
168
+ )
169
+
170
+ const saveToCache = () => {
171
+ if (!cacheKey.value || !tableData.value.length) return
172
+ try {
173
+ sessionStorage.setItem(cacheKey.value, JSON.stringify({
174
+ data: tableData.value,
175
+ rowCount: rowCount.value,
176
+ pagination: pagination.value,
177
+ sorting: sorting.value,
178
+ columnFilters: columnFilters.value,
179
+ columnVisibility: columnVisibility.value,
180
+ columnOrder: columnOrder.value,
181
+ search: props.search,
182
+ timestamp: Date.now(),
183
+ }))
184
+ } catch (e) {
185
+ console.warn('[FullTable] Cache save error:', e)
186
+ }
187
+ }
188
+
189
+ const loadFromCache = () => {
190
+ if (!cacheKey.value) return null
191
+ try {
192
+ const raw = sessionStorage.getItem(cacheKey.value)
193
+ if (!raw) return null
194
+ const cached = JSON.parse(raw)
195
+ if (Date.now() - cached.timestamp > 10 * 60 * 1000) {
196
+ sessionStorage.removeItem(cacheKey.value)
197
+ return null
198
+ }
199
+ if (cached.search !== props.search) return null
200
+ return cached
201
+ } catch { return null }
202
+ }
203
+
204
+ const clearCache = () => {
205
+ if (cacheKey.value) sessionStorage.removeItem(cacheKey.value)
206
+ }
207
+
208
+ // ─── Restore guard (prevents watchers from triggering fetch during restore) ───
209
+ const isRestoring = ref(false)
210
+
211
+ const loadFromCacheOnMount = async () => {
212
+ const cached = loadFromCache()
213
+ if (!cached) return false
214
+
215
+ isRestoring.value = true
216
+ tableData.value = cached.data
217
+ rowCount.value = cached.rowCount
218
+ pagination.value = cached.pagination
219
+ sorting.value = cached.sorting
220
+ columnFilters.value = cached.columnFilters
221
+ columnVisibility.value = cached.columnVisibility
222
+ if (cached.columnOrder?.length) columnOrder.value = cached.columnOrder
223
+ lastDataLength.value = cached.data.length
224
+ isDataFromCache.value = true
225
+
226
+ if (cached.search !== props.search) emit('update:search', cached.search)
227
+
228
+ await nextTick()
229
+ isRestoring.value = false
230
+ return true
231
+ }
232
+
233
+ // ─── Watchers ─────────────────────────────────────────────────────────────────
234
+ watch(pagination, () => { if (!isRestoring.value) scheduleFetch(0) }, { deep: true })
235
+ watch(sorting, () => { if (!isRestoring.value) scheduleFetch(0) }, { deep: true })
236
+ watch(columnFilters, () => { if (!isRestoring.value) scheduleFetch(300) }, { deep: true })
237
+
238
+ watch(() => props.search, () => {
239
+ if (isRestoring.value) return
240
+ pagination.value = { ...pagination.value, pageIndex: 0 }
241
+ scheduleFetch(500)
242
+ })
243
+
244
+ watch(() => props.params, () => {
245
+ if (isRestoring.value) return
246
+ pagination.value = { ...pagination.value, pageIndex: 0 }
247
+ scheduleFetch(0)
248
+ }, { deep: true })
249
+
250
+ // ─── Lifecycle ────────────────────────────────────────────────────────────────
251
+ const initColumnOrder = () => {
252
+ const ids = props.checkable ? ['select'] : []
253
+ for (const col of props.columns) ids.push(col.key)
254
+ columnOrder.value = ids
255
+ }
256
+
257
+ onMounted(async () => {
258
+ initColumnOrder()
259
+ try {
260
+ const fromCache = await loadFromCacheOnMount()
261
+ if (!fromCache) await fetchData()
262
+ } catch (e) {
263
+ console.error('[FullTable] Mount error:', e)
264
+ }
265
+ })
266
+
267
+ onBeforeUnmount(() => {
268
+ if (fetchTimeout) clearTimeout(fetchTimeout)
269
+ if (props.cached && tableData.value.length > 0) saveToCache()
270
+ })
271
+
272
+ // ─── Column settings panel ────────────────────────────────────────────────────
273
+ const showColumnPanel = ref(false)
274
+ const columnPanelRef = ref(null)
275
+
276
+ const orderedColumns = computed(() => {
277
+ if (!columnOrder.value.length) return props.columns
278
+ return [...props.columns].sort((a, b) => {
279
+ const ia = columnOrder.value.indexOf(a.key)
280
+ const ib = columnOrder.value.indexOf(b.key)
281
+ return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib)
282
+ })
283
+ })
284
+
285
+ let draggedPanelKey = null
286
+ const dragOverPanelKey = ref(null)
287
+
288
+ const onPanelDragStart = (key) => { draggedPanelKey = key }
289
+ const onPanelDragOver = (e, key) => { e.preventDefault(); dragOverPanelKey.value = key }
290
+ const onPanelDragLeave = () => { dragOverPanelKey.value = null }
291
+ const onPanelDrop = (key) => {
292
+ if (!draggedPanelKey || draggedPanelKey === key) return
293
+ const order = [...columnOrder.value]
294
+ const from = order.indexOf(draggedPanelKey)
295
+ const to = order.indexOf(key)
296
+ if (from < 0 || to < 0) return
297
+ order.splice(from, 1)
298
+ order.splice(to, 0, draggedPanelKey)
299
+ columnOrder.value = order
300
+ draggedPanelKey = null
301
+ dragOverPanelKey.value = null
302
+ }
303
+
304
+ const handlePanelOutsideClick = (e) => {
305
+ if (columnPanelRef.value && !columnPanelRef.value.contains(e.target)) {
306
+ showColumnPanel.value = false
307
+ }
308
+ }
309
+
310
+ watch(showColumnPanel, (v) => {
311
+ if (v) document.addEventListener('mousedown', handlePanelOutsideClick)
312
+ else document.removeEventListener('mousedown', handlePanelOutsideClick)
313
+ })
314
+
315
+ // ─── Header drag reorder ──────────────────────────────────────────────────────
316
+ let draggedHeaderId = null
317
+ const dragOverHeaderId = ref(null)
318
+
319
+ const onHeaderDragStart = (colId) => { draggedHeaderId = colId }
320
+ const onHeaderDragOver = (e, colId) => { e.preventDefault(); dragOverHeaderId.value = colId }
321
+ const onHeaderDragLeave = () => { dragOverHeaderId.value = null }
322
+ const onHeaderDrop = (colId) => {
323
+ if (!draggedHeaderId || draggedHeaderId === colId) return
324
+ const order = [...columnOrder.value]
325
+ const from = order.indexOf(draggedHeaderId)
326
+ const to = order.indexOf(colId)
327
+ if (from < 0 || to < 0) return
328
+ order.splice(from, 1)
329
+ order.splice(to, 0, draggedHeaderId)
330
+ columnOrder.value = order
331
+ draggedHeaderId = null
332
+ dragOverHeaderId.value = null
333
+ }
334
+
335
+ // ─── Row selection ────────────────────────────────────────────────────────────
336
+ const getSelectedRows = () => {
337
+ const selected = table.getSelectedRowModel().rows.map(r => r.original)
338
+ return table.getIsAllRowsSelected()
339
+ ? { meta: { all: true }, rows: [] }
340
+ : { meta: { all: false }, rows: selected }
341
+ }
342
+
343
+ // ─── Export ───────────────────────────────────────────────────────────────────
344
+ const exportTable = async (format, exportAllPages, exportFilteredRows) => {
345
+ const { download } = useDownload()
346
+ const id = crypto.randomUUID()
347
+ toast.show({
348
+ id, type: 'process', title: 'Descargando archivo...',
349
+ progress: 0, progressLabel: 'Iniciando descarga', message: '', position: 'top-right',
350
+ })
351
+
352
+ const validFormats = ['csv', 'xlsx', 'pdf', 'json']
353
+ const params = {
354
+ ...buildRequestParams(),
355
+ exportType: validFormats.includes(format) ? format : 'csv',
356
+ exportAllPages,
357
+ exportFilteredRows,
358
+ }
359
+
360
+ try {
361
+ const { blob, headers } = await download(props.endpoint, params, {
362
+ method: 'POST',
363
+ onProgress: (p) => toast.update(id, { progress: p, progressLabel: `Descargando... ${p}%` }),
364
+ })
365
+
366
+ let fileName = 'export.' + format
367
+ const cd = headers['content-disposition']
368
+ if (cd) {
369
+ const m = cd.match(/filename="(.+)"/)
370
+ if (m?.[1]) fileName = m[1]
371
+ }
372
+
373
+ const url = window.URL.createObjectURL(blob)
374
+ const a = document.createElement('a')
375
+ a.href = url; a.setAttribute('download', fileName)
376
+ document.body.appendChild(a); a.click(); a.remove()
377
+ window.URL.revokeObjectURL(url)
378
+
379
+ toast.update(id, { progress: 100, progressLabel: '¡Descarga completada!', message: 'El archivo se descargó correctamente.' })
380
+ setTimeout(() => toast.remove(id), 2000)
381
+ } catch (e) {
382
+ toast.update(id, { progressLabel: 'Error en la descarga', message: e.message, severity: 'danger' })
383
+ setTimeout(() => toast.remove(id), 3000)
384
+ }
385
+ }
386
+
387
+ // ─── Per-page ─────────────────────────────────────────────────────────────────
388
+ const handlePerPageChange = (val) => {
389
+ if (val === 'custom') { isCustomPerPage.value = true; return }
390
+ table.setPageSize(parseInt(val))
391
+ }
392
+
393
+ const resetPerPage = () => {
394
+ isCustomPerPage.value = false
395
+ if (![10, 25, 50, 100].includes(pagination.value.pageSize)) table.setPageSize(10)
396
+ }
397
+
398
+ // ─── Row click ────────────────────────────────────────────────────────────────
399
+ const hasRowClickListener = computed(() => !!instance?.vnode?.props?.onRowClick)
400
+ const isRowClickEnabled = computed(() => props.clickRowToOpen || hasRowClickListener.value)
401
+
402
+ const interactiveSelector = [
403
+ 'a', 'button', 'input', 'select', 'textarea', 'label', 'summary',
404
+ "[role='button']", "[role='link']", "[contenteditable='true']",
405
+ '[data-row-click-ignore]', '[data-no-row-click]', '.hs-dropdown', '.dropdown',
406
+ ].join(',')
407
+
408
+ const shouldIgnoreRowClick = (e) => {
409
+ const t = e?.target
410
+ if (!(t instanceof Element)) return false
411
+ const el = t.closest(interactiveSelector)
412
+ return !!el && e.currentTarget?.contains(el)
413
+ }
414
+
415
+ const handleRowClick = (row, e) => {
416
+ if (!isRowClickEnabled.value || shouldIgnoreRowClick(e)) return
417
+ emit('row-click', row.original, e)
418
+ }
419
+
420
+ const handleRowKeydown = (row, e) => {
421
+ if (!isRowClickEnabled.value || shouldIgnoreRowClick(e)) return
422
+ if (!['Enter', ' '].includes(e.key)) return
423
+ e.preventDefault()
424
+ emit('row-click', row.original, e)
425
+ }
426
+
427
+ // ─── Expose ───────────────────────────────────────────────────────────────────
428
+ defineExpose({
429
+ getSelectedRows,
430
+ loading,
431
+ exportTable,
432
+ reload: () => { clearCache(); fetchData() },
433
+ clearCache,
434
+ table,
435
+ })
436
+ </script>
437
+
438
+ <template>
439
+ <div class="relative">
440
+
441
+ <!-- Column settings panel -->
442
+ <Transition
443
+ enter-active-class="transition ease-out duration-150"
444
+ enter-from-class="opacity-0 translate-y-1 scale-95"
445
+ enter-to-class="opacity-100 translate-y-0 scale-100"
446
+ leave-active-class="transition ease-in duration-100"
447
+ leave-from-class="opacity-100 translate-y-0 scale-100"
448
+ leave-to-class="opacity-0 translate-y-1 scale-95"
449
+ >
450
+ <div
451
+ v-if="showColumnPanel"
452
+ ref="columnPanelRef"
453
+ class="absolute bottom-16 right-6 z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl shadow-2xl p-3 min-w-56 max-h-80 overflow-y-auto"
454
+ >
455
+ <p class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-2 px-1">
456
+ Columnas visibles
457
+ </p>
458
+ <div
459
+ v-for="col in orderedColumns"
460
+ :key="col.key"
461
+ draggable="true"
462
+ @dragstart="onPanelDragStart(col.key)"
463
+ @dragover="(e) => onPanelDragOver(e, col.key)"
464
+ @dragleave="onPanelDragLeave"
465
+ @drop="onPanelDrop(col.key)"
466
+ class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none transition-colors"
467
+ :class="dragOverPanelKey === col.key
468
+ ? 'bg-indigo-50 dark:bg-indigo-900/20 ring-1 ring-indigo-300 dark:ring-indigo-700'
469
+ : 'hover:bg-slate-50 dark:hover:bg-slate-700 cursor-grab'"
470
+ >
471
+ <IconGripVertical class="size-4 text-slate-300 dark:text-slate-600 shrink-0" />
472
+ <input
473
+ type="checkbox"
474
+ :checked="table.getColumn(col.key)?.getIsVisible() ?? true"
475
+ @change="table.getColumn(col.key)?.toggleVisibility()"
476
+ @click.stop
477
+ class="rounded border-gray-300 dark:bg-slate-700 dark:border-slate-600 shrink-0 cursor-pointer"
478
+ />
479
+ <span class="text-sm text-slate-700 dark:text-slate-200 truncate">{{ col.label }}</span>
480
+ </div>
481
+ </div>
482
+ </Transition>
483
+
484
+ <!-- Table view -->
485
+ <div v-if="!isGridView" class="overflow-x-auto relative">
486
+ <table class="relative min-w-full divide-y divide-gray-200 dark:divide-slate-700">
487
+ <thead class="relative z-20 bg-white dark:bg-slate-800">
488
+ <template v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
489
+ <!-- Main header row -->
490
+ <tr
491
+ class="divide-x divide-gray-200 dark:border-slate-700 dark:divide-slate-700"
492
+ :class="{ 'border-t border-gray-200': loading || tableData.length > 0 }"
493
+ >
494
+ <th
495
+ v-for="header in headerGroup.headers"
496
+ :key="header.id"
497
+ scope="col"
498
+ :draggable="header.id !== 'select'"
499
+ @dragstart="header.id !== 'select' && onHeaderDragStart(header.id)"
500
+ @dragover="header.id !== 'select' && onHeaderDragOver($event, header.id)"
501
+ @dragleave="onHeaderDragLeave"
502
+ @drop="header.id !== 'select' && onHeaderDrop(header.id)"
503
+ :class="[
504
+ header.id === 'select' ? 'text-center w-12' : (header.column.columnDef.meta?.class || 'min-w-52'),
505
+ dragOverHeaderId === header.id ? 'bg-indigo-50 dark:bg-indigo-900/20' : '',
506
+ header.column.getCanSort() ? 'cursor-pointer select-none' : '',
507
+ ]"
508
+ @click="header.column.getCanSort() && header.column.toggleSorting()"
509
+ >
510
+ <!-- Select all checkbox -->
511
+ <template v-if="header.id === 'select'">
512
+ <input
513
+ type="checkbox"
514
+ :checked="table.getIsAllRowsSelected()"
515
+ :indeterminate="table.getIsSomeRowsSelected()"
516
+ @change="table.getToggleAllRowsSelectedHandler()($event)"
517
+ class="mx-2 shrink-0 border-gray-300 rounded-sm text-blue-900 focus:ring-blue-900 dark:bg-slate-800 dark:border-slate-600"
518
+ />
519
+ </template>
520
+ <!-- Regular column header -->
521
+ <template v-else>
522
+ <div class="px-6 py-3 flex items-center gap-x-1 text-[11px] font-bold text-gray-500 dark:text-slate-400 uppercase tracking-wider w-full">
523
+ {{ header.column.columnDef.meta?.label ?? header.id }}
524
+ <span v-if="header.column.getCanSort()">
525
+ <IconArrowsSort v-if="!header.column.getIsSorted()" class="size-4 opacity-40" />
526
+ <IconSortDescendingSmallBig v-else-if="header.column.getIsSorted() === 'desc'" class="size-5" />
527
+ <IconSortAscendingSmallBig v-else class="size-5" />
528
+ </span>
529
+ </div>
530
+ </template>
531
+ </th>
532
+ </tr>
533
+
534
+ <!-- Column filter row -->
535
+ <tr
536
+ v-if="hasFilterableColumns"
537
+ class="divide-x divide-gray-200 dark:divide-slate-700 border-b border-gray-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/50"
538
+ >
539
+ <th
540
+ v-for="header in headerGroup.headers"
541
+ :key="'f-' + header.id"
542
+ :class="header.id === 'select' ? 'w-12' : 'px-3 py-1.5'"
543
+ >
544
+ <input
545
+ v-if="header.column.getCanFilter()"
546
+ :value="header.column.getFilterValue() ?? ''"
547
+ @input="(e) => header.column.setFilterValue(e.target.value || undefined)"
548
+ :placeholder="`Filtrar ${header.column.columnDef.meta?.label ?? ''}...`"
549
+ class="w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg text-xs text-slate-600 dark:text-slate-300 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"
550
+ />
551
+ </th>
552
+ </tr>
553
+ </template>
554
+ </thead>
555
+
556
+ <tbody ref="tableBodyRef" class="divide-y divide-gray-200 dark:divide-slate-700">
557
+ <!-- Loading skeleton -->
558
+ <tr
559
+ v-if="loading"
560
+ v-for="(_, i) in skeletonRows"
561
+ :key="'sk-' + i"
562
+ class="animate-pulse divide-x divide-gray-200 dark:divide-slate-700 bg-white dark:bg-slate-800"
563
+ >
564
+ <td
565
+ v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
566
+ :key="'skc-' + header.id"
567
+ :class="header.id === 'select' ? 'text-center w-12' : 'px-6'"
568
+ :style="{ height: lastRowHeight + 'px' }"
569
+ >
570
+ <div v-if="header.id === 'select'" class="w-4 h-4 bg-gray-300 dark:bg-slate-600 rounded mx-auto"></div>
571
+ <div v-else class="h-4 w-[50%] rounded bg-gray-200 dark:bg-slate-600"></div>
572
+ </td>
573
+ </tr>
574
+
575
+ <!-- Empty skeleton -->
576
+ <tr
577
+ v-if="!loading && tableData.length === 0"
578
+ v-for="(_, i) in skeletonRows"
579
+ :key="'esk-' + i"
580
+ class="divide-x divide-gray-200 dark:divide-slate-700 bg-white dark:bg-slate-800"
581
+ >
582
+ <td
583
+ v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
584
+ :key="'eskc-' + header.id"
585
+ :class="header.id === 'select' ? 'text-center w-12' : 'px-6'"
586
+ :style="{ height: lastRowHeight + 'px' }"
587
+ >
588
+ <div v-if="header.id === 'select'" class="w-4 h-4 bg-gray-200 dark:bg-slate-600 rounded mx-auto"></div>
589
+ <div v-else class="h-4 w-[50%] rounded bg-gray-100 dark:bg-slate-700"></div>
590
+ </td>
591
+ </tr>
592
+
593
+ <!-- Data rows -->
594
+ <tr
595
+ v-else
596
+ v-for="row in table.getRowModel().rows"
597
+ :key="row.id"
598
+ @click="(e) => handleRowClick(row, e)"
599
+ @keydown="(e) => handleRowKeydown(row, e)"
600
+ :tabindex="isRowClickEnabled ? 0 : undefined"
601
+ class="divide-x divide-gray-200 dark:divide-slate-700 bg-white hover:bg-gray-50 dark:bg-slate-800 dark:hover:bg-slate-900 transition-colors"
602
+ :class="{
603
+ 'cursor-pointer': isRowClickEnabled,
604
+ 'bg-indigo-50/40 dark:bg-indigo-900/10 hover:bg-indigo-50/60': row.getIsSelected(),
605
+ }"
606
+ >
607
+ <td
608
+ v-for="cell in row.getVisibleCells()"
609
+ :key="cell.id"
610
+ :class="[
611
+ cell.column.id === 'select'
612
+ ? 'text-center w-12'
613
+ : 'px-6 py-3 text-sm text-slate-600 dark:text-slate-300',
614
+ cell.column.id !== 'select' ? cell.column.columnDef.meta?.class ?? '' : '',
615
+ ]"
616
+ @click.stop="cell.column.id === 'select' ? null : undefined"
617
+ >
618
+ <!-- Select checkbox -->
619
+ <template v-if="cell.column.id === 'select'">
620
+ <div @click.stop>
621
+ <input
622
+ type="checkbox"
623
+ :checked="row.getIsSelected()"
624
+ :disabled="!row.getCanSelect()"
625
+ @change="row.getToggleSelectedHandler()($event)"
626
+ class="rounded border-gray-300 dark:bg-slate-800 dark:border-slate-600"
627
+ />
628
+ </div>
629
+ </template>
630
+ <!-- Data cell with slot -->
631
+ <template v-else>
632
+ <slot :name="cell.column.id" :row="row.original" :value="cell.getValue()">
633
+ {{ cell.getValue() }}
634
+ </slot>
635
+ </template>
636
+ </td>
637
+ </tr>
638
+ </tbody>
639
+ </table>
640
+
641
+ <!-- Empty state overlays -->
642
+ <div
643
+ v-if="!loading && tableData.length === 0 && !search && !columnFilters.length"
644
+ class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-slate-800/60 rounded-xl"
645
+ >
646
+ <slot name="empty">
647
+ <p class="text-slate-400 dark:text-slate-500 text-lg font-medium italic">No hay registros</p>
648
+ </slot>
649
+ </div>
650
+
651
+ <div
652
+ v-if="!loading && tableData.length === 0 && (search || columnFilters.length)"
653
+ class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-white/60 dark:bg-slate-800/60 rounded-xl"
654
+ >
655
+ <slot name="empty-search">
656
+ <p class="text-slate-400 dark:text-slate-500 text-lg font-medium italic">No hay registros en la búsqueda</p>
657
+ </slot>
658
+ </div>
659
+ </div>
660
+
661
+ <!-- Grid view -->
662
+ <div v-else class="relative">
663
+ <div v-if="loading" :class="gridClass">
664
+ <div v-for="(_, i) in skeletonRows" :key="'gsk-' + i" class="animate-pulse">
665
+ <slot name="grid-skeleton">
666
+ <div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
667
+ <div class="space-y-3">
668
+ <div class="h-4 bg-gray-200 dark:bg-slate-600 rounded w-3/4"></div>
669
+ <div class="h-4 bg-gray-200 dark:bg-slate-600 rounded w-1/2"></div>
670
+ <div class="h-6 bg-gray-200 dark:bg-slate-600 rounded w-1/4"></div>
671
+ </div>
672
+ </div>
673
+ </slot>
674
+ </div>
675
+ </div>
676
+
677
+ <div v-else-if="tableData.length > 0" :class="gridClass">
678
+ <slot
679
+ name="grid-item"
680
+ v-for="row in table.getRowModel().rows"
681
+ :key="row.id"
682
+ :row="row.original"
683
+ :tanstack-row="row"
684
+ :is-selected="row.getIsSelected()"
685
+ :checkable="checkable"
686
+ :toggle-row="() => row.toggleSelected()"
687
+ >
688
+ <div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow relative"
689
+ :class="{ 'ring-2 ring-indigo-400 dark:ring-indigo-600': row.getIsSelected() }">
690
+ <div v-if="checkable" class="absolute top-2 left-2 z-10">
691
+ <input type="checkbox" :checked="row.getIsSelected()" @change="row.toggleSelected()"
692
+ class="rounded border-gray-300 dark:bg-slate-800 dark:border-slate-600" />
693
+ </div>
694
+ <div class="space-y-2" :class="{ 'pt-6': checkable }">
695
+ <div v-for="cell in row.getVisibleCells().filter(c => c.column.id !== 'select')" :key="cell.id" class="flex justify-between">
696
+ <span class="text-sm text-gray-500 dark:text-slate-400">{{ cell.column.columnDef.meta?.label ?? cell.column.id }}:</span>
697
+ <span class="text-sm text-gray-900 dark:text-slate-100">
698
+ <slot :name="cell.column.id" :row="row.original" :value="cell.getValue()">{{ cell.getValue() }}</slot>
699
+ </span>
700
+ </div>
701
+ </div>
702
+ </div>
703
+ </slot>
704
+ </div>
705
+
706
+ <div v-else class="flex items-center justify-center py-12">
707
+ <slot v-if="!search && !columnFilters.length" name="empty">
708
+ <p class="text-gray-500 dark:text-slate-400 text-lg">No hay registros</p>
709
+ </slot>
710
+ <slot v-else name="empty-search">
711
+ <p class="text-gray-500 dark:text-slate-400 text-lg">No hay registros en la búsqueda</p>
712
+ </slot>
713
+ </div>
714
+ </div>
715
+
716
+ <!-- Pagination & controls bar -->
717
+ <div class="flex flex-col sm:flex-row items-center justify-between gap-y-4 sm:gap-y-0 mt-4 px-6 pb-6">
718
+ <!-- Left: reload, total, cache, columns button -->
719
+ <div class="flex items-center gap-x-4 flex-wrap gap-y-2">
720
+ <!-- Reload button -->
721
+ <div v-if="showReloadButton" class="flex items-center gap-x-2">
722
+ <IconReload
723
+ v-if="!loading"
724
+ class="size-4 cursor-pointer text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 transition-colors"
725
+ @click="() => { clearCache(); isDataFromCache.value = false; fetchData() }"
726
+ />
727
+ <div v-else>
728
+ <svg class="animate-spin size-4 text-slate-400 dark:text-slate-600" 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">
729
+ <circle cx="12" cy="12" r="10" opacity=".25" />
730
+ <path d="M22 12a10 10 0 0 1-10 10" />
731
+ </svg>
732
+ </div>
733
+ </div>
734
+
735
+ <!-- Total records -->
736
+ <p class="text-sm text-gray-800 dark:text-slate-200 font-medium">{{ rowCount }} registros</p>
737
+
738
+ <!-- Cache badge -->
739
+ <div v-if="isDataFromCache && cached" class="group relative flex items-center">
740
+ <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">
741
+ <IconBolt class="size-3.5 fill-current" />
742
+ <span class="text-[10px] font-bold uppercase tracking-wider">Instant</span>
743
+ </div>
744
+ <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">
745
+ <div class="font-bold mb-1 flex items-center gap-x-1.5 text-emerald-400">
746
+ <IconBolt class="size-3" /> Datos en Caché
747
+ </div>
748
+ Los datos se cargaron instantáneamente desde la memoria local. Actualice para sincronizar con el servidor.
749
+ <div class="absolute top-full left-4 -mt-1 border-4 border-transparent border-t-slate-900"></div>
750
+ </div>
751
+ </div>
752
+
753
+ <!-- Columns panel button -->
754
+ <button
755
+ @click="showColumnPanel = !showColumnPanel"
756
+ class="flex items-center gap-x-1.5 py-1 px-2.5 rounded-lg text-[11px] font-bold text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors border border-transparent"
757
+ :class="showColumnPanel ? 'bg-slate-100 dark:bg-slate-700 border-slate-200 dark:border-slate-600' : ''"
758
+ >
759
+ <IconLayoutColumns class="size-4" />
760
+ Columnas
761
+ </button>
762
+ </div>
763
+
764
+ <!-- Right: per-page + pagination -->
765
+ <div class="flex items-center gap-x-8">
766
+ <!-- Per page selector -->
767
+ <div class="flex items-center gap-x-2">
768
+ <label class="text-[10px] font-bold text-gray-400 dark:text-slate-500 uppercase tracking-widest">Filas:</label>
769
+ <select
770
+ v-if="!isCustomPerPage"
771
+ :value="pagination.pageSize"
772
+ @change="(e) => handlePerPageChange(e.target.value)"
773
+ class="bg-slate-100 dark:bg-slate-800 border-none text-[11px] font-bold text-slate-600 dark:text-slate-300 rounded-lg focus:ring-0 cursor-pointer py-1 pl-2 pr-8"
774
+ >
775
+ <option :value="10">10</option>
776
+ <option :value="25">25</option>
777
+ <option :value="50">50</option>
778
+ <option :value="100">100</option>
779
+ <option value="custom">Otro...</option>
780
+ </select>
781
+ <div v-else class="flex items-center gap-x-1">
782
+ <input
783
+ type="number"
784
+ :value="pagination.pageSize"
785
+ @change="(e) => table.setPageSize(parseInt(e.target.value) || 10)"
786
+ min="1" max="500"
787
+ class="w-14 bg-slate-100 dark:bg-slate-800 border-none text-[11px] font-bold text-slate-600 dark:text-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500/20 py-1 px-2"
788
+ />
789
+ <button @click="resetPerPage" class="text-[10px] text-indigo-500 font-bold hover:underline">Volver</button>
790
+ </div>
791
+ </div>
792
+
793
+ <!-- Pagination nav -->
794
+ <nav class="flex justify-end items-center gap-x-1" aria-label="Pagination">
795
+ <button
796
+ type="button"
797
+ class="size-8 flex items-center justify-center rounded-lg text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10 disabled:opacity-30"
798
+ :disabled="!table.getCanPreviousPage()"
799
+ @click="table.previousPage()"
800
+ >
801
+ <svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
802
+ <path d="m15 18-6-6 6-6" />
803
+ </svg>
804
+ </button>
805
+ <div class="flex items-center gap-x-1 mx-2">
806
+ <span class="size-8 flex items-center justify-center text-xs font-bold rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-white">
807
+ {{ pagination.pageIndex + 1 }}
808
+ </span>
809
+ <span class="text-[10px] font-bold text-gray-400 dark:text-slate-500 uppercase mx-1">de</span>
810
+ <span class="text-[10px] font-bold text-gray-400 dark:text-slate-500">{{ table.getPageCount() }}</span>
811
+ </div>
812
+ <button
813
+ type="button"
814
+ class="size-8 flex items-center justify-center rounded-lg text-gray-800 hover:bg-gray-100 dark:text-white dark:hover:bg-white/10 disabled:opacity-30"
815
+ :disabled="!table.getCanNextPage()"
816
+ @click="table.nextPage()"
817
+ >
818
+ <svg class="shrink-0 size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
819
+ <path d="m9 18 6-6-6-6" />
820
+ </svg>
821
+ </button>
822
+ </nav>
823
+ </div>
824
+ </div>
825
+ </div>
826
+ </template>
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.15",
3
+
4
+ "version": "0.1.17",
4
5
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
6
  "keywords": [
6
7
  "nuxt",
@@ -30,13 +31,14 @@
30
31
  "dependencies": {
31
32
  "@innertia-solutions/nuxt-core": "^0.1.4",
32
33
  "@tabler/icons-vue": "^3.44.0",
33
- "preline": "^3.2.3",
34
34
  "@tailwindcss/aspect-ratio": "^0.4.2",
35
35
  "@tailwindcss/forms": "^0.5.10",
36
36
  "@tailwindcss/vite": "^4.0.0",
37
+ "@tanstack/vue-table": "^8.21.3",
38
+ "preline": "^3.2.3",
37
39
  "tailwindcss": "^4.0.0",
38
- "vanilla-calendar-pro": "^3.1.0",
39
- "uuid": "^13.0.0"
40
+ "uuid": "^13.0.0",
41
+ "vanilla-calendar-pro": "^3.1.0"
40
42
  },
41
43
  "devDependencies": {
42
44
  "nuxt": "^4.4.2",