@innertia-solutions/innertia-nuxt 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.github/workflows/auto-publish.yml +64 -0
  2. package/.github/workflows/release.yml +59 -0
  3. package/README.md +60 -0
  4. package/app.config.ts +70 -0
  5. package/components/Admin/Base.vue +144 -0
  6. package/components/Admin/Header.vue +32 -0
  7. package/components/Admin/Page.vue +65 -0
  8. package/components/Admin/PageHeader.vue +31 -0
  9. package/components/App/Button.vue +59 -0
  10. package/components/App/DevEnvironmentBar.vue +43 -0
  11. package/components/App/Dropdown.vue +286 -0
  12. package/components/App/EmptyState.vue +433 -0
  13. package/components/App/LoadingState.vue +40 -0
  14. package/components/App/PageLoadingSpinner.vue +118 -0
  15. package/components/App/PreviewDock.vue +64 -0
  16. package/components/App/SwitchColorTheme.vue +51 -0
  17. package/components/App/Tag.vue +193 -0
  18. package/components/DataTable.vue +713 -0
  19. package/components/Forms/DatePicker.vue +255 -0
  20. package/components/Forms/Input.vue +75 -0
  21. package/components/Forms/Select.vue +100 -0
  22. package/components/Forms/SelectServer.vue +726 -0
  23. package/components/Layout/Admin.vue +32 -0
  24. package/components/Layout/Auth.vue +29 -0
  25. package/components/Layout/SidebarWithAppColumn.vue +388 -0
  26. package/components/Layout/TopBar.vue +113 -0
  27. package/components/MobileBlocker.vue +85 -0
  28. package/components/MobileLoginPicker.vue +83 -0
  29. package/components/Modal/Base.vue +29 -0
  30. package/components/Modal/DeleteConfirm.vue +48 -0
  31. package/components/Modal.vue +103 -0
  32. package/components/Nav/Tabs.vue +55 -0
  33. package/components/PermissionsTree.vue +272 -0
  34. package/components/Table/Database.vue +183 -0
  35. package/components/Table/DownloadDropdown.vue +111 -0
  36. package/components/Table/Enterprise.vue +540 -0
  37. package/components/Table/FilterDropdown.vue +226 -0
  38. package/components/Table/Grid.vue +62 -0
  39. package/components/Table/Kanban.vue +188 -0
  40. package/components/Table/List.vue +128 -0
  41. package/components/Table/PreviewTimeline.vue +118 -0
  42. package/components/Table/Standard.vue +1217 -0
  43. package/components/Table/index.vue +974 -0
  44. package/components/TableExportable.vue +172 -0
  45. package/components/TableFilter.vue +93 -0
  46. package/components/Toast/Alert.vue +113 -0
  47. package/components/Toast/Container.vue +34 -0
  48. package/components/Toast/Notification.vue +45 -0
  49. package/components/Toast/Process.vue +88 -0
  50. package/composables/useApi.js +95 -0
  51. package/composables/useApp.ts +46 -0
  52. package/composables/useAuth.js +82 -0
  53. package/composables/useContext.js +44 -0
  54. package/composables/useDate.js +241 -0
  55. package/composables/useDevice.js +21 -0
  56. package/composables/useDockedPreviews.js +56 -0
  57. package/composables/useDownload.js +87 -0
  58. package/composables/useEntity.js +82 -0
  59. package/composables/useForm.js +119 -0
  60. package/composables/useInnertiaMode.ts +25 -0
  61. package/composables/useMobileGuard.ts +81 -0
  62. package/composables/useNotifications.js +22 -0
  63. package/composables/usePermissions.js +23 -0
  64. package/composables/useRealtime.js +123 -0
  65. package/composables/useRequestInterceptors.js +27 -0
  66. package/composables/useRoles.js +53 -0
  67. package/composables/useRutFormatter.js +39 -0
  68. package/composables/useTable.ts +94 -0
  69. package/composables/useTablePreferences.ts +33 -0
  70. package/composables/useTenant.js +27 -0
  71. package/composables/useTimeAgo.js +37 -0
  72. package/composables/useToast.js +69 -0
  73. package/composables/useUserRealtime.js +17 -0
  74. package/composables/useUsers.js +111 -0
  75. package/css/themes/autumn.css +401 -0
  76. package/css/themes/bubblegum.css +408 -0
  77. package/css/themes/cashmere.css +412 -0
  78. package/css/themes/harvest.css +416 -0
  79. package/css/themes/moon.css +140 -0
  80. package/css/themes/ocean.css +273 -0
  81. package/css/themes/olive.css +413 -0
  82. package/css/themes/retro.css +431 -0
  83. package/css/themes/theme.css +725 -0
  84. package/error.vue +78 -0
  85. package/middleware/01.detect-subdomain.global.ts +43 -0
  86. package/middleware/02.validate-tenant.global.ts +67 -0
  87. package/middleware/03.apps.global.ts +88 -0
  88. package/middleware/auth.ts +9 -0
  89. package/middleware/guest.ts +9 -0
  90. package/nuxt.config.ts +42 -0
  91. package/package.json +60 -0
  92. package/pages/tenant-error.vue +50 -0
  93. package/plugins/api-auth.ts +12 -0
  94. package/plugins/api-tenant.client.ts +21 -0
  95. package/plugins/appearance.ts +8 -0
  96. package/plugins/auth-init.ts +34 -0
  97. package/plugins/dark-state.client.ts +29 -0
  98. package/plugins/dockedPreviewsSync.client.js +17 -0
  99. package/plugins/preline.client.ts +68 -0
  100. package/plugins/theme.client.ts +7 -0
  101. package/plugins/vue-query.ts +29 -0
  102. package/public/init-theme.js +15 -0
  103. package/spark.css +721 -0
  104. package/stores/auth.js +130 -0
  105. package/stores/dockedPreviews.js +34 -0
  106. package/stores/notifications.js +24 -0
  107. package/stores/tenant.js +54 -0
  108. package/stores/toast.js +129 -0
