@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
|
-
|
|
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
|
-
"
|
|
39
|
-
"
|
|
40
|
+
"uuid": "^13.0.0",
|
|
41
|
+
"vanilla-calendar-pro": "^3.1.0"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
44
|
"nuxt": "^4.4.2",
|
|
File without changes
|