@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: { type: Object, default: null },
6
- endpoint: { type: String, default: '' },
7
- columns: { type: Array, required: true },
8
- name: { type: String, default: '' },
9
- params: { type: Object, default: () => ({}) },
10
- checkable: { type: Boolean, default: false },
11
- cached: { type: Boolean, default: false },
12
- showReloadButton: { type: Boolean, default: true },
13
- clickRowToOpen: { type: Boolean, default: false },
14
- searchPlaceholder: { type: String, default: 'Buscar...' },
15
- showSearch: { type: Boolean, default: true },
16
- showFilters: { type: Boolean, default: true },
17
- showExport: { type: Boolean, default: true },
18
- filters: { type: Array, default: () => [] },
19
- splitRatio: { type: Number, default: 60 },
20
- autoClosePreview: { type: Boolean, default: true },
21
- previewHref: { type: [String, Function], default: null }, // url fija o (row) => url
22
- previewDeletable: { type: Boolean, default: false },
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
- const dragOverKey = ref(null)
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 onDragStart = (key) => { draggedKey = key }
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 p-3 min-w-56 max-h-80 overflow-y-auto"
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
- <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2 px-1">
924
- Columnas visibles
925
- </p>
926
- <div
927
- v-for="col in orderedColumns"
928
- :key="col.key"
929
- draggable="true"
930
- @dragstart="onDragStart(col.key)"
931
- @dragover="(e) => onDragOver(e, col.key)"
932
- @dragleave="onDragLeave"
933
- @drop="onDrop(col.key)"
934
- class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none transition-colors"
935
- :class="dragOverKey === col.key
936
- ? 'bg-blue-50 dark:bg-blue-900/20 ring-1 ring-blue-300 dark:ring-blue-700'
937
- : 'hover:bg-muted-hover cursor-grab'"
938
- >
939
- <IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
940
- <input
941
- type="checkbox"
942
- :checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
943
- @change="tableRef?.table.getColumn(col.key)?.toggleVisibility()"
944
- @click.stop
945
- class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
946
- />
947
- <span class="text-sm text-foreground truncate">{{ col.label }}</span>
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
- showPerPage: { type: Boolean, default: false },
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.getVisibleLeafColumns()"
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-background">
575
+ <thead class="relative z-20 bg-card">
479
576
  <template v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
480
- <tr>
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 overflow-hidden"
588
+ class="relative"
491
589
  :class="[
492
590
  header.id === 'select' ? 'text-center' : '',
493
- dragOverHeaderId === header.id ? 'bg-primary/5 dark:bg-primary/10' : '',
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-primary focus:ring-0 focus:ring-offset-0 dark:bg-card"
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 class="px-4 py-3 flex items-center gap-x-1 text-xs font-medium text-muted-foreground w-full">
509
- {{ header.column.columnDef.meta?.label ?? header.id }}
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-primary/60 !w-0.5'
531
- : 'bg-surface-1 group-hover/rz:bg-primary/40 dark:group-hover/rz:bg-primary group-hover/rz:w-0.5'"
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="header.id === 'select' ? 'w-12' : 'px-3 py-1.5'"
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-primary/20 focus:border-primary outline-none transition-all"
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-background"
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="header.id === 'select' ? 'text-center w-12' : 'px-4'"
571
- :style="{ height: lastRowHeight + 'px' }"
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-background"
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-background"
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-background hover:bg-layer-hover transition-colors"
727
+ class="bg-card hover:bg-layer-hover transition-colors"
616
728
  :class="{
617
729
  'cursor-pointer': isRowClickEnabled,
618
- 'bg-primary/5 dark:bg-primary/10 hover:bg-primary/10': row.getIsSelected(),
619
- '!bg-primary/10 dark:!bg-primary/15 ring-1 ring-inset ring-primary/30 dark:ring-primary/40': previewRowId && row.original.id === previewRowId,
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-background"
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-background/60 rounded-xl"
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-background/60 rounded-xl"
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-primary/60': row.getIsSelected() }">
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
- <div v-if="showPerPage" class="flex items-center gap-x-2">
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-primary/20 py-1 px-2"
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-primary font-bold hover:underline">Volver</button>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.142",
3
+ "version": "0.1.143",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -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
+ }