@@ -0,0 +1,540 @@
1
+ <script setup>
2
+ import { IconSearch, IconAdjustmentsHorizontal, IconLayoutColumns, IconGripVertical, IconDownload, IconBookmark, IconChevronDown, IconX } from '@tabler/icons-vue'
3
+
4
+ // ─── Props ────────────────────────────────────────────────────────────────────
5
+
6
+ const props = defineProps({
7
+ // DataTable wiring
8
+ table: { type: Object, default: null },
9
+ endpoint: { type: String, default: '' },
10
+ endpointParams: { type: Object, default: () => ({}) },
11
+ columns: { type: Array, required: true },
12
+ name: { type: String, default: '' },
13
+ params: { type: Object, default: () => ({}) },
14
+ cached: { type: Boolean, default: true },
15
+ showReloadButton: { type: Boolean, default: true },
16
+ clickRowToOpen: { type: Boolean, default: false },
17
+ // Toolbar
18
+ searchPlaceholder: { type: String, default: 'Buscar...' },
19
+ showSearch: { type: Boolean, default: true },
20
+ showFilters: { type: Boolean, default: true },
21
+ showColumns: { type: Boolean, default: true },
22
+ showExport: { type: Boolean, default: true },
23
+ showSaveView: { type: Boolean, default: false },
24
+ // Checkboxes
25
+ checkable: { type: Boolean, default: true },
26
+ // Preview panel tabs: [{ key: 'resumen', label: 'Resumen' }, ...]
27
+ previewTabs: { type: Array, default: () => [] },
28
+ // Split ratio
29
+ splitRatio: { type: Number, default: 55 },
30
+ // Filters as chips in the filter bar: [{ key, label, options: [{value,label}] }]
31
+ filterChips: { type: Array, default: () => [] },
32
+ })
33
+
34
+ const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
35
+ const resolvedName = computed(() => props.table?.name ?? props.name)
36
+
37
+ // ─── Emits / Slots ────────────────────────────────────────────────────────────
38
+
39
+ const emit = defineEmits(['row-click', 'loaded', 'save-view'])
40
+ const slots = useSlots()
41
+
42
+ const excludedSlots = new Set(['toolbar', 'action', 'filter-bar', 'preview', 'preview-header'])
43
+ const forwardedSlots = computed(() =>
44
+ Object.fromEntries(Object.entries(slots).filter(([k]) => !excludedSlots.has(k)))
45
+ )
46
+
47
+ // ─── Search & filters ─────────────────────────────────────────────────────────
48
+
49
+ const search = ref('')
50
+ const activeFilters = ref({})
51
+
52
+ // Filter chips state: { [key]: selectedValue }
53
+ const chipValues = reactive(
54
+ Object.fromEntries((props.filterChips ?? []).map(f => [f.key, '']))
55
+ )
56
+ const openChip = ref(null)
57
+
58
+ const clearChips = () => {
59
+ for (const key of Object.keys(chipValues)) chipValues[key] = ''
60
+ }
61
+ const hasActiveChips = computed(() =>
62
+ Object.values(chipValues).some(v => v !== '' && v !== null && v !== undefined)
63
+ )
64
+
65
+ // Advanced filter panel
66
+ const showFilterPanel = ref(false)
67
+ const filterPanelRef = ref(null)
68
+
69
+ const filtersConfig = computed(() =>
70
+ props.columns.filter(c => c.filterType)
71
+ )
72
+ const activeFilterCount = computed(() =>
73
+ Object.values(activeFilters.value).filter(v => v !== null && v !== undefined && v !== '').length
74
+ )
75
+
76
+ const mergedParams = computed(() => ({
77
+ ...props.params,
78
+ ...props.endpointParams,
79
+ ...activeFilters.value,
80
+ ...Object.fromEntries(Object.entries(chipValues).filter(([, v]) => v !== '')),
81
+ }))
82
+
83
+ // ─── Preview panel ────────────────────────────────────────────────────────────
84
+
85
+ const previewRow = ref(null)
86
+ const currentRatio = ref(props.splitRatio)
87
+ const containerRef = ref(null)
88
+ const previewEnabled = ref(false)
89
+ const paginationHeight = ref(0)
90
+ const previewFromCache = ref(false)
91
+
92
+ const previewCacheKey = computed(() => `table-enterprise-preview-${resolvedName.value}`)
93
+
94
+ // Active preview tab
95
+ const firstTabKey = computed(() =>
96
+ props.previewTabs?.length ? props.previewTabs[0].key : 'datos'
97
+ )
98
+ const previewTab = ref(firstTabKey.value)
99
+ watch(previewRow, () => { previewTab.value = firstTabKey.value })
100
+
101
+ const closePreview = () => { previewRow.value = null }
102
+
103
+ const tableRef = ref(null)
104
+ const tableMeta = ref(null)
105
+
106
+ const handleRowClick = (row) => {
107
+ if (previewEnabled.value) {
108
+ previewRow.value = previewRow.value?.id === row.id ? null : row
109
+ } else {
110
+ emit('row-click', row)
111
+ }
112
+ }
113
+
114
+ const handleLoaded = (res) => {
115
+ emit('loaded', res)
116
+ if (res?.meta) tableMeta.value = res.meta
117
+ if (previewRow.value && Array.isArray(res?.data)) {
118
+ const fresh = res.data.find(r => r.id === previewRow.value.id)
119
+ if (fresh) previewRow.value = fresh
120
+ else closePreview()
121
+ }
122
+ }
123
+
124
+ // Persist preview in session cache
125
+ watch(previewRow, (row) => {
126
+ if (!props.cached) return
127
+ if (row) sessionStorage.setItem(previewCacheKey.value, JSON.stringify(row))
128
+ else sessionStorage.removeItem(previewCacheKey.value)
129
+ })
130
+
131
+ // Track pagination height
132
+ let paginationObserver = null
133
+ watch(() => tableRef.value?.paginationBarRef, (el) => {
134
+ paginationObserver?.disconnect()
135
+ paginationObserver = null
136
+ if (!el) return
137
+ paginationHeight.value = el.offsetHeight
138
+ paginationObserver = new ResizeObserver(() => { paginationHeight.value = el.offsetHeight })
139
+ paginationObserver.observe(el)
140
+ }, { flush: 'post' })
141
+
142
+ // Resize handle
143
+ const startResize = (e) => {
144
+ e.preventDefault()
145
+ const onMove = (ev) => {
146
+ if (!containerRef.value) return
147
+ const rect = containerRef.value.getBoundingClientRect()
148
+ const ratio = ((ev.clientX - rect.left) / rect.width) * 100
149
+ currentRatio.value = Math.min(80, Math.max(25, ratio))
150
+ }
151
+ const onUp = () => {
152
+ window.removeEventListener('mousemove', onMove)
153
+ window.removeEventListener('mouseup', onUp)
154
+ }
155
+ window.addEventListener('mousemove', onMove)
156
+ window.addEventListener('mouseup', onUp)
157
+ }
158
+
159
+ // ─── Column panel ─────────────────────────────────────────────────────────────
160
+
161
+ const showColumnPanel = ref(false)
162
+ const columnPanelRef = ref(null)
163
+ const columnButtonRef = ref(null)
164
+ const columnPanelStyle = ref({})
165
+
166
+ const orderedColumns = computed(() => {
167
+ if (!tableRef.value) return props.columns.filter(c => c.label)
168
+ const ids = tableRef.value.table.getAllLeafColumns().map(c => c.id).filter(id => id !== 'select')
169
+ return ids.map(id => props.columns.find(c => c.key === id)).filter(c => c?.label)
170
+ })
171
+
172
+ let draggedKey = null
173
+ const dragOverKey = ref(null)
174
+ const onDragStart = (key) => { draggedKey = key }
175
+ const onDragOver = (e, key) => { e.preventDefault(); dragOverKey.value = key }
176
+ const onDragLeave = () => { dragOverKey.value = null }
177
+ const onDrop = (key) => {
178
+ if (!draggedKey || draggedKey === key) return
179
+ const ids = tableRef.value?.table.getAllLeafColumns().map(c => c.id) ?? []
180
+ const from = ids.indexOf(draggedKey)
181
+ const to = ids.indexOf(key)
182
+ if (from < 0 || to < 0) return
183
+ ids.splice(from, 1); ids.splice(to, 0, draggedKey)
184
+ const selIdx = ids.indexOf('select')
185
+ if (selIdx > 0) { ids.splice(selIdx, 1); ids.unshift('select') }
186
+ tableRef.value?.setColumnOrder(ids)
187
+ draggedKey = null; dragOverKey.value = null
188
+ }
189
+
190
+ // ─── Outside-click helpers ────────────────────────────────────────────────────
191
+
192
+ const onColumnOutsideClick = (e) => {
193
+ if (
194
+ columnPanelRef.value && !columnPanelRef.value.contains(e.target) &&
195
+ columnButtonRef.value && !columnButtonRef.value.contains(e.target)
196
+ ) showColumnPanel.value = false
197
+ }
198
+ const onFilterOutsideClick = (e) => {
199
+ if (filterPanelRef.value && !filterPanelRef.value.contains(e.target)) showFilterPanel.value = false
200
+ }
201
+
202
+ watch(showColumnPanel, async (v) => {
203
+ if (v) {
204
+ await nextTick()
205
+ const rect = columnButtonRef.value?.getBoundingClientRect()
206
+ if (rect) columnPanelStyle.value = { top: rect.bottom + 6 + 'px', right: window.innerWidth - rect.right + 'px' }
207
+ document.addEventListener('mousedown', onColumnOutsideClick)
208
+ } else {
209
+ document.removeEventListener('mousedown', onColumnOutsideClick)
210
+ }
211
+ })
212
+ watch(showFilterPanel, (v) => {
213
+ if (v) document.addEventListener('mousedown', onFilterOutsideClick)
214
+ else document.removeEventListener('mousedown', onFilterOutsideClick)
215
+ })
216
+
217
+ // ─── Lifecycle ────────────────────────────────────────────────────────────────
218
+
219
+ const onEsc = (e) => { if (e.key === 'Escape' && previewRow.value) closePreview() }
220
+ onMounted(async () => {
221
+ previewEnabled.value = !!(slots.preview)
222
+ window.addEventListener('keydown', onEsc)
223
+ if (props.cached && previewEnabled.value) {
224
+ try {
225
+ const raw = sessionStorage.getItem(previewCacheKey.value)
226
+ if (raw) {
227
+ previewFromCache.value = true
228
+ previewRow.value = JSON.parse(raw)
229
+ await nextTick()
230
+ previewFromCache.value = false
231
+ }
232
+ } catch {}
233
+ }
234
+ })
235
+ onBeforeUnmount(() => {
236
+ window.removeEventListener('keydown', onEsc)
237
+ paginationObserver?.disconnect()
238
+ })
239
+
240
+ // ─── Expose ───────────────────────────────────────────────────────────────────
241
+
242
+ const getSelectedRows = () => tableRef.value?.getSelectedRows()
243
+ const reload = () => tableRef.value?.reload()
244
+ const clearCache = () => tableRef.value?.clearCache()
245
+ const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
246
+ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview })
247
+ </script>
248
+
249
+ <template>
250
+ <div class="relative" ref="containerRef">
251
+ <div class="bg-card border border-card-line rounded-2xl shadow-sm overflow-hidden">
252
+
253
+ <!-- ── Toolbar ──────────────────────────────────────────────────────── -->
254
+ <div class="flex flex-wrap items-center gap-2 px-4 py-3 border-b border-card-line">
255
+
256
+ <!-- Search -->
257
+ <div v-if="showSearch" class="flex-1 min-w-48">
258
+ <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
259
+ </div>
260
+
261
+ <!-- Advanced filters button -->
262
+ <button
263
+ v-if="showFilters && filtersConfig.length > 0"
264
+ type="button"
265
+ @click="showFilterPanel = !showFilterPanel"
266
+ :class="[
267
+ 'py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border transition-colors',
268
+ showFilterPanel || activeFilterCount > 0
269
+ ? 'border-primary/50 bg-primary/5 text-primary dark:bg-primary/10'
270
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
271
+ ]"
272
+ >
273
+ <IconAdjustmentsHorizontal class="size-4" stroke="1.5" />
274
+ Filtros
275
+ <span v-if="activeFilterCount > 0" class="text-xs font-bold">({{ activeFilterCount }})</span>
276
+ <IconChevronDown class="size-3.5" stroke="2" />
277
+ </button>
278
+
279
+ <!-- Toolbar slot (custom filter dropdowns, etc.) -->
280
+ <slot name="toolbar" />
281
+
282
+ <!-- Spacer -->
283
+ <div class="flex-1" />
284
+
285
+ <!-- Save view -->
286
+ <button
287
+ v-if="showSaveView"
288
+ type="button"
289
+ class="py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover transition-colors"
290
+ @click="emit('save-view')"
291
+ >
292
+ <IconBookmark class="size-4" stroke="1.5" />
293
+ Guardar vista
294
+ </button>
295
+
296
+ <!-- Columns -->
297
+ <button
298
+ v-if="showColumns"
299
+ ref="columnButtonRef"
300
+ type="button"
301
+ @click="showColumnPanel = !showColumnPanel"
302
+ :class="[
303
+ 'py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border transition-colors',
304
+ showColumnPanel
305
+ ? 'border-primary/50 bg-primary/5 text-primary dark:bg-primary/10'
306
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
307
+ ]"
308
+ >
309
+ <IconLayoutColumns class="size-4" stroke="1.5" />
310
+ Columnas
311
+ </button>
312
+
313
+ <!-- Export -->
314
+ <TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
315
+
316
+ <!-- Primary action slot -->
317
+ <slot name="action" />
318
+ </div>
319
+
320
+ <!-- ── Filter bar (chips) ───────────────────────────────────────────── -->
321
+ <div
322
+ v-if="filterChips.length > 0 || $slots['filter-bar']"
323
+ class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-card-line bg-surface/40"
324
+ >
325
+ <!-- Built-in chips from filterChips prop -->
326
+ <template v-if="filterChips.length > 0">
327
+ <div
328
+ v-for="chip in filterChips"
329
+ :key="chip.key"
330
+ class="relative"
331
+ >
332
+ <button
333
+ type="button"
334
+ @click="openChip = openChip === chip.key ? null : chip.key"
335
+ :class="[
336
+ 'inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-sm border transition-colors',
337
+ chipValues[chip.key]
338
+ ? 'border-primary/50 bg-primary/5 text-primary font-medium dark:bg-primary/10'
339
+ : 'border-card-line bg-card text-muted-foreground hover:bg-muted-hover',
340
+ ]"
341
+ >
342
+ <span class="text-muted-foreground">{{ chip.label }}:</span>
343
+ <span class="font-medium">
344
+ {{ chip.options?.find(o => o.value === chipValues[chip.key])?.label ?? 'Todos' }}
345
+ </span>
346
+ <IconChevronDown class="size-3" stroke="2" />
347
+ </button>
348
+
349
+ <!-- Chip dropdown -->
350
+ <Transition
351
+ enter-active-class="transition ease-out duration-100"
352
+ enter-from-class="opacity-0 scale-95"
353
+ enter-to-class="opacity-100 scale-100"
354
+ leave-active-class="transition ease-in duration-75"
355
+ leave-from-class="opacity-100 scale-100"
356
+ leave-to-class="opacity-0 scale-95"
357
+ >
358
+ <div
359
+ v-if="openChip === chip.key"
360
+ v-click-outside="() => openChip = null"
361
+ class="absolute top-full left-0 z-50 mt-1 bg-dropdown border border-dropdown-line rounded-xl shadow-xl py-1 min-w-40"
362
+ >
363
+ <button
364
+ type="button"
365
+ class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted-hover transition-colors"
366
+ :class="chipValues[chip.key] === '' ? 'font-semibold text-foreground' : 'text-muted-foreground'"
367
+ @click="chipValues[chip.key] = ''; openChip = null"
368
+ >Todos</button>
369
+ <button
370
+ v-for="opt in chip.options"
371
+ :key="opt.value"
372
+ type="button"
373
+ class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted-hover transition-colors"
374
+ :class="chipValues[chip.key] === opt.value ? 'font-semibold text-foreground' : 'text-muted-foreground'"
375
+ @click="chipValues[chip.key] = opt.value; openChip = null"
376
+ >{{ opt.label }}</button>
377
+ </div>
378
+ </Transition>
379
+ </div>
380
+
381
+ <button
382
+ v-if="hasActiveChips"
383
+ type="button"
384
+ class="inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
385
+ @click="clearChips"
386
+ >
387
+ <IconX class="size-3" stroke="2" />
388
+ Limpiar filtros
389
+ </button>
390
+ </template>
391
+
392
+ <!-- Custom filter bar slot -->
393
+ <slot name="filter-bar" />
394
+ </div>
395
+
396
+ <!-- Advanced filter panel -->
397
+ <Transition
398
+ enter-active-class="transition ease-out duration-150"
399
+ enter-from-class="opacity-0 translate-y-1 scale-95"
400
+ enter-to-class="opacity-100 translate-y-0 scale-100"
401
+ leave-active-class="transition ease-in duration-100"
402
+ leave-from-class="opacity-100 translate-y-0 scale-100"
403
+ leave-to-class="opacity-0 translate-y-1 scale-95"
404
+ >
405
+ <div
406
+ v-if="showFilterPanel"
407
+ ref="filterPanelRef"
408
+ class="absolute top-14 left-4 z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 min-w-64 max-h-96 overflow-y-auto"
409
+ >
410
+ <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-3 px-1">Filtros avanzados</p>
411
+ <TableFilter v-model="activeFilters" :columns="filtersConfig" />
412
+ </div>
413
+ </Transition>
414
+
415
+ <!-- ── Table + Preview ──────────────────────────────────────────────── -->
416
+ <div class="relative overflow-hidden">
417
+
418
+ <Table
419
+ ref="tableRef"
420
+ :endpoint="resolvedEndpoint"
421
+ :columns="columns"
422
+ :name="resolvedName"
423
+ :params="mergedParams"
424
+ :search="search"
425
+ :checkable="checkable"
426
+ :cached="cached"
427
+ :show-reload-button="showReloadButton"
428
+ :click-row-to-open="clickRowToOpen"
429
+ :preview-row-id="previewRow?.id ?? null"
430
+ :preview-mode="!!previewEnabled"
431
+ @row-click="handleRowClick"
432
+ @loaded="handleLoaded"
433
+ @page-change="closePreview"
434
+ @per-page-change="closePreview"
435
+ >
436
+ <template v-for="(_, sname) in forwardedSlots" #[sname]="slotProps">
437
+ <slot :name="sname" v-bind="slotProps ?? {}" />
438
+ </template>
439
+ </Table>
440
+
441
+ <!-- Preview overlay -->
442
+ <Transition
443
+ :enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
444
+ :enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
445
+ :enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
446
+ leave-active-class="transition ease-in duration-150"
447
+ leave-from-class="opacity-100 translate-x-0"
448
+ leave-to-class="opacity-0 translate-x-6"
449
+ >
450
+ <div
451
+ v-if="previewRow && previewEnabled"
452
+ class="absolute top-0 right-0 z-30 flex bg-card border-l border-card-line shadow-[-4px_0_16px_rgba(0,0,0,0.06)]"
453
+ :style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
454
+ >
455
+ <!-- Resize handle -->
456
+ <div
457
+ class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-primary/40 transition-colors"
458
+ @mousedown="startResize"
459
+ />
460
+
461
+ <div class="flex flex-col flex-1 overflow-hidden">
462
+
463
+ <!-- Fixed header -->
464
+ <div v-if="$slots['preview-header']" class="shrink-0 border-b border-card-line">
465
+ <slot name="preview-header" :row="previewRow" :close="closePreview" />
466
+ </div>
467
+
468
+ <!-- Tabs (top) — only if previewTabs provided -->
469
+ <div
470
+ v-if="previewTabs && previewTabs.length > 1"
471
+ class="shrink-0 flex border-b border-card-line overflow-x-auto"
472
+ >
473
+ <button
474
+ v-for="tab in previewTabs"
475
+ :key="tab.key"
476
+ type="button"
477
+ @click="previewTab = tab.key"
478
+ :class="[
479
+ 'px-4 py-2.5 text-xs font-semibold whitespace-nowrap transition-colors border-b-2 -mb-px',
480
+ previewTab === tab.key
481
+ ? 'border-primary text-primary'
482
+ : 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover',
483
+ ]"
484
+ >{{ tab.label }}</button>
485
+ </div>
486
+
487
+ <!-- Scrollable preview content -->
488
+ <div class="flex-1 overflow-y-auto min-h-0">
489
+ <slot name="preview" :row="previewRow" :tab="previewTab" :close="closePreview" />
490
+ </div>
491
+
492
+ </div>
493
+ </div>
494
+ </Transition>
495
+ </div>
496
+ </div>
497
+
498
+ <!-- Column panel (teleported) -->
499
+ <Teleport to="body">
500
+ <Transition
501
+ enter-active-class="transition ease-out duration-150"
502
+ enter-from-class="opacity-0 translate-y-1 scale-95"
503
+ enter-to-class="opacity-100 translate-y-0 scale-100"
504
+ leave-active-class="transition ease-in duration-100"
505
+ leave-from-class="opacity-100 translate-y-0 scale-100"
506
+ leave-to-class="opacity-0 translate-y-1 scale-95"
507
+ >
508
+ <div
509
+ v-if="showColumnPanel"
510
+ ref="columnPanelRef"
511
+ 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"
512
+ :style="columnPanelStyle"
513
+ >
514
+ <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2 px-1">Columnas visibles</p>
515
+ <div
516
+ v-for="col in orderedColumns"
517
+ :key="col.key"
518
+ draggable="true"
519
+ @dragstart="onDragStart(col.key)"
520
+ @dragover="(e) => onDragOver(e, col.key)"
521
+ @dragleave="onDragLeave"
522
+ @drop="onDrop(col.key)"
523
+ class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none transition-colors"
524
+ :class="dragOverKey === col.key ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted-hover cursor-grab'"
525
+ >
526
+ <IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
527
+ <input
528
+ type="checkbox"
529
+ :checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
530
+ @change="tableRef?.table.getColumn(col.key)?.toggleVisibility()"
531
+ @click.stop
532
+ class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
533
+ />
534
+ <span class="text-sm text-foreground truncate">{{ col.label }}</span>
535
+ </div>
536
+ </div>
537
+ </Transition>
538
+ </Teleport>
539
+ </div>
540
+ </template>