@innertia-solutions/nuxt-theme-spark 0.1.142 → 0.1.143
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.
|
@@ -1,30 +1,44 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { IconSearch, IconLayoutColumns, IconGripVertical, IconMinus, IconMaximize, IconX, IconPlus, IconChevronLeft, IconCheck, IconChevronDown, IconExternalLink, IconTrash } from '@tabler/icons-vue'
|
|
3
|
+
import Table from './index.vue'
|
|
3
4
|
|
|
4
5
|
const props = defineProps({
|
|
5
|
-
table:
|
|
6
|
-
endpoint:
|
|
7
|
-
columns:
|
|
8
|
-
name:
|
|
9
|
-
params:
|
|
10
|
-
checkable:
|
|
11
|
-
cached:
|
|
12
|
-
showReloadButton:
|
|
13
|
-
clickRowToOpen:
|
|
14
|
-
searchPlaceholder:
|
|
15
|
-
showSearch:
|
|
16
|
-
showFilters:
|
|
17
|
-
showExport:
|
|
18
|
-
filters:
|
|
19
|
-
splitRatio:
|
|
20
|
-
autoClosePreview:
|
|
21
|
-
previewHref:
|
|
22
|
-
previewDeletable:
|
|
6
|
+
table: { type: Object, default: null },
|
|
7
|
+
endpoint: { type: String, default: '' },
|
|
8
|
+
columns: { type: Array, required: true },
|
|
9
|
+
name: { type: String, default: '' },
|
|
10
|
+
params: { type: Object, default: () => ({}) },
|
|
11
|
+
checkable: { type: Boolean, default: false },
|
|
12
|
+
cached: { type: Boolean, default: false },
|
|
13
|
+
showReloadButton: { type: Boolean, default: true },
|
|
14
|
+
clickRowToOpen: { type: Boolean, default: false },
|
|
15
|
+
searchPlaceholder: { type: String, default: 'Buscar...' },
|
|
16
|
+
showSearch: { type: Boolean, default: true },
|
|
17
|
+
showFilters: { type: Boolean, default: true },
|
|
18
|
+
showExport: { type: Boolean, default: true },
|
|
19
|
+
filters: { type: Array, default: () => [] },
|
|
20
|
+
splitRatio: { type: Number, default: 60 },
|
|
21
|
+
autoClosePreview: { type: Boolean, default: true },
|
|
22
|
+
previewHref: { type: [String, Function], default: null }, // url fija o (row) => url
|
|
23
|
+
previewDeletable: { type: Boolean, default: false },
|
|
24
|
+
defaultPinnedColumns: { type: Object, default: null }, // { left?: string[], right?: string[] }
|
|
25
|
+
persistPreferences: { type: Boolean, default: true }, // persist column prefs in backend
|
|
23
26
|
})
|
|
24
27
|
|
|
25
28
|
const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
|
|
26
29
|
const resolvedName = computed(() => props.table?.name ?? props.name)
|
|
27
30
|
|
|
31
|
+
// ─── Table preferences (column pinning, visibility, order) ───────────────────
|
|
32
|
+
const tablePrefName = computed(() => resolvedName.value || 'default')
|
|
33
|
+
const { preferences: tablePrefs, load: loadPrefs, save: savePrefs } = useTablePreferences(tablePrefName.value)
|
|
34
|
+
|
|
35
|
+
// Resolved initial pinned columns: merge defaultPinnedColumns with saved preferences
|
|
36
|
+
const resolvedPinnedColumns = computed(() => {
|
|
37
|
+
const saved = tablePrefs.value.pinning
|
|
38
|
+
if (saved) return saved
|
|
39
|
+
return props.defaultPinnedColumns ?? null
|
|
40
|
+
})
|
|
41
|
+
|
|
28
42
|
const emit = defineEmits(['row-click', 'loaded', 'preview-delete'])
|
|
29
43
|
const slots = useSlots()
|
|
30
44
|
const forwardedSlots = computed(() => {
|
|
@@ -35,6 +49,7 @@ const forwardedSlots = computed(() => {
|
|
|
35
49
|
const search = ref('')
|
|
36
50
|
const activeFilters = ref({})
|
|
37
51
|
const tableRef = ref(null)
|
|
52
|
+
const prefsLoaded = ref(false)
|
|
38
53
|
|
|
39
54
|
// ─── Filter config ─────────────────────────────────────────────────────────────
|
|
40
55
|
const filtersConfig = computed(() =>
|
|
@@ -402,6 +417,24 @@ onMounted(async () => {
|
|
|
402
417
|
}
|
|
403
418
|
} catch {}
|
|
404
419
|
}
|
|
420
|
+
|
|
421
|
+
// Load column preferences from backend
|
|
422
|
+
if (props.persistPreferences && resolvedName.value) {
|
|
423
|
+
await loadPrefs()
|
|
424
|
+
// Apply saved visibility
|
|
425
|
+
if (tablePrefs.value.visibility && tableRef.value?.table) {
|
|
426
|
+
tableRef.value.table.setColumnVisibility(tablePrefs.value.visibility)
|
|
427
|
+
}
|
|
428
|
+
// Apply saved column order
|
|
429
|
+
if (tablePrefs.value.order?.length && tableRef.value?.setColumnOrder) {
|
|
430
|
+
tableRef.value.setColumnOrder(tablePrefs.value.order)
|
|
431
|
+
}
|
|
432
|
+
// Apply saved pinning
|
|
433
|
+
if (tablePrefs.value.pinning && tableRef.value?.table) {
|
|
434
|
+
tableRef.value.table.setColumnPinning(tablePrefs.value.pinning)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
prefsLoaded.value = true
|
|
405
438
|
})
|
|
406
439
|
onBeforeUnmount(() => {
|
|
407
440
|
window.removeEventListener('keydown', onEsc)
|
|
@@ -422,24 +455,105 @@ const orderedColumns = computed(() => {
|
|
|
422
455
|
})
|
|
423
456
|
|
|
424
457
|
let draggedKey = null
|
|
425
|
-
|
|
458
|
+
let draggedFromSection = null // 'left' | 'center' | 'right'
|
|
459
|
+
const dragOverKey = ref(null)
|
|
460
|
+
const dragOverSection = ref(null)
|
|
461
|
+
|
|
462
|
+
// ─── Columns grouped by pinning section ───────────────────────────────────────
|
|
463
|
+
const columnsBySection = computed(() => {
|
|
464
|
+
// reactive dependency on pinning state
|
|
465
|
+
const _pin = tableRef.value?.columnPinning?.value
|
|
466
|
+
const cols = orderedColumns.value
|
|
467
|
+
if (!tableRef.value?.table) return { left: [], center: cols, right: [] }
|
|
468
|
+
|
|
469
|
+
const left = [], center = [], right = []
|
|
470
|
+
for (const col of cols) {
|
|
471
|
+
const pinned = tableRef.value.table.getColumn(col.key)?.getIsPinned()
|
|
472
|
+
if (pinned === 'left') left.push(col)
|
|
473
|
+
else if (pinned === 'right') right.push(col)
|
|
474
|
+
else center.push(col)
|
|
475
|
+
}
|
|
476
|
+
return { left, center, right }
|
|
477
|
+
})
|
|
426
478
|
|
|
427
|
-
const
|
|
428
|
-
const onDragOver = (e, key) => { e.preventDefault(); dragOverKey.value = key }
|
|
429
|
-
const onDragLeave = () => { dragOverKey.value = null }
|
|
430
|
-
const onDrop = (key) => {
|
|
431
|
-
if (!draggedKey || draggedKey === key) return
|
|
432
|
-
const ids = tableRef.value?.table.getAllLeafColumns().map(c => c.id) ?? []
|
|
433
|
-
const from = ids.indexOf(draggedKey)
|
|
434
|
-
const to = ids.indexOf(key)
|
|
435
|
-
if (from < 0 || to < 0) return
|
|
436
|
-
ids.splice(from, 1)
|
|
437
|
-
ids.splice(to, 0, draggedKey)
|
|
438
|
-
const selIdx = ids.indexOf('select')
|
|
439
|
-
if (selIdx > 0) { ids.splice(selIdx, 1); ids.unshift('select') }
|
|
440
|
-
tableRef.value?.setColumnOrder(ids)
|
|
479
|
+
const resetColDrag = () => {
|
|
441
480
|
draggedKey = null
|
|
481
|
+
draggedFromSection = null
|
|
442
482
|
dragOverKey.value = null
|
|
483
|
+
dragOverSection.value = null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const onDragStart = (key, section) => { draggedKey = key; draggedFromSection = section }
|
|
487
|
+
const onDragLeave = () => { dragOverKey.value = null }
|
|
488
|
+
|
|
489
|
+
// Auto-pin anchor columns when any column enters/leaves a pinned section:
|
|
490
|
+
// Left section → checkbox (select) is always pinned left
|
|
491
|
+
// Right section → actions column is always pinned right
|
|
492
|
+
// Called AFTER pinColumn(draggedKey) so columnsBySection reflects the new state.
|
|
493
|
+
const enforceAnchorPins = (targetSection) => {
|
|
494
|
+
const t = tableRef.value
|
|
495
|
+
if (!t) return
|
|
496
|
+
const from = draggedFromSection // still valid before resetColDrag()
|
|
497
|
+
|
|
498
|
+
// ─── Left anchor: select checkbox ────────────────────────────────────────────
|
|
499
|
+
// Order (select always first) is enforced by a watch in Table/index.vue
|
|
500
|
+
if (props.checkable && t.table?.getColumn('select')) {
|
|
501
|
+
if (targetSection === 'left') {
|
|
502
|
+
t.pinColumn('select', 'left')
|
|
503
|
+
} else if (from === 'left' && columnsBySection.value.left.length === 0) {
|
|
504
|
+
t.pinColumn('select', false)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ─── Right anchor: actions ────────────────────────────────────────────────────
|
|
509
|
+
// 'actions' has label:'' so it's excluded from orderedColumns/columnsBySection.
|
|
510
|
+
// We only auto-pin it if it exists in the columns definition.
|
|
511
|
+
const hasActions = props.columns.some(c => c.key === 'actions')
|
|
512
|
+
if (hasActions) {
|
|
513
|
+
if (targetSection === 'right') {
|
|
514
|
+
// Something was pinned right → force-pin actions too
|
|
515
|
+
t.pinColumn('actions', 'right')
|
|
516
|
+
} else if (from === 'right') {
|
|
517
|
+
// Something left the right section → unpin actions if no more right columns
|
|
518
|
+
if (columnsBySection.value.right.length === 0) t.pinColumn('actions', false)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Drop on a specific column row (handles both reorder + section change)
|
|
524
|
+
const onDrop = (targetKey, targetSection) => {
|
|
525
|
+
if (!draggedKey) { resetColDrag(); return }
|
|
526
|
+
|
|
527
|
+
if (draggedFromSection !== targetSection) {
|
|
528
|
+
// Change pinning
|
|
529
|
+
const pinVal = targetSection === 'left' ? 'left' : targetSection === 'right' ? 'right' : false
|
|
530
|
+
tableRef.value?.pinColumn(draggedKey, pinVal)
|
|
531
|
+
enforceAnchorPins(targetSection)
|
|
532
|
+
persistCurrentPrefs()
|
|
533
|
+
} else if (draggedKey !== targetKey) {
|
|
534
|
+
// Reorder within section
|
|
535
|
+
const ids = tableRef.value?.table.getAllLeafColumns().map(c => c.id) ?? []
|
|
536
|
+
const from = ids.indexOf(draggedKey)
|
|
537
|
+
const to = ids.indexOf(targetKey)
|
|
538
|
+
if (from >= 0 && to >= 0) {
|
|
539
|
+
ids.splice(from, 1)
|
|
540
|
+
ids.splice(to, 0, draggedKey)
|
|
541
|
+
const selIdx = ids.indexOf('select')
|
|
542
|
+
if (selIdx > 0) { ids.splice(selIdx, 1); ids.unshift('select') }
|
|
543
|
+
tableRef.value?.setColumnOrder(ids)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
resetColDrag()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Drop on the section zone itself (empty area) — only changes pinning
|
|
550
|
+
const onDropSection = (targetSection) => {
|
|
551
|
+
if (!draggedKey || draggedFromSection === targetSection) { resetColDrag(); return }
|
|
552
|
+
const pinVal = targetSection === 'left' ? 'left' : targetSection === 'right' ? 'right' : false
|
|
553
|
+
tableRef.value?.pinColumn(draggedKey, pinVal)
|
|
554
|
+
enforceAnchorPins(targetSection)
|
|
555
|
+
persistCurrentPrefs()
|
|
556
|
+
resetColDrag()
|
|
443
557
|
}
|
|
444
558
|
|
|
445
559
|
const onColumnPanelOutsideClick = (e) => {
|
|
@@ -470,13 +584,55 @@ watch(showColumnPanel, async (v) => {
|
|
|
470
584
|
}
|
|
471
585
|
})
|
|
472
586
|
|
|
587
|
+
// ─── Persist column preferences when they change ─────────────────────────────
|
|
588
|
+
const persistCurrentPrefs = () => {
|
|
589
|
+
if (!props.persistPreferences || !resolvedName.value || !prefsLoaded.value || !tableRef.value) return
|
|
590
|
+
const tanTable = tableRef.value.table
|
|
591
|
+
if (!tanTable) return
|
|
592
|
+
|
|
593
|
+
const visibility = Object.fromEntries(
|
|
594
|
+
tanTable.getAllLeafColumns()
|
|
595
|
+
.filter(c => c.id !== 'select')
|
|
596
|
+
.map(c => [c.id, c.getIsVisible()])
|
|
597
|
+
)
|
|
598
|
+
const order = tanTable.getAllLeafColumns().map(c => c.id).filter(id => id !== 'select')
|
|
599
|
+
const rawPinning = tableRef.value.columnPinning?.value ?? tanTable.getState().columnPinning
|
|
600
|
+
const pinning = rawPinning
|
|
601
|
+
? { left: rawPinning.left ?? [], right: rawPinning.right ?? [] }
|
|
602
|
+
: { left: [], right: [] }
|
|
603
|
+
|
|
604
|
+
savePrefs({ visibility, order, pinning })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Watch column pinning changes via tableRef
|
|
608
|
+
watch(
|
|
609
|
+
() => tableRef.value?.columnPinning?.value,
|
|
610
|
+
() => { if (prefsLoaded.value) persistCurrentPrefs() },
|
|
611
|
+
{ deep: true }
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
// Watch column visibility changes
|
|
615
|
+
watch(
|
|
616
|
+
() => tableRef.value?.table?.getState()?.columnVisibility,
|
|
617
|
+
() => { if (prefsLoaded.value) persistCurrentPrefs() },
|
|
618
|
+
{ deep: true }
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
// Watch column order changes
|
|
622
|
+
watch(
|
|
623
|
+
() => tableRef.value?.table?.getState()?.columnOrder,
|
|
624
|
+
() => { if (prefsLoaded.value) persistCurrentPrefs() },
|
|
625
|
+
{ deep: true }
|
|
626
|
+
)
|
|
627
|
+
|
|
473
628
|
// ─── Expose ───────────────────────────────────────────────────────────────────
|
|
474
629
|
const getSelectedRows = () => tableRef.value?.getSelectedRows()
|
|
475
630
|
const reload = () => tableRef.value?.reload()
|
|
476
631
|
const clearCache = () => tableRef.value?.clearCache()
|
|
477
632
|
const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
|
|
633
|
+
const pinColumn = (key, position) => tableRef.value?.pinColumn(key, position)
|
|
478
634
|
|
|
479
|
-
defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview })
|
|
635
|
+
defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview, pinColumn })
|
|
480
636
|
</script>
|
|
481
637
|
|
|
482
638
|
<template>
|
|
@@ -573,6 +729,7 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, close
|
|
|
573
729
|
:click-row-to-open="clickRowToOpen"
|
|
574
730
|
:preview-row-id="previewRow?.id ?? null"
|
|
575
731
|
:preview-mode="!!previewEnabled"
|
|
732
|
+
:pinned-columns="resolvedPinnedColumns"
|
|
576
733
|
@row-click="handleRowClick"
|
|
577
734
|
@loaded="handleLoaded"
|
|
578
735
|
@page-change="closePreview"
|
|
@@ -917,35 +1074,142 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, close
|
|
|
917
1074
|
<div
|
|
918
1075
|
v-if="showColumnPanel"
|
|
919
1076
|
ref="columnPanelRef"
|
|
920
|
-
class="fixed z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl
|
|
1077
|
+
class="fixed z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl min-w-64 max-h-[480px] overflow-y-auto"
|
|
921
1078
|
:style="columnPanelStyle"
|
|
922
1079
|
>
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1080
|
+
|
|
1081
|
+
<!-- ── Sección: Fija a la izquierda ── -->
|
|
1082
|
+
<div class="p-2 pb-1">
|
|
1083
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-1 pb-1.5 flex items-center gap-1.5">
|
|
1084
|
+
<span class="size-1.5 rounded-full bg-indigo-400 inline-block"></span>
|
|
1085
|
+
Fija a la izquierda
|
|
1086
|
+
</p>
|
|
1087
|
+
<div
|
|
1088
|
+
class="rounded-lg min-h-[34px] transition-colors"
|
|
1089
|
+
:class="dragOverSection === 'left' && draggedKey && !columnsBySection.left.find(c => c.key === draggedKey)
|
|
1090
|
+
? 'bg-indigo-50 dark:bg-indigo-900/20 ring-1 ring-indigo-300 dark:ring-indigo-700'
|
|
1091
|
+
: columnsBySection.left.length === 0 ? 'border border-dashed border-card-line' : ''"
|
|
1092
|
+
@dragover.prevent="dragOverSection = 'left'"
|
|
1093
|
+
@dragleave="dragOverSection = null"
|
|
1094
|
+
@drop.stop="onDropSection('left')"
|
|
1095
|
+
>
|
|
1096
|
+
<p v-if="columnsBySection.left.length === 0" class="flex items-center justify-center h-[34px] text-xs text-muted-foreground-2 italic select-none">
|
|
1097
|
+
Arrastra columnas aquí
|
|
1098
|
+
</p>
|
|
1099
|
+
<div
|
|
1100
|
+
v-for="col in columnsBySection.left"
|
|
1101
|
+
:key="col.key"
|
|
1102
|
+
draggable="true"
|
|
1103
|
+
@dragstart="onDragStart(col.key, 'left')"
|
|
1104
|
+
@dragover.prevent="dragOverSection = 'left'; dragOverKey = col.key"
|
|
1105
|
+
@dragleave="dragOverKey = null"
|
|
1106
|
+
@drop.stop="onDrop(col.key, 'left')"
|
|
1107
|
+
class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none cursor-grab transition-colors"
|
|
1108
|
+
:class="dragOverKey === col.key ? 'bg-indigo-50 dark:bg-indigo-900/20' : 'hover:bg-muted-hover'"
|
|
1109
|
+
>
|
|
1110
|
+
<IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
|
|
1111
|
+
<input
|
|
1112
|
+
type="checkbox"
|
|
1113
|
+
:checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
|
|
1114
|
+
@change="tableRef?.table.getColumn(col.key)?.toggleVisibility(); persistCurrentPrefs()"
|
|
1115
|
+
@click.stop
|
|
1116
|
+
class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
|
|
1117
|
+
/>
|
|
1118
|
+
<span class="text-sm text-foreground truncate flex-1">{{ col.label }}</span>
|
|
1119
|
+
<span class="size-1.5 rounded-full bg-indigo-400 shrink-0 opacity-60" />
|
|
1120
|
+
</div>
|
|
1121
|
+
</div>
|
|
948
1122
|
</div>
|
|
1123
|
+
|
|
1124
|
+
<div class="mx-3 border-t border-dropdown-line" />
|
|
1125
|
+
|
|
1126
|
+
<!-- ── Sección: Columnas libres ── -->
|
|
1127
|
+
<div class="p-2 py-1">
|
|
1128
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-1 pb-1.5 flex items-center gap-1.5">
|
|
1129
|
+
<span class="size-1.5 rounded-full bg-muted-foreground-2 inline-block"></span>
|
|
1130
|
+
Columnas
|
|
1131
|
+
</p>
|
|
1132
|
+
<div
|
|
1133
|
+
class="rounded-lg min-h-[34px] transition-colors"
|
|
1134
|
+
:class="dragOverSection === 'center' && draggedKey && !columnsBySection.center.find(c => c.key === draggedKey)
|
|
1135
|
+
? 'bg-muted/60 ring-1 ring-border'
|
|
1136
|
+
: ''"
|
|
1137
|
+
@dragover.prevent="dragOverSection = 'center'"
|
|
1138
|
+
@dragleave="dragOverSection = null"
|
|
1139
|
+
@drop.stop="onDropSection('center')"
|
|
1140
|
+
>
|
|
1141
|
+
<p v-if="columnsBySection.center.length === 0" class="flex items-center justify-center h-[34px] text-xs text-muted-foreground-2 italic select-none">
|
|
1142
|
+
Sin columnas libres
|
|
1143
|
+
</p>
|
|
1144
|
+
<div
|
|
1145
|
+
v-for="col in columnsBySection.center"
|
|
1146
|
+
:key="col.key"
|
|
1147
|
+
draggable="true"
|
|
1148
|
+
@dragstart="onDragStart(col.key, 'center')"
|
|
1149
|
+
@dragover.prevent="dragOverSection = 'center'; dragOverKey = col.key"
|
|
1150
|
+
@dragleave="dragOverKey = null"
|
|
1151
|
+
@drop.stop="onDrop(col.key, 'center')"
|
|
1152
|
+
class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none cursor-grab transition-colors"
|
|
1153
|
+
:class="dragOverKey === col.key ? 'bg-blue-50 dark:bg-blue-900/20 ring-1 ring-blue-200 dark:ring-blue-700' : 'hover:bg-muted-hover'"
|
|
1154
|
+
>
|
|
1155
|
+
<IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
|
|
1156
|
+
<input
|
|
1157
|
+
type="checkbox"
|
|
1158
|
+
:checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
|
|
1159
|
+
@change="tableRef?.table.getColumn(col.key)?.toggleVisibility(); persistCurrentPrefs()"
|
|
1160
|
+
@click.stop
|
|
1161
|
+
class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
|
|
1162
|
+
/>
|
|
1163
|
+
<span class="text-sm text-foreground truncate flex-1">{{ col.label }}</span>
|
|
1164
|
+
</div>
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
|
|
1168
|
+
<div class="mx-3 border-t border-dropdown-line" />
|
|
1169
|
+
|
|
1170
|
+
<!-- ── Sección: Fija a la derecha ── -->
|
|
1171
|
+
<div class="p-2 pt-1">
|
|
1172
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-1 pb-1.5 flex items-center gap-1.5">
|
|
1173
|
+
<span class="size-1.5 rounded-full bg-amber-400 inline-block"></span>
|
|
1174
|
+
Fija a la derecha
|
|
1175
|
+
</p>
|
|
1176
|
+
<div
|
|
1177
|
+
class="rounded-lg min-h-[34px] transition-colors"
|
|
1178
|
+
:class="dragOverSection === 'right' && draggedKey && !columnsBySection.right.find(c => c.key === draggedKey)
|
|
1179
|
+
? 'bg-amber-50 dark:bg-amber-900/20 ring-1 ring-amber-300 dark:ring-amber-700'
|
|
1180
|
+
: columnsBySection.right.length === 0 ? 'border border-dashed border-card-line' : ''"
|
|
1181
|
+
@dragover.prevent="dragOverSection = 'right'"
|
|
1182
|
+
@dragleave="dragOverSection = null"
|
|
1183
|
+
@drop.stop="onDropSection('right')"
|
|
1184
|
+
>
|
|
1185
|
+
<p v-if="columnsBySection.right.length === 0" class="flex items-center justify-center h-[34px] text-xs text-muted-foreground-2 italic select-none">
|
|
1186
|
+
Arrastra columnas aquí
|
|
1187
|
+
</p>
|
|
1188
|
+
<div
|
|
1189
|
+
v-for="col in columnsBySection.right"
|
|
1190
|
+
:key="col.key"
|
|
1191
|
+
draggable="true"
|
|
1192
|
+
@dragstart="onDragStart(col.key, 'right')"
|
|
1193
|
+
@dragover.prevent="dragOverSection = 'right'; dragOverKey = col.key"
|
|
1194
|
+
@dragleave="dragOverKey = null"
|
|
1195
|
+
@drop.stop="onDrop(col.key, 'right')"
|
|
1196
|
+
class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none cursor-grab transition-colors"
|
|
1197
|
+
:class="dragOverKey === col.key ? 'bg-amber-50 dark:bg-amber-900/20' : 'hover:bg-muted-hover'"
|
|
1198
|
+
>
|
|
1199
|
+
<IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
|
|
1200
|
+
<input
|
|
1201
|
+
type="checkbox"
|
|
1202
|
+
:checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
|
|
1203
|
+
@change="tableRef?.table.getColumn(col.key)?.toggleVisibility(); persistCurrentPrefs()"
|
|
1204
|
+
@click.stop
|
|
1205
|
+
class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
|
|
1206
|
+
/>
|
|
1207
|
+
<span class="text-sm text-foreground truncate flex-1">{{ col.label }}</span>
|
|
1208
|
+
<span class="size-1.5 rounded-full bg-amber-400 shrink-0 opacity-60" />
|
|
1209
|
+
</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
|
|
949
1213
|
</div>
|
|
950
1214
|
</Transition>
|
|
951
1215
|
</Teleport>
|
|
@@ -6,31 +6,34 @@ import {
|
|
|
6
6
|
IconChevronDown,
|
|
7
7
|
IconReload,
|
|
8
8
|
IconBolt,
|
|
9
|
+
IconPin,
|
|
9
10
|
} from '@tabler/icons-vue'
|
|
10
11
|
|
|
11
12
|
const props = defineProps({
|
|
12
13
|
endpoint: { type: String, required: true },
|
|
13
|
-
columns: { type: Array, required: true },
|
|
14
|
+
columns: { type: Array, required: true }, // [{ key, label, sortable?, filterable?, class? }]
|
|
14
15
|
params: { type: Object, default: () => ({}) },
|
|
15
16
|
checkable: { type: Boolean, default: false },
|
|
16
17
|
search: { type: String, default: '' },
|
|
17
18
|
name: { type: String, required: true },
|
|
18
19
|
cached: { type: Boolean, default: false },
|
|
19
20
|
showReloadButton: { type: Boolean, default: true },
|
|
20
|
-
viewMode: { type: String, default: 'table' },
|
|
21
|
+
viewMode: { type: String, default: 'table' }, // 'table' | 'grid'
|
|
21
22
|
gridClass: { type: String, default: 'grid grid-cols-2 lg:grid-cols-3 gap-4' },
|
|
22
23
|
clickRowToOpen: { type: Boolean, default: false },
|
|
23
24
|
previewRowId: { type: [String, Number], default: null },
|
|
24
25
|
previewMode: { type: Boolean, default: false },
|
|
25
|
-
|
|
26
|
+
pinnedColumns: { type: Object, default: null }, // { left?: string[], right?: string[] }
|
|
26
27
|
})
|
|
27
28
|
|
|
28
29
|
const emit = defineEmits(['update:search', 'row-click', 'loaded', 'page-change', 'per-page-change'])
|
|
29
30
|
const instance = getCurrentInstance()
|
|
30
31
|
|
|
32
|
+
// ─── API / toast ─────────────────────────────────────────────────────────────
|
|
31
33
|
const api = useApi()
|
|
32
34
|
const toast = useToast()
|
|
33
35
|
|
|
36
|
+
// ─── Local data ───────────────────────────────────────────────────────────────
|
|
34
37
|
const tableData = ref([])
|
|
35
38
|
const rowCount = ref(0)
|
|
36
39
|
const loading = ref(false)
|
|
@@ -45,6 +48,7 @@ const skeletonRows = computed(() => {
|
|
|
45
48
|
})
|
|
46
49
|
const isGridView = computed(() => props.viewMode === 'grid')
|
|
47
50
|
|
|
51
|
+
// ─── TanStack state ───────────────────────────────────────────────────────────
|
|
48
52
|
const pagination = ref({ pageIndex: 0, pageSize: 10 })
|
|
49
53
|
const sorting = ref([])
|
|
50
54
|
const columnFilters = ref([])
|
|
@@ -53,12 +57,14 @@ const columnOrder = ref([])
|
|
|
53
57
|
const columnSizing = ref({})
|
|
54
58
|
const columnSizingInfo = ref({})
|
|
55
59
|
const rowSelection = ref({})
|
|
60
|
+
const columnPinning = ref({ left: [], right: [] })
|
|
56
61
|
const isCustomPerPage = ref(false)
|
|
57
62
|
|
|
58
63
|
const makeUpdater = (stateRef) => (updater) => {
|
|
59
64
|
stateRef.value = typeof updater === 'function' ? updater(stateRef.value) : updater
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
// ─── Column definitions ───────────────────────────────────────────────────────
|
|
62
68
|
const buildColumnDefs = () => {
|
|
63
69
|
const defs = []
|
|
64
70
|
if (props.checkable) {
|
|
@@ -94,6 +100,7 @@ const columnDefs = buildColumnDefs()
|
|
|
94
100
|
|
|
95
101
|
const hasFilterableColumns = computed(() => props.columns.some(c => c.filterable))
|
|
96
102
|
|
|
103
|
+
// ─── TanStack table instance ──────────────────────────────────────────────────
|
|
97
104
|
const table = useVueTable({
|
|
98
105
|
get data() { return tableData.value },
|
|
99
106
|
get rowCount() { return rowCount.value },
|
|
@@ -107,6 +114,7 @@ const table = useVueTable({
|
|
|
107
114
|
get columnSizing() { return columnSizing.value },
|
|
108
115
|
get columnSizingInfo() { return columnSizingInfo.value },
|
|
109
116
|
get rowSelection() { return rowSelection.value },
|
|
117
|
+
get columnPinning() { return columnPinning.value },
|
|
110
118
|
},
|
|
111
119
|
onPaginationChange: makeUpdater(pagination),
|
|
112
120
|
onSortingChange: makeUpdater(sorting),
|
|
@@ -116,9 +124,11 @@ const table = useVueTable({
|
|
|
116
124
|
onColumnSizingChange: makeUpdater(columnSizing),
|
|
117
125
|
onColumnSizingInfoChange: makeUpdater(columnSizingInfo),
|
|
118
126
|
onRowSelectionChange: makeUpdater(rowSelection),
|
|
127
|
+
onColumnPinningChange: makeUpdater(columnPinning),
|
|
119
128
|
getCoreRowModel: getCoreRowModel(),
|
|
120
129
|
columnResizeMode: 'onChange',
|
|
121
130
|
enableColumnResizing: true,
|
|
131
|
+
enableColumnPinning: true,
|
|
122
132
|
manualPagination: true,
|
|
123
133
|
manualSorting: true,
|
|
124
134
|
manualFiltering: true,
|
|
@@ -127,6 +137,59 @@ const table = useVueTable({
|
|
|
127
137
|
enableRowSelection: true,
|
|
128
138
|
})
|
|
129
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 ────────────────────────────────────────────────────────────────────
|
|
130
193
|
const buildRequestParams = () => {
|
|
131
194
|
const { sort, ...otherParams } = props.params
|
|
132
195
|
return {
|
|
@@ -165,12 +228,14 @@ const fetchData = async () => {
|
|
|
165
228
|
}
|
|
166
229
|
}
|
|
167
230
|
|
|
231
|
+
// ─── Scheduled fetch (deduplicates concurrent state changes) ─────────────────
|
|
168
232
|
let fetchTimeout = null
|
|
169
233
|
const scheduleFetch = (delay = 0) => {
|
|
170
234
|
if (fetchTimeout) clearTimeout(fetchTimeout)
|
|
171
235
|
fetchTimeout = setTimeout(() => fetchData(), delay)
|
|
172
236
|
}
|
|
173
237
|
|
|
238
|
+
// ─── Cache ────────────────────────────────────────────────────────────────────
|
|
174
239
|
const cacheKey = computed(() => {
|
|
175
240
|
if (!props.cached || !props.name) return null
|
|
176
241
|
const base = `full_table_${props.name}`
|
|
@@ -216,6 +281,7 @@ const clearCache = () => {
|
|
|
216
281
|
if (cacheKey.value) sessionStorage.removeItem(cacheKey.value)
|
|
217
282
|
}
|
|
218
283
|
|
|
284
|
+
// ─── Restore guard (prevents watchers from triggering fetch during restore) ───
|
|
219
285
|
const isRestoring = ref(false)
|
|
220
286
|
|
|
221
287
|
const loadFromCacheOnMount = async () => {
|
|
@@ -244,6 +310,7 @@ const loadFromCacheOnMount = async () => {
|
|
|
244
310
|
return true
|
|
245
311
|
}
|
|
246
312
|
|
|
313
|
+
// ─── Watchers ─────────────────────────────────────────────────────────────────
|
|
247
314
|
watch(tableData, (newData) => {
|
|
248
315
|
if (newData.length > 0 && tableBodyRef.value) {
|
|
249
316
|
const firstDataRow = Array.from(tableBodyRef.value.children).find(el => el.dataset.rowType === 'data')
|
|
@@ -278,6 +345,7 @@ watch(() => props.params, () => {
|
|
|
278
345
|
scheduleFetch(0)
|
|
279
346
|
}, { deep: true })
|
|
280
347
|
|
|
348
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
|
281
349
|
const initColumnOrder = () => {
|
|
282
350
|
const ids = props.checkable ? ['select'] : []
|
|
283
351
|
for (const col of props.columns) ids.push(col.key)
|
|
@@ -299,12 +367,15 @@ onBeforeUnmount(() => {
|
|
|
299
367
|
if (props.cached && tableData.value.length > 0) saveToCache()
|
|
300
368
|
})
|
|
301
369
|
|
|
370
|
+
// ─── Column settings panel ────────────────────────────────────────────────────
|
|
302
371
|
const setColumnOrder = (order) => { columnOrder.value = order }
|
|
303
372
|
|
|
373
|
+
// ─── Header drag reorder ──────────────────────────────────────────────────────
|
|
304
374
|
let draggedHeaderId = null
|
|
305
375
|
const dragOverHeaderId = ref(null)
|
|
306
376
|
const resizeHoverId = ref(null)
|
|
307
377
|
|
|
378
|
+
// ─── Column auto-size on double click ─────────────────────────────────────────
|
|
308
379
|
const _canvas = typeof document !== 'undefined' ? document.createElement('canvas') : null
|
|
309
380
|
const _ctx = _canvas?.getContext('2d')
|
|
310
381
|
|
|
@@ -343,6 +414,7 @@ const onHeaderDrop = (colId) => {
|
|
|
343
414
|
if (from < 0 || to < 0) return
|
|
344
415
|
order.splice(from, 1)
|
|
345
416
|
order.splice(to, 0, draggedHeaderId)
|
|
417
|
+
// keep 'select' pinned first
|
|
346
418
|
const selIdx = order.indexOf('select')
|
|
347
419
|
if (selIdx > 0) { order.splice(selIdx, 1); order.unshift('select') }
|
|
348
420
|
columnOrder.value = order
|
|
@@ -350,6 +422,7 @@ const onHeaderDrop = (colId) => {
|
|
|
350
422
|
dragOverHeaderId.value = null
|
|
351
423
|
}
|
|
352
424
|
|
|
425
|
+
// ─── Row selection ────────────────────────────────────────────────────────────
|
|
353
426
|
const getSelectedRows = () => {
|
|
354
427
|
const selected = table.getSelectedRowModel().rows.map(r => r.original)
|
|
355
428
|
return table.getIsAllRowsSelected()
|
|
@@ -357,6 +430,7 @@ const getSelectedRows = () => {
|
|
|
357
430
|
: { meta: { all: false }, rows: selected }
|
|
358
431
|
}
|
|
359
432
|
|
|
433
|
+
// ─── Export ───────────────────────────────────────────────────────────────────
|
|
360
434
|
const exportTable = async (format, exportAllPages, exportFilteredRows, selectedIds = null) => {
|
|
361
435
|
const { download } = useDownload()
|
|
362
436
|
const id = crypto.randomUUID()
|
|
@@ -401,6 +475,7 @@ const exportTable = async (format, exportAllPages, exportFilteredRows, selectedI
|
|
|
401
475
|
}
|
|
402
476
|
}
|
|
403
477
|
|
|
478
|
+
// ─── Per-page ─────────────────────────────────────────────────────────────────
|
|
404
479
|
const handlePerPageChange = (val) => {
|
|
405
480
|
if (val === 'custom') { isCustomPerPage.value = true; return }
|
|
406
481
|
table.setPageSize(parseInt(val))
|
|
@@ -411,6 +486,7 @@ const resetPerPage = () => {
|
|
|
411
486
|
if (![10, 25, 50, 100].includes(pagination.value.pageSize)) table.setPageSize(10)
|
|
412
487
|
}
|
|
413
488
|
|
|
489
|
+
// ─── Row click ────────────────────────────────────────────────────────────────
|
|
414
490
|
const hasRowClickListener = computed(() => !!instance?.vnode?.props?.onRowClick)
|
|
415
491
|
const isRowClickEnabled = computed(() => props.clickRowToOpen || props.previewMode || hasRowClickListener.value)
|
|
416
492
|
|
|
@@ -439,6 +515,24 @@ const handleRowKeydown = (row, e) => {
|
|
|
439
515
|
emit('row-click', row.original, e)
|
|
440
516
|
}
|
|
441
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 ───────────────────────────────────────────────────────────────────
|
|
442
536
|
const reloadTable = () => {
|
|
443
537
|
clearCache()
|
|
444
538
|
isDataFromCache.value = false
|
|
@@ -456,6 +550,8 @@ defineExpose({
|
|
|
456
550
|
isDataFromCache,
|
|
457
551
|
cached: computed(() => props.cached),
|
|
458
552
|
paginationBarRef,
|
|
553
|
+
columnPinning,
|
|
554
|
+
pinColumn,
|
|
459
555
|
})
|
|
460
556
|
</script>
|
|
461
557
|
|
|
@@ -469,50 +565,60 @@ defineExpose({
|
|
|
469
565
|
:style="{ tableLayout: 'fixed', width: table.getTotalSize() + 'px', minWidth: '100%' }"
|
|
470
566
|
>
|
|
471
567
|
<colgroup>
|
|
568
|
+
<!-- Must use pinning order (left|center|right) — same as getHeaderGroups() and row.getVisibleCells() -->
|
|
472
569
|
<col
|
|
473
|
-
v-for="col in table.
|
|
570
|
+
v-for="col in [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getRightVisibleLeafColumns()]"
|
|
474
571
|
:key="col.id"
|
|
475
572
|
:style="{ width: col.getSize() + 'px' }"
|
|
476
573
|
>
|
|
477
574
|
</colgroup>
|
|
478
|
-
<thead class="relative z-20 bg-
|
|
575
|
+
<thead class="relative z-20 bg-card">
|
|
479
576
|
<template v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
|
480
|
-
|
|
577
|
+
<!-- Main header row -->
|
|
578
|
+
<tr class="bg-card">
|
|
481
579
|
<th
|
|
482
580
|
v-for="header in headerGroup.headers"
|
|
483
581
|
:key="header.id"
|
|
484
582
|
scope="col"
|
|
485
|
-
:draggable="header.id !== 'select' && resizeHoverId !== header.id"
|
|
486
|
-
@dragstart="header.id !== 'select' && resizeHoverId !== header.id && onHeaderDragStart(header.id)"
|
|
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)"
|
|
487
585
|
@dragover="header.id !== 'select' && onHeaderDragOver($event, header.id)"
|
|
488
586
|
@dragleave="onHeaderDragLeave"
|
|
489
587
|
@drop="header.id !== 'select' && onHeaderDrop(header.id)"
|
|
490
|
-
class="relative
|
|
588
|
+
class="relative"
|
|
491
589
|
:class="[
|
|
492
590
|
header.id === 'select' ? 'text-center' : '',
|
|
493
|
-
dragOverHeaderId === header.id ? 'bg-
|
|
591
|
+
dragOverHeaderId === header.id ? 'bg-indigo-50 dark:bg-indigo-900/20' : '',
|
|
494
592
|
header.column.getCanSort() ? 'cursor-pointer select-none' : '',
|
|
495
593
|
]"
|
|
594
|
+
:style="getPinnedStyles(header.column, true)"
|
|
496
595
|
@click="header.column.getCanSort() && header.column.toggleSorting()"
|
|
497
596
|
>
|
|
597
|
+
<!-- Select all checkbox -->
|
|
498
598
|
<template v-if="header.id === 'select'">
|
|
499
599
|
<input
|
|
500
600
|
type="checkbox"
|
|
501
601
|
:checked="table.getIsAllRowsSelected()"
|
|
502
602
|
:indeterminate="table.getIsSomeRowsSelected()"
|
|
503
603
|
@change="table.getToggleAllRowsSelectedHandler()($event)"
|
|
504
|
-
class="mx-2 shrink-0 border-card-line rounded-sm text-
|
|
604
|
+
class="mx-2 shrink-0 border-card-line rounded-sm text-blue-900 focus:ring-0 focus:ring-offset-0 dark:bg-card"
|
|
505
605
|
/>
|
|
506
606
|
</template>
|
|
607
|
+
<!-- Regular column header -->
|
|
507
608
|
<template v-else>
|
|
508
|
-
<div
|
|
509
|
-
|
|
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>
|
|
510
615
|
<span v-if="header.column.getCanSort()">
|
|
511
616
|
<IconSelector v-if="!header.column.getIsSorted()" class="size-4 opacity-40" />
|
|
512
617
|
<IconChevronDown v-else-if="header.column.getIsSorted() === 'desc'" class="size-4" />
|
|
513
618
|
<IconChevronUp v-else class="size-4" />
|
|
514
619
|
</span>
|
|
515
620
|
</div>
|
|
621
|
+
<!-- Resize handle -->
|
|
516
622
|
<div
|
|
517
623
|
v-if="header.column.getCanResize()"
|
|
518
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"
|
|
@@ -527,14 +633,15 @@ defineExpose({
|
|
|
527
633
|
<div
|
|
528
634
|
class="h-4 w-px transition-all"
|
|
529
635
|
:class="header.column.getIsResizing()
|
|
530
|
-
? 'bg-
|
|
531
|
-
: 'bg-surface-1 group-hover/rz:bg-
|
|
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'"
|
|
532
638
|
/>
|
|
533
639
|
</div>
|
|
534
640
|
</template>
|
|
535
641
|
</th>
|
|
536
642
|
</tr>
|
|
537
643
|
|
|
644
|
+
<!-- Column filter row -->
|
|
538
645
|
<tr
|
|
539
646
|
v-if="hasFilterableColumns"
|
|
540
647
|
class="border-b border-card-line bg-muted/50"
|
|
@@ -542,14 +649,17 @@ defineExpose({
|
|
|
542
649
|
<th
|
|
543
650
|
v-for="header in headerGroup.headers"
|
|
544
651
|
:key="'f-' + header.id"
|
|
545
|
-
:class="
|
|
652
|
+
:class="[
|
|
653
|
+
header.id === 'select' ? 'w-12' : 'px-3 py-1.5',
|
|
654
|
+
]"
|
|
655
|
+
:style="getPinnedStyles(header.column, true)"
|
|
546
656
|
>
|
|
547
657
|
<input
|
|
548
658
|
v-if="header.column.getCanFilter()"
|
|
549
659
|
:value="header.column.getFilterValue() ?? ''"
|
|
550
660
|
@input="(e) => header.column.setFilterValue(e.target.value || undefined)"
|
|
551
661
|
:placeholder="`Filtrar ${header.column.columnDef.meta?.label ?? ''}...`"
|
|
552
|
-
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-
|
|
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"
|
|
553
663
|
/>
|
|
554
664
|
</th>
|
|
555
665
|
</tr>
|
|
@@ -562,44 +672,46 @@ defineExpose({
|
|
|
562
672
|
v-if="loading"
|
|
563
673
|
v-for="(_, i) in skeletonRows"
|
|
564
674
|
:key="'sk-' + i"
|
|
565
|
-
class="animate-pulse bg-
|
|
675
|
+
class="animate-pulse bg-card"
|
|
566
676
|
>
|
|
567
677
|
<td
|
|
568
678
|
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
569
679
|
:key="'skc-' + header.id"
|
|
570
|
-
:class="
|
|
571
|
-
|
|
680
|
+
:class="[
|
|
681
|
+
header.id === 'select' ? 'text-center w-12' : 'px-4 overflow-hidden',
|
|
682
|
+
]"
|
|
683
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
572
684
|
>
|
|
573
685
|
<div v-if="header.id === 'select'" class="w-4 h-4 bg-surface-1 rounded mx-auto"></div>
|
|
574
686
|
<div v-else class="h-4 w-[50%] rounded bg-surface-1"></div>
|
|
575
687
|
</td>
|
|
576
688
|
</tr>
|
|
577
689
|
|
|
578
|
-
<!-- Loading filler rows -->
|
|
690
|
+
<!-- Loading filler rows: pad to pageSize so table height doesn't change -->
|
|
579
691
|
<tr
|
|
580
692
|
v-if="loading && skeletonRows.length < pagination.pageSize"
|
|
581
693
|
v-for="i in (pagination.pageSize - skeletonRows.length)"
|
|
582
694
|
:key="'lf-' + i"
|
|
583
|
-
class="bg-
|
|
695
|
+
class="bg-card"
|
|
584
696
|
>
|
|
585
697
|
<td
|
|
586
698
|
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
587
699
|
:key="'lfc-' + header.id"
|
|
588
|
-
:style="{ height: lastRowHeight + 'px' }"
|
|
700
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
589
701
|
/>
|
|
590
702
|
</tr>
|
|
591
703
|
|
|
592
|
-
<!-- Empty filler rows -->
|
|
704
|
+
<!-- Empty filler rows: maintain table height when no results -->
|
|
593
705
|
<tr
|
|
594
706
|
v-if="!loading && tableData.length === 0"
|
|
595
707
|
v-for="i in pagination.pageSize"
|
|
596
708
|
:key="'esk-' + i"
|
|
597
|
-
class="bg-
|
|
709
|
+
class="bg-card"
|
|
598
710
|
>
|
|
599
711
|
<td
|
|
600
712
|
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
601
713
|
:key="'eskc-' + header.id"
|
|
602
|
-
:style="{ height: lastRowHeight + 'px' }"
|
|
714
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
603
715
|
/>
|
|
604
716
|
</tr>
|
|
605
717
|
|
|
@@ -612,12 +724,13 @@ defineExpose({
|
|
|
612
724
|
@click="(e) => handleRowClick(row, e)"
|
|
613
725
|
@keydown="(e) => handleRowKeydown(row, e)"
|
|
614
726
|
:tabindex="isRowClickEnabled ? 0 : undefined"
|
|
615
|
-
class="bg-
|
|
727
|
+
class="bg-card hover:bg-layer-hover transition-colors"
|
|
616
728
|
:class="{
|
|
617
729
|
'cursor-pointer': isRowClickEnabled,
|
|
618
|
-
'bg-
|
|
619
|
-
'!bg-
|
|
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,
|
|
620
732
|
}"
|
|
733
|
+
:style="pinnedRowStyle(row)"
|
|
621
734
|
>
|
|
622
735
|
<td
|
|
623
736
|
v-for="cell in row.getVisibleCells()"
|
|
@@ -625,11 +738,13 @@ defineExpose({
|
|
|
625
738
|
:data-col-id="cell.column.id"
|
|
626
739
|
:class="[
|
|
627
740
|
cell.column.id === 'select'
|
|
628
|
-
? 'text-center w-12'
|
|
629
|
-
: 'px-4 py-3 text-sm text-muted-foreground-1',
|
|
741
|
+
? 'text-center w-12 overflow-hidden'
|
|
742
|
+
: 'px-4 py-3 text-sm text-muted-foreground-1 overflow-hidden',
|
|
630
743
|
cell.column.id !== 'select' ? cell.column.columnDef.meta?.class ?? '' : '',
|
|
631
744
|
]"
|
|
745
|
+
:style="getPinnedStyles(cell.column)"
|
|
632
746
|
>
|
|
747
|
+
<!-- Select checkbox -->
|
|
633
748
|
<template v-if="cell.column.id === 'select'">
|
|
634
749
|
<div @click.stop>
|
|
635
750
|
<input
|
|
@@ -641,6 +756,7 @@ defineExpose({
|
|
|
641
756
|
/>
|
|
642
757
|
</div>
|
|
643
758
|
</template>
|
|
759
|
+
<!-- Data cell with slot -->
|
|
644
760
|
<template v-else>
|
|
645
761
|
<slot :name="cell.column.id" :row="row.original" :value="cell.getValue()">
|
|
646
762
|
{{ cell.getValue() }}
|
|
@@ -649,25 +765,26 @@ defineExpose({
|
|
|
649
765
|
</td>
|
|
650
766
|
</tr>
|
|
651
767
|
|
|
652
|
-
<!-- Filler rows -->
|
|
768
|
+
<!-- Filler rows: pad table to full page height when data < perPage -->
|
|
653
769
|
<tr
|
|
654
770
|
v-if="!loading && tableData.length > 0 && tableData.length < pagination.pageSize"
|
|
655
771
|
v-for="i in (pagination.pageSize - tableData.length)"
|
|
656
772
|
:key="'fill-' + i"
|
|
657
|
-
class="bg-
|
|
773
|
+
class="bg-card"
|
|
658
774
|
>
|
|
659
775
|
<td
|
|
660
776
|
v-for="header in (table.getHeaderGroups()[0]?.headers ?? [])"
|
|
661
777
|
:key="'fillc-' + header.id"
|
|
662
|
-
:style="{ height: lastRowHeight + 'px' }"
|
|
778
|
+
:style="{ height: lastRowHeight + 'px', ...getPinnedStyles(header.column) }"
|
|
663
779
|
/>
|
|
664
780
|
</tr>
|
|
665
781
|
</tbody>
|
|
666
782
|
</table>
|
|
667
783
|
|
|
784
|
+
<!-- Empty state overlays -->
|
|
668
785
|
<div
|
|
669
786
|
v-if="!loading && tableData.length === 0 && !search && !columnFilters.length"
|
|
670
|
-
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-
|
|
787
|
+
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-card/60 rounded-xl"
|
|
671
788
|
>
|
|
672
789
|
<slot name="empty">
|
|
673
790
|
<p class="text-muted-foreground text-lg font-medium italic">No hay registros</p>
|
|
@@ -676,7 +793,7 @@ defineExpose({
|
|
|
676
793
|
|
|
677
794
|
<div
|
|
678
795
|
v-if="!loading && tableData.length === 0 && (search || columnFilters.length)"
|
|
679
|
-
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-
|
|
796
|
+
class="absolute inset-0 z-10 pointer-events-none flex items-center justify-center backdrop-blur-sm bg-card/60 rounded-xl"
|
|
680
797
|
>
|
|
681
798
|
<slot name="empty-search">
|
|
682
799
|
<p class="text-muted-foreground text-lg font-medium italic">No hay registros en la búsqueda</p>
|
|
@@ -712,7 +829,7 @@ defineExpose({
|
|
|
712
829
|
:toggle-row="() => row.toggleSelected()"
|
|
713
830
|
>
|
|
714
831
|
<div class="bg-card rounded-lg border border-card-line p-4 hover:shadow-md transition-shadow relative"
|
|
715
|
-
:class="{ 'ring-2 ring-
|
|
832
|
+
:class="{ 'ring-2 ring-indigo-400 dark:ring-indigo-600': row.getIsSelected() }">
|
|
716
833
|
<div v-if="checkable" class="absolute top-2 left-2 z-10">
|
|
717
834
|
<input type="checkbox" :checked="row.getIsSelected()" @change="row.toggleSelected()"
|
|
718
835
|
class="rounded border-card-line dark:bg-card" />
|
|
@@ -739,9 +856,11 @@ defineExpose({
|
|
|
739
856
|
</div>
|
|
740
857
|
</div>
|
|
741
858
|
|
|
742
|
-
<!-- Pagination bar -->
|
|
859
|
+
<!-- Pagination & controls bar -->
|
|
743
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 -->
|
|
744
862
|
<div class="flex items-center gap-x-4 flex-wrap gap-y-2">
|
|
863
|
+
<!-- Reload button -->
|
|
745
864
|
<div v-if="showReloadButton" class="flex items-center gap-x-2">
|
|
746
865
|
<IconReload
|
|
747
866
|
v-if="!loading"
|
|
@@ -756,8 +875,10 @@ defineExpose({
|
|
|
756
875
|
</div>
|
|
757
876
|
</div>
|
|
758
877
|
|
|
878
|
+
<!-- Total records -->
|
|
759
879
|
<p class="text-sm text-foreground font-medium">{{ rowCount }} registros</p>
|
|
760
880
|
|
|
881
|
+
<!-- Cache badge -->
|
|
761
882
|
<div v-if="isDataFromCache && cached" class="group relative flex items-center">
|
|
762
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">
|
|
763
884
|
<IconBolt class="size-3.5 fill-current" />
|
|
@@ -771,10 +892,13 @@ defineExpose({
|
|
|
771
892
|
<div class="absolute top-full left-4 -mt-1 border-4 border-transparent border-t-slate-900"></div>
|
|
772
893
|
</div>
|
|
773
894
|
</div>
|
|
895
|
+
|
|
774
896
|
</div>
|
|
775
897
|
|
|
898
|
+
<!-- Right: per-page + pagination -->
|
|
776
899
|
<div class="flex items-center gap-x-8">
|
|
777
|
-
|
|
900
|
+
<!-- Per page selector -->
|
|
901
|
+
<div class="flex items-center gap-x-2">
|
|
778
902
|
<label class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Filas:</label>
|
|
779
903
|
<select
|
|
780
904
|
v-if="!isCustomPerPage"
|
|
@@ -794,12 +918,13 @@ defineExpose({
|
|
|
794
918
|
:value="pagination.pageSize"
|
|
795
919
|
@change="(e) => table.setPageSize(parseInt(e.target.value) || 10)"
|
|
796
920
|
min="1" max="500"
|
|
797
|
-
class="w-14 bg-surface border-none text-[11px] font-bold text-muted-foreground-1 rounded-lg focus:ring-2 focus:ring-
|
|
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"
|
|
798
922
|
/>
|
|
799
|
-
<button @click="resetPerPage" class="text-[10px] text-
|
|
923
|
+
<button @click="resetPerPage" class="text-[10px] text-indigo-500 font-bold hover:underline">Volver</button>
|
|
800
924
|
</div>
|
|
801
925
|
</div>
|
|
802
926
|
|
|
927
|
+
<!-- Pagination nav -->
|
|
803
928
|
<nav class="flex justify-end items-center gap-x-1" aria-label="Pagination">
|
|
804
929
|
<button
|
|
805
930
|
type="button"
|
|
@@ -833,3 +958,17 @@ defineExpose({
|
|
|
833
958
|
</div>
|
|
834
959
|
</div>
|
|
835
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
|
+
|
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useDebounceFn } from '@vueuse/core'
|
|
2
|
+
|
|
3
|
+
export interface TablePreferences {
|
|
4
|
+
pinning?: { left: string[], right: string[] }
|
|
5
|
+
visibility?: Record<string, boolean>
|
|
6
|
+
order?: string[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useTablePreferences(tableName: string) {
|
|
10
|
+
const api = useApi()
|
|
11
|
+
const prefKey = `table:${tableName}:columns`
|
|
12
|
+
|
|
13
|
+
const preferences = ref<TablePreferences>({})
|
|
14
|
+
|
|
15
|
+
const load = async () => {
|
|
16
|
+
try {
|
|
17
|
+
const data = await api.get(`auth/me/preferences/${prefKey}`)
|
|
18
|
+
preferences.value = data?.value ?? {}
|
|
19
|
+
} catch {
|
|
20
|
+
preferences.value = {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const save = useDebounceFn(async (value: TablePreferences) => {
|
|
25
|
+
try {
|
|
26
|
+
await api.put(`auth/me/preferences/${prefKey}`, { value, cast: 'json' })
|
|
27
|
+
} catch {
|
|
28
|
+
// silent fail — preferencias no son críticas
|
|
29
|
+
}
|
|
30
|
+
}, 800)
|
|
31
|
+
|
|
32
|
+
return { preferences, load, save }
|
|
33
|
+
}
|