@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,1217 @@
1
+ <script setup>
2
+ import { IconSearch, IconLayoutColumns, IconGripVertical, IconMinus, IconMaximize, IconX, IconPlus, IconChevronLeft, IconCheck, IconChevronDown, IconExternalLink, IconTrash } from '@tabler/icons-vue'
3
+ import Table from './index.vue'
4
+
5
+ const props = defineProps({
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
26
+ })
27
+
28
+ const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
29
+ const resolvedName = computed(() => props.table?.name ?? props.name)
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
+
42
+ const emit = defineEmits(['row-click', 'loaded', 'preview-delete'])
43
+ const slots = useSlots()
44
+ const forwardedSlots = computed(() => {
45
+ const excluded = new Set(['toolbar', 'preview'])
46
+ return Object.fromEntries(Object.entries(slots).filter(([k]) => !excluded.has(k)))
47
+ })
48
+
49
+ const search = ref('')
50
+ const activeFilters = ref({})
51
+ const tableRef = ref(null)
52
+ const prefsLoaded = ref(false)
53
+
54
+ // ─── Filter config ─────────────────────────────────────────────────────────────
55
+ const filtersConfig = computed(() =>
56
+ props.filters?.length ? props.filters : props.columns.filter(c => c.filterType)
57
+ )
58
+
59
+ const hasFilterableColumns = computed(() => filtersConfig.value.length > 0)
60
+
61
+ // ─── Notion-style filter ───────────────────────────────────────────────────────
62
+ const showFilterPanel = ref(false)
63
+ const filterMenuStep = ref('columns') // 'columns' | 'value'
64
+ const pendingCol = ref(null)
65
+ const pendingValue = ref(null) // string for text/select, { singleDate, from, to } for daterange
66
+ const pendingDateOp = ref('before') // 'before' | 'after' | 'between'
67
+ const filterMenuRef = ref(null)
68
+ const filterAddBtnRef = ref(null)
69
+ const filterMenuStyle = ref({})
70
+
71
+ const pendingOperator = ref('contains')
72
+ const pendingValueInputRef = ref(null)
73
+
74
+ const textOps = [
75
+ { value: 'contains', label: 'contiene' },
76
+ { value: 'starts_with', label: 'empieza con' },
77
+ { value: 'equals', label: 'es igual a' },
78
+ ]
79
+
80
+ const selectOps = [
81
+ { value: 'is', label: 'es' },
82
+ { value: 'is_not', label: 'no es' },
83
+ ]
84
+
85
+ const dateOps = [
86
+ { value: 'before', label: 'antes de' },
87
+ { value: 'after', label: 'después de' },
88
+ { value: 'between', label: 'entre' },
89
+ ]
90
+
91
+ const activeFilterList = computed(() =>
92
+ filtersConfig.value
93
+ .filter(col => {
94
+ const v = activeFilters.value[col.key]
95
+ if (!v) return false
96
+ if (col.filterType === 'daterange') return v?.from || v?.to
97
+ return v?.value !== null && v?.value !== undefined && v?.value !== ''
98
+ })
99
+ .map(col => {
100
+ const v = activeFilters.value[col.key]
101
+ let displayOp = '', displayVal = ''
102
+ if (col.filterType === 'daterange') {
103
+ if (v.from && v.to) { displayOp = 'entre'; displayVal = `${v.from} y ${v.to}` }
104
+ else if (v.from) { displayOp = 'después de'; displayVal = v.from }
105
+ else { displayOp = 'antes de'; displayVal = v.to }
106
+ } else if (col.filterType === 'select') {
107
+ const op = selectOps.find(o => o.value === v.operator) ?? selectOps[0]
108
+ displayOp = op.label
109
+ displayVal = col.filterOptions?.find(o => o.value === v.value)?.label ?? v.value
110
+ } else {
111
+ const op = textOps.find(o => o.value === v.operator) ?? textOps[0]
112
+ displayOp = op.label
113
+ displayVal = v.value
114
+ }
115
+ return { key: col.key, label: col.label, displayOp, displayVal, col }
116
+ })
117
+ )
118
+
119
+ const activeFilterCount = computed(() => activeFilterList.value.length)
120
+
121
+ // Columns NOT yet filtered — what appears in the picker (already-active columns are hidden)
122
+ const availableFilterColumns = computed(() =>
123
+ filtersConfig.value.filter(col => {
124
+ const v = activeFilters.value[col.key]
125
+ if (!v) return true
126
+ if (col.filterType === 'daterange') return !v.from && !v.to
127
+ return !v.value
128
+ })
129
+ )
130
+
131
+ // Convert activeFilters to enriched [{field, operator, value}] for the backend DataTable
132
+ const enrichedFilters = computed(() => {
133
+ const result = []
134
+ for (const col of filtersConfig.value) {
135
+ const v = activeFilters.value[col.key]
136
+ if (!v) continue
137
+ if (col.filterType === 'daterange') {
138
+ if (!v.from && !v.to) continue
139
+ if (v.from) result.push({ field: col.key, operator: 'after', value: v.from })
140
+ if (v.to) result.push({ field: col.key, operator: 'before', value: v.to })
141
+ } else {
142
+ if (!v.value) continue
143
+ result.push({ field: col.key, operator: v.operator ?? 'contains', value: v.value })
144
+ }
145
+ }
146
+ return result
147
+ })
148
+
149
+ const mergedParams = computed(() => ({
150
+ ...props.params,
151
+ ...(enrichedFilters.value.length ? { filters: enrichedFilters.value } : {}),
152
+ }))
153
+
154
+ const removeFilter = (key) => {
155
+ const u = { ...activeFilters.value }; delete u[key]; activeFilters.value = u
156
+ }
157
+
158
+ const updateFilterMenuPosition = () => {
159
+ const rect = filterAddBtnRef.value?.getBoundingClientRect()
160
+ if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
161
+ }
162
+
163
+ const openFilterMenu = async () => {
164
+ filterMenuStep.value = 'columns'
165
+ pendingCol.value = null
166
+ showFilterPanel.value = true
167
+ await nextTick()
168
+ updateFilterMenuPosition()
169
+ }
170
+
171
+ const toggleFilterMenu = async () => {
172
+ if (showFilterPanel.value) { closeFilterMenu() } else { await openFilterMenu() }
173
+ }
174
+
175
+ const closeFilterMenu = () => {
176
+ showFilterPanel.value = false
177
+ filterMenuStep.value = 'columns'
178
+ pendingCol.value = null
179
+ pendingValue.value = null
180
+ }
181
+
182
+ const selectFilterColumn = (col) => {
183
+ pendingCol.value = col
184
+ const existing = activeFilters.value[col.key]
185
+ if (col.filterType === 'daterange') {
186
+ if (existing?.from && existing?.to) { pendingDateOp.value = 'between'; pendingValue.value = { from: existing.from, to: existing.to, singleDate: '' } }
187
+ else if (existing?.from) { pendingDateOp.value = 'after'; pendingValue.value = { singleDate: existing.from, from: '', to: '' } }
188
+ else if (existing?.to) { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: existing.to, from: '', to: '' } }
189
+ else { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: '', from: '', to: '' } }
190
+ } else {
191
+ pendingOperator.value = existing?.operator ?? (col.filterType === 'select' ? 'is' : 'contains')
192
+ pendingValue.value = existing?.value ?? ''
193
+ }
194
+ filterMenuStep.value = 'value'
195
+ }
196
+
197
+ const applyPendingFilter = () => {
198
+ if (!pendingCol.value) return
199
+ const col = pendingCol.value
200
+ let v
201
+ if (col.filterType === 'daterange') {
202
+ if (pendingDateOp.value === 'between') v = { from: pendingValue.value.from, to: pendingValue.value.to }
203
+ else if (pendingDateOp.value === 'after') v = { from: pendingValue.value.singleDate }
204
+ else v = { to: pendingValue.value.singleDate }
205
+ } else {
206
+ v = { value: pendingValue.value, operator: pendingOperator.value }
207
+ }
208
+ activeFilters.value = { ...activeFilters.value, [col.key]: v }
209
+ closeFilterMenu()
210
+ }
211
+
212
+ const openEditFilter = async (col) => {
213
+ selectFilterColumn(col)
214
+ showFilterPanel.value = true
215
+ await nextTick()
216
+ const rect = filterAddBtnRef.value?.getBoundingClientRect()
217
+ if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
218
+ }
219
+
220
+ const onFilterMenuOutsideClick = (e) => {
221
+ if (filterMenuRef.value && !filterMenuRef.value.contains(e.target) &&
222
+ filterAddBtnRef.value && !filterAddBtnRef.value.contains(e.target)) {
223
+ closeFilterMenu()
224
+ }
225
+ }
226
+ watch(showFilterPanel, v => {
227
+ if (v) {
228
+ document.addEventListener('mousedown', onFilterMenuOutsideClick)
229
+ window.addEventListener('scroll', updateFilterMenuPosition, true)
230
+ window.addEventListener('resize', updateFilterMenuPosition)
231
+ } else {
232
+ document.removeEventListener('mousedown', onFilterMenuOutsideClick)
233
+ window.removeEventListener('scroll', updateFilterMenuPosition, true)
234
+ window.removeEventListener('resize', updateFilterMenuPosition)
235
+ }
236
+ })
237
+
238
+ watch(filterMenuStep, async (step) => {
239
+ if (step === 'value' && pendingCol.value?.filterType === 'text') {
240
+ await nextTick()
241
+ pendingValueInputRef.value?.focus()
242
+ }
243
+ })
244
+
245
+ // ─── Preview href helper ───────────────────────────────────────────────────────
246
+ const resolvedPreviewHref = computed(() => {
247
+ if (!props.previewHref || !previewRow.value) return null
248
+ return typeof props.previewHref === 'function'
249
+ ? props.previewHref(previewRow.value)
250
+ : props.previewHref
251
+ })
252
+
253
+ // ─── Preview panel ─────────────────────────────────────────────────────────────
254
+ const previewRow = ref(null)
255
+ const currentRatio = ref(props.splitRatio)
256
+ const containerRef = ref(null)
257
+ const previewEnabled = ref(false)
258
+ const paginationHeight = ref(0)
259
+
260
+ const previewCacheKey = computed(() => `table-preview-${resolvedName.value}`)
261
+
262
+ const previewFromCache = ref(false)
263
+ const previewPanelRef = ref(null)
264
+ const closePreview = () => { previewRow.value = null }
265
+
266
+ const previewTab = ref('datos')
267
+ const tableMeta = ref(null)
268
+
269
+ const hasHistory = computed(() => !!tableMeta.value?.has_history)
270
+ const resolvedHistoryEndpoint = computed(() => {
271
+ if (!hasHistory.value || !previewRow.value?.id || !tableMeta.value?.entity_type) return null
272
+ return `history/${tableMeta.value.entity_type}/${previewRow.value.id}`
273
+ })
274
+
275
+ watch(previewRow, async (row) => {
276
+ previewTab.value = 'datos'
277
+ if (row) {
278
+ await nextTick()
279
+ if (typeof window !== 'undefined') window.HSStaticMethods?.autoInit?.(['Tooltip'])
280
+ }
281
+ })
282
+
283
+ const handleRowClick = (row) => {
284
+ if (previewEnabled.value) {
285
+ if (previewRow.value?.id === row.id) return // ya está abierto, no pestañear
286
+ collapseDock()
287
+ previewRow.value = row
288
+ } else {
289
+ emit('row-click', row)
290
+ }
291
+ }
292
+
293
+ // Persist preview row in session cache when table cache is enabled
294
+ watch(previewRow, (row) => {
295
+ if (!props.cached) return
296
+ if (row) sessionStorage.setItem(previewCacheKey.value, JSON.stringify(row))
297
+ else sessionStorage.removeItem(previewCacheKey.value)
298
+ })
299
+
300
+ // When data reloads, update previewRow with fresh data — close silently if deleted
301
+ const handleLoaded = (res) => {
302
+ emit('loaded', res)
303
+ if (res?.meta) tableMeta.value = res.meta
304
+ if (previewRow.value && Array.isArray(res?.data)) {
305
+ const fresh = res.data.find(r => r.id === previewRow.value.id)
306
+ if (fresh) previewRow.value = fresh
307
+ else closePreview()
308
+ }
309
+ }
310
+
311
+ // Track pagination bar height so the overlay never covers it
312
+ let paginationObserver = null
313
+ watch(() => tableRef.value?.paginationBarRef, (el) => {
314
+ paginationObserver?.disconnect()
315
+ paginationObserver = null
316
+ if (!el) return
317
+ paginationHeight.value = el.offsetHeight
318
+ paginationObserver = new ResizeObserver(() => {
319
+ paginationHeight.value = el.offsetHeight
320
+ })
321
+ paginationObserver.observe(el)
322
+ }, { flush: 'post' })
323
+
324
+ const startResize = (e) => {
325
+ e.preventDefault()
326
+ const onMove = (ev) => {
327
+ if (!containerRef.value) return
328
+ const rect = containerRef.value.getBoundingClientRect()
329
+ const ratio = ((ev.clientX - rect.left) / rect.width) * 100
330
+ currentRatio.value = Math.min(80, Math.max(25, ratio))
331
+ }
332
+ const onUp = () => {
333
+ window.removeEventListener('mousemove', onMove)
334
+ window.removeEventListener('mouseup', onUp)
335
+ }
336
+ window.addEventListener('mousemove', onMove)
337
+ window.addEventListener('mouseup', onUp)
338
+ }
339
+
340
+ const onEsc = (e) => { if (e.key === 'Escape') { if (previewRow.value) closePreview(); else collapseDock() } }
341
+
342
+ // ─── Auto-close preview on outside click ──────────────────────────────────────
343
+ const onDocMousedown = (e) => {
344
+ if (props.autoClosePreview && previewRow.value && previewPanelRef.value && !previewPanelRef.value.contains(e.target)) {
345
+ closePreview()
346
+ }
347
+ }
348
+
349
+ // ─── Dock (minimizar preview) ──────────────────────────────────────────────────
350
+ const {
351
+ docked,
352
+ dock, undock: undockItem, isActive,
353
+ activeDockId, activeDockRect,
354
+ expandDock, collapseDock,
355
+ } = useDockedPreviews()
356
+ const route = useRoute()
357
+
358
+ function minimizePreview() {
359
+ if (!previewRow.value) return
360
+ const label = previewRow.value.name ?? previewRow.value.title ?? previewRow.value.email ?? String(previewRow.value.id)
361
+ const subtitle = previewRow.value.email ?? previewRow.value.description ?? null
362
+ dock({
363
+ id: `${resolvedName.value}-${previewRow.value.id}`,
364
+ label,
365
+ subtitle,
366
+ row: { ...previewRow.value },
367
+ tableName: resolvedName.value,
368
+ route: route.path,
369
+ })
370
+ closePreview()
371
+ }
372
+
373
+ // Item que debe mostrarse como mini-preview flotante (pertenece a esta tabla)
374
+ const floatingItem = computed(() =>
375
+ activeDockId.value
376
+ ? docked.value.find(d => d.id === activeDockId.value && d.tableName === resolvedName.value) ?? null
377
+ : null
378
+ )
379
+
380
+ // Posición del panel flotante: centrado sobre el tab que lo abrió
381
+ const floatingPanelStyle = computed(() => {
382
+ const rect = activeDockRect.value
383
+ const panelW = 384
384
+ const bottom = 52
385
+ if (!rect || typeof window === 'undefined') return { bottom: bottom + 'px', right: '16px' }
386
+ const tabCenter = rect.left + rect.width / 2
387
+ let right = window.innerWidth - tabCenter - panelW / 2
388
+ right = Math.max(8, Math.min(right, window.innerWidth - panelW - 8))
389
+ return { bottom: bottom + 'px', right: right + 'px' }
390
+ })
391
+
392
+ function expandToFull(item) {
393
+ previewRow.value = item.row
394
+ undockItem(item.id)
395
+ }
396
+
397
+ // Escuchar evento de restauración (fallback cuando la tabla no estaba montada)
398
+ onMounted(() => {
399
+ useNuxtApp().hooks.hook('preview:restore', (item) => {
400
+ if (item.tableName === resolvedName.value) previewRow.value = item.row
401
+ })
402
+ })
403
+
404
+ onMounted(async () => {
405
+ previewEnabled.value = !!slots.preview
406
+ window.addEventListener('keydown', onEsc)
407
+ document.addEventListener('mousedown', onDocMousedown)
408
+ // Restore preview from session cache — mark as from-cache to skip enter animation
409
+ if (props.cached && previewEnabled.value) {
410
+ try {
411
+ const raw = sessionStorage.getItem(previewCacheKey.value)
412
+ if (raw) {
413
+ previewFromCache.value = true
414
+ previewRow.value = JSON.parse(raw)
415
+ await nextTick()
416
+ previewFromCache.value = false
417
+ }
418
+ } catch {}
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
438
+ })
439
+ onBeforeUnmount(() => {
440
+ window.removeEventListener('keydown', onEsc)
441
+ document.removeEventListener('mousedown', onDocMousedown)
442
+ paginationObserver?.disconnect()
443
+ })
444
+
445
+ // ─── Column panel ─────────────────────────────────────────────────────────────
446
+ const showColumnPanel = ref(false)
447
+ const columnPanelRef = ref(null)
448
+ const columnButtonRef = ref(null)
449
+ const columnPanelStyle = ref({})
450
+
451
+ const orderedColumns = computed(() => {
452
+ if (!tableRef.value) return props.columns.filter(c => c.label)
453
+ const ids = tableRef.value.table.getAllLeafColumns().map(c => c.id).filter(id => id !== 'select')
454
+ return ids.map(id => props.columns.find(c => c.key === id)).filter(c => c?.label)
455
+ })
456
+
457
+ let draggedKey = 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
+ })
478
+
479
+ const resetColDrag = () => {
480
+ draggedKey = null
481
+ draggedFromSection = null
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()
557
+ }
558
+
559
+ const onColumnPanelOutsideClick = (e) => {
560
+ if (
561
+ columnPanelRef.value && !columnPanelRef.value.contains(e.target) &&
562
+ columnButtonRef.value && !columnButtonRef.value.contains(e.target)
563
+ ) {
564
+ showColumnPanel.value = false
565
+ }
566
+ }
567
+
568
+ const updateColumnPanelPosition = () => {
569
+ const rect = columnButtonRef.value?.getBoundingClientRect()
570
+ if (rect) columnPanelStyle.value = { top: rect.bottom + 6 + 'px', right: window.innerWidth - rect.right + 'px' }
571
+ }
572
+
573
+ watch(showColumnPanel, async (v) => {
574
+ if (v) {
575
+ await nextTick()
576
+ updateColumnPanelPosition()
577
+ document.addEventListener('mousedown', onColumnPanelOutsideClick)
578
+ window.addEventListener('scroll', updateColumnPanelPosition, true)
579
+ window.addEventListener('resize', updateColumnPanelPosition)
580
+ } else {
581
+ document.removeEventListener('mousedown', onColumnPanelOutsideClick)
582
+ window.removeEventListener('scroll', updateColumnPanelPosition, true)
583
+ window.removeEventListener('resize', updateColumnPanelPosition)
584
+ }
585
+ })
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
+
628
+ // ─── Expose ───────────────────────────────────────────────────────────────────
629
+ const getSelectedRows = () => tableRef.value?.getSelectedRows()
630
+ const reload = () => tableRef.value?.reload()
631
+ const clearCache = () => tableRef.value?.clearCache()
632
+ const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
633
+ const pinColumn = (key, position) => tableRef.value?.pinColumn(key, position)
634
+
635
+ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview, pinColumn })
636
+ </script>
637
+
638
+ <template>
639
+ <div class="relative" ref="containerRef">
640
+
641
+ <!-- Toolbar row (no card) -->
642
+ <div class="flex flex-wrap items-center gap-2 mb-2">
643
+
644
+ <!-- Search -->
645
+ <div v-if="showSearch" class="flex-1 min-w-48 max-w-xs">
646
+ <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
647
+ </div>
648
+
649
+ <!-- + Filtros button -->
650
+ <div v-if="showFilters && hasFilterableColumns" ref="filterAddBtnRef" class="relative">
651
+ <button
652
+ type="button"
653
+ @click="toggleFilterMenu"
654
+ :class="[
655
+ 'inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-lg border transition-colors',
656
+ activeFilterList.length
657
+ ? 'border-primary/40 bg-primary/10 text-primary'
658
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
659
+ ]"
660
+ >
661
+ <IconPlus class="size-3.5" />
662
+ Filtros{{ activeFilterList.length ? ` (${activeFilterList.length})` : '' }}
663
+ </button>
664
+ </div>
665
+
666
+ <!-- Slot for custom toolbar buttons -->
667
+ <slot name="toolbar" />
668
+
669
+ <!-- Secondary actions: pushed to the right, icon-only style -->
670
+ <div class="ml-auto flex items-center gap-1">
671
+ <button
672
+ ref="columnButtonRef"
673
+ type="button"
674
+ @click="showColumnPanel = !showColumnPanel"
675
+ :title="'Columnas'"
676
+ :class="[
677
+ 'p-1.5 inline-flex items-center justify-center rounded-lg border transition-colors',
678
+ showColumnPanel
679
+ ? 'border-primary/40 bg-primary/10 text-primary'
680
+ : 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground'
681
+ ]"
682
+ >
683
+ <IconLayoutColumns class="size-4" />
684
+ </button>
685
+
686
+ <TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
687
+ </div>
688
+ </div>
689
+
690
+ <!-- Filter chips row (shown when filters active) -->
691
+ <div v-if="activeFilterList.length" class="flex flex-wrap items-center gap-1.5 mb-2">
692
+ <div
693
+ v-for="chip in activeFilterList"
694
+ :key="chip.key"
695
+ class="inline-flex items-center text-xs rounded-lg border border-card-line bg-card overflow-hidden"
696
+ >
697
+ <span class="px-2.5 py-1 text-foreground font-medium border-r border-card-line bg-surface">{{ chip.label }}</span>
698
+ <span class="px-2 py-1 text-muted-foreground">{{ chip.displayOp }}</span>
699
+ <button
700
+ type="button"
701
+ @click.stop="openEditFilter(chip.col)"
702
+ class="inline-flex items-center gap-1 px-2 py-1 text-primary font-medium hover:bg-primary/10 transition-colors border-x border-card-line"
703
+ >
704
+ {{ chip.displayVal }}
705
+ <IconChevronDown class="size-3 opacity-60" />
706
+ </button>
707
+ <button
708
+ type="button"
709
+ @click.stop="removeFilter(chip.key)"
710
+ class="px-1.5 py-1 text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
711
+ >
712
+ <IconX class="size-3" />
713
+ </button>
714
+ </div>
715
+ </div>
716
+
717
+ <!-- Tabla -->
718
+ <div class="overflow-hidden border-t border-b border-card-line">
719
+ <Table
720
+ ref="tableRef"
721
+ :endpoint="resolvedEndpoint"
722
+ :columns="columns"
723
+ :name="resolvedName"
724
+ :params="mergedParams"
725
+ :search="search"
726
+ :checkable="checkable"
727
+ :cached="cached"
728
+ :show-reload-button="showReloadButton"
729
+ :click-row-to-open="clickRowToOpen"
730
+ :preview-row-id="previewRow?.id ?? null"
731
+ :preview-mode="!!previewEnabled"
732
+ :pinned-columns="resolvedPinnedColumns"
733
+ @row-click="handleRowClick"
734
+ @loaded="handleLoaded"
735
+ @page-change="closePreview"
736
+ @per-page-change="closePreview"
737
+ >
738
+ <template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
739
+ <slot :name="name" v-bind="slotProps ?? {}" />
740
+ </template>
741
+ </Table>
742
+ </div>
743
+
744
+ <!-- Preview panel — overlay deslizante desde la derecha (cubre todo el componente) -->
745
+ <Transition
746
+ :enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
747
+ :enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
748
+ :enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
749
+ leave-active-class="transition ease-in duration-150"
750
+ leave-from-class="opacity-100 translate-x-0"
751
+ leave-to-class="opacity-0 translate-x-6"
752
+ >
753
+ <div
754
+ v-if="previewRow && previewEnabled"
755
+ ref="previewPanelRef"
756
+ class="absolute top-0 right-0 bottom-0 z-30 flex flex-col bg-card border border-card-line rounded-2xl shadow-xl overflow-hidden"
757
+ :style="{ width: (100 - currentRatio) + '%' }"
758
+ >
759
+ <!-- Resize handle — thin pill on left edge -->
760
+ <div
761
+ class="absolute left-1 top-1/2 -translate-y-1/2 h-12 w-1 cursor-col-resize rounded-full bg-border hover:bg-primary/50 transition-colors z-10"
762
+ @mousedown="startResize"
763
+ />
764
+
765
+ <!-- Barra de acciones del preview -->
766
+ <div class="shrink-0 flex items-center gap-3 px-5 py-4 border-b border-card-line">
767
+ <!-- Título (reemplazable via slot) -->
768
+ <div class="flex-1 min-w-0">
769
+ <slot name="preview-header" :row="previewRow" :close="closePreview" />
770
+ </div>
771
+
772
+ <!-- Botones de acción — todos icon-only, mismo tamaño y estilo -->
773
+ <div class="flex items-center gap-0.5 shrink-0">
774
+
775
+ <!-- Acciones extra configurables desde el padre -->
776
+ <slot name="preview-actions" :row="previewRow" :close="closePreview" />
777
+
778
+ <!-- Abrir (configurable vía :preview-href) -->
779
+ <div v-if="resolvedPreviewHref" class="hs-tooltip [--placement:top] inline-block">
780
+ <NuxtLink
781
+ :to="resolvedPreviewHref"
782
+ class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
783
+ >
784
+ <IconExternalLink class="size-3.5" />
785
+ </NuxtLink>
786
+ <span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Abrir</span>
787
+ </div>
788
+
789
+ <!-- Eliminar (configurable vía :preview-deletable) -->
790
+ <div v-if="previewDeletable" class="hs-tooltip [--placement:top] inline-block">
791
+ <button
792
+ type="button"
793
+ class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
794
+ @click.stop="emit('preview-delete', previewRow)"
795
+ >
796
+ <IconTrash class="size-3.5" />
797
+ </button>
798
+ <span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Eliminar</span>
799
+ </div>
800
+
801
+ <!-- Minimizar (siempre) -->
802
+ <div class="hs-tooltip [--placement:top] inline-block">
803
+ <button
804
+ type="button"
805
+ class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
806
+ @click.stop="minimizePreview"
807
+ >
808
+ <IconMinus class="size-3.5" />
809
+ </button>
810
+ <span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Minimizar</span>
811
+ </div>
812
+
813
+ <!-- Cerrar (siempre) -->
814
+ <div class="hs-tooltip [--placement:top] inline-block">
815
+ <button
816
+ type="button"
817
+ class="hs-tooltip-toggle inline-flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
818
+ @click.stop="closePreview"
819
+ >
820
+ <IconX class="size-3.5" />
821
+ </button>
822
+ <span class="hs-tooltip-content hs-tooltip-shown:opacity-100 hs-tooltip-shown:visible opacity-0 transition-opacity inline-block absolute invisible z-10 py-1 px-2 bg-tooltip border border-tooltip-line text-xs font-medium text-tooltip-foreground rounded-md shadow-2xs" role="tooltip">Cerrar</span>
823
+ </div>
824
+
825
+ </div>
826
+ </div>
827
+
828
+ <!-- Scrollable content -->
829
+ <div class="flex-1 overflow-y-auto min-h-0">
830
+ <slot v-if="previewTab === 'datos'" name="preview" :row="previewRow" :close="closePreview" />
831
+ <Table.PreviewTimeline
832
+ v-else-if="previewTab === 'bitacora' && resolvedHistoryEndpoint"
833
+ :endpoint="resolvedHistoryEndpoint"
834
+ />
835
+ </div>
836
+
837
+ <!-- Tabs — bottom -->
838
+ <div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
839
+ <button
840
+ type="button"
841
+ @click="previewTab = 'datos'"
842
+ :class="[
843
+ 'flex-1 py-2.5 text-xs font-semibold transition-colors border-r border-card-line border-t-2 -mt-px',
844
+ previewTab === 'datos'
845
+ ? 'border-t-card text-foreground'
846
+ : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
847
+ ]"
848
+ >
849
+ Datos
850
+ </button>
851
+ <button
852
+ type="button"
853
+ @click="resolvedHistoryEndpoint && (previewTab = 'bitacora')"
854
+ :disabled="!resolvedHistoryEndpoint"
855
+ :class="[
856
+ 'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
857
+ !resolvedHistoryEndpoint
858
+ ? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
859
+ : previewTab === 'bitacora'
860
+ ? 'border-t-card text-foreground'
861
+ : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
862
+ ]"
863
+ >
864
+ Bitácora
865
+ </button>
866
+ </div>
867
+
868
+ </div>
869
+ </Transition>
870
+
871
+ <!-- ── Floating mini-preview (dock expand, estilo Gmail) ── -->
872
+ <Teleport to="body">
873
+ <Transition
874
+ enter-active-class="transition ease-out duration-200"
875
+ enter-from-class="opacity-0 translate-y-4"
876
+ enter-to-class="opacity-100 translate-y-0"
877
+ leave-active-class="transition ease-in duration-150"
878
+ leave-from-class="opacity-100 translate-y-0"
879
+ leave-to-class="opacity-0 translate-y-4"
880
+ >
881
+ <div
882
+ v-if="floatingItem"
883
+ class="fixed z-[60] w-96 flex flex-col bg-card border border-card-line rounded-t-xl shadow-2xl overflow-hidden"
884
+ :style="{ ...floatingPanelStyle, maxHeight: 'min(480px, calc(100vh - 60px))' }"
885
+ >
886
+ <div class="flex items-center gap-2 px-3 py-2.5 border-b border-card-line shrink-0 bg-surface select-none">
887
+ <span class="size-6 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
888
+ {{ (floatingItem.label?.[0] ?? '?').toUpperCase() }}
889
+ </span>
890
+ <div class="flex-1 min-w-0">
891
+ <p class="text-sm font-semibold text-foreground truncate leading-tight">{{ floatingItem.label }}</p>
892
+ <p v-if="floatingItem.subtitle" class="text-xs text-muted-foreground truncate">{{ floatingItem.subtitle }}</p>
893
+ </div>
894
+ <button type="button" title="Expandir" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="expandToFull(floatingItem)">
895
+ <IconMaximize class="size-3.5" />
896
+ </button>
897
+ <button type="button" title="Minimizar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="collapseDock()">
898
+ <IconMinus class="size-3.5" />
899
+ </button>
900
+ <button type="button" title="Cerrar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" @click.stop="undockItem(floatingItem.id)">
901
+ <IconX class="size-3.5" />
902
+ </button>
903
+ </div>
904
+ <div class="flex-1 overflow-y-auto min-h-0">
905
+ <slot name="preview" :row="floatingItem.row" :close="() => undockItem(floatingItem.id)" />
906
+ </div>
907
+ </div>
908
+ </Transition>
909
+ </Teleport>
910
+
911
+ <!-- Filter menu — teleported to body -->
912
+ <Teleport to="body">
913
+ <Transition
914
+ enter-active-class="transition ease-out duration-150"
915
+ enter-from-class="opacity-0 translate-y-1 scale-95"
916
+ enter-to-class="opacity-100 translate-y-0 scale-100"
917
+ leave-active-class="transition ease-in duration-100"
918
+ leave-from-class="opacity-100 translate-y-0 scale-100"
919
+ leave-to-class="opacity-0 translate-y-1 scale-95"
920
+ >
921
+ <div
922
+ v-if="showFilterPanel"
923
+ ref="filterMenuRef"
924
+ class="fixed z-[60] bg-dropdown border border-dropdown-line rounded-xl shadow-2xl overflow-hidden"
925
+ :style="filterMenuStyle"
926
+ >
927
+
928
+ <!-- Step 1: column picker -->
929
+ <template v-if="filterMenuStep === 'columns'">
930
+ <p class="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest px-3 pt-3 pb-1.5">
931
+ Filtrar por
932
+ </p>
933
+ <div class="pb-2 min-w-48">
934
+ <template v-if="availableFilterColumns.length">
935
+ <button
936
+ v-for="col in availableFilterColumns"
937
+ :key="col.key"
938
+ type="button"
939
+ @click.stop="selectFilterColumn(col)"
940
+ class="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-foreground hover:bg-muted-hover transition-colors text-left group"
941
+ >
942
+ <span>{{ col.label }}</span>
943
+ <span class="text-[11px] text-muted-foreground-2 group-hover:text-muted-foreground transition-colors capitalize">
944
+ {{ col.filterType === 'daterange' ? 'fecha' : col.filterType === 'select' ? 'opción' : 'texto' }}
945
+ </span>
946
+ </button>
947
+ </template>
948
+ <p v-else class="px-3 py-3 text-xs text-muted-foreground italic">
949
+ Todos los filtros están configurados
950
+ </p>
951
+ </div>
952
+ </template>
953
+
954
+ <!-- Step 2: value config -->
955
+ <template v-else-if="filterMenuStep === 'value' && pendingCol">
956
+
957
+ <!-- Header: back + field name + operator selector -->
958
+ <div class="flex items-center gap-1.5 px-2 py-2 border-b border-dropdown-line">
959
+ <button
960
+ type="button"
961
+ @click.stop="filterMenuStep = 'columns'"
962
+ class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors shrink-0"
963
+ >
964
+ <IconChevronLeft class="size-4" />
965
+ </button>
966
+ <span class="text-sm font-medium text-foreground">{{ pendingCol.label }}</span>
967
+
968
+ <!-- Operator: native select for text (3 options) -->
969
+ <select
970
+ v-if="pendingCol.filterType === 'text'"
971
+ v-model="pendingOperator"
972
+ class="ml-auto text-xs bg-dropdown border border-dropdown-line rounded-md px-2 py-1 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
973
+ >
974
+ <option v-for="op in textOps" :key="op.value" :value="op.value">{{ op.label }}</option>
975
+ </select>
976
+
977
+ <!-- Operator: segmented toggle for select (es / no es) -->
978
+ <div v-else-if="pendingCol.filterType === 'select'" class="ml-auto flex rounded-md border border-dropdown-line overflow-hidden text-xs">
979
+ <button
980
+ v-for="op in selectOps"
981
+ :key="op.value"
982
+ type="button"
983
+ @click.stop="pendingOperator = op.value"
984
+ :class="[
985
+ 'px-2.5 py-1 transition-colors',
986
+ pendingOperator === op.value
987
+ ? 'bg-primary/10 text-primary'
988
+ : 'text-muted-foreground hover:bg-muted-hover'
989
+ ]"
990
+ >
991
+ {{ op.label }}
992
+ </button>
993
+ </div>
994
+
995
+ <!-- Operator: inline select for daterange -->
996
+ <select
997
+ v-else-if="pendingCol.filterType === 'daterange'"
998
+ v-model="pendingDateOp"
999
+ class="ml-auto text-xs bg-dropdown border border-dropdown-line rounded-md px-2 py-1 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
1000
+ >
1001
+ <option v-for="op in dateOps" :key="op.value" :value="op.value">{{ op.label }}</option>
1002
+ </select>
1003
+ </div>
1004
+
1005
+ <div class="p-3 min-w-56 space-y-2">
1006
+
1007
+ <!-- ── TEXT ── -->
1008
+ <input
1009
+ v-if="pendingCol.filterType === 'text'"
1010
+ ref="pendingValueInputRef"
1011
+ v-model="pendingValue"
1012
+ type="text"
1013
+ @keydown.enter.stop="applyPendingFilter"
1014
+ @keydown.escape.stop="closeFilterMenu"
1015
+ placeholder="Valor..."
1016
+ class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60 focus:border-primary"
1017
+ />
1018
+
1019
+ <!-- ── SELECT ── -->
1020
+ <div v-else-if="pendingCol.filterType === 'select'" class="space-y-0.5 max-h-52 overflow-y-auto -mx-1">
1021
+ <button
1022
+ v-for="opt in pendingCol.filterOptions"
1023
+ :key="opt.value"
1024
+ type="button"
1025
+ @click.stop="pendingValue = opt.value; applyPendingFilter()"
1026
+ :class="[
1027
+ 'w-full flex items-center gap-2 px-2.5 py-2 text-sm rounded-lg transition-colors text-left',
1028
+ pendingValue === opt.value
1029
+ ? 'bg-primary/10 text-primary'
1030
+ : 'hover:bg-muted-hover text-foreground'
1031
+ ]"
1032
+ >
1033
+ <span class="flex-1">{{ opt.label }}</span>
1034
+ <IconCheck v-if="pendingValue === opt.value" class="size-3.5 shrink-0 text-primary/70" />
1035
+ </button>
1036
+ </div>
1037
+
1038
+ <!-- ── DATERANGE ── -->
1039
+ <template v-else-if="pendingCol.filterType === 'daterange'">
1040
+ <template v-if="pendingDateOp === 'between'">
1041
+ <input type="date" v-model="pendingValue.from" class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
1042
+ <input type="date" v-model="pendingValue.to" class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
1043
+ </template>
1044
+ <input v-else type="date" v-model="pendingValue.singleDate" class="w-full rounded-lg border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
1045
+ </template>
1046
+
1047
+ <!-- Apply (not for select — auto-applies on click) -->
1048
+ <button
1049
+ v-if="pendingCol.filterType !== 'select'"
1050
+ type="button"
1051
+ @click.stop="applyPendingFilter"
1052
+ class="w-full py-1.5 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 active:bg-primary/80 transition-colors"
1053
+ >
1054
+ Aplicar
1055
+ </button>
1056
+
1057
+ </div>
1058
+ </template>
1059
+
1060
+ </div>
1061
+ </Transition>
1062
+ </Teleport>
1063
+
1064
+ <!-- Column panel — teleported to body to escape overflow-hidden -->
1065
+ <Teleport to="body">
1066
+ <Transition
1067
+ enter-active-class="transition ease-out duration-150"
1068
+ enter-from-class="opacity-0 translate-y-1 scale-95"
1069
+ enter-to-class="opacity-100 translate-y-0 scale-100"
1070
+ leave-active-class="transition ease-in duration-100"
1071
+ leave-from-class="opacity-100 translate-y-0 scale-100"
1072
+ leave-to-class="opacity-0 translate-y-1 scale-95"
1073
+ >
1074
+ <div
1075
+ v-if="showColumnPanel"
1076
+ ref="columnPanelRef"
1077
+ class="fixed z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl min-w-64 max-h-[480px] overflow-y-auto"
1078
+ :style="columnPanelStyle"
1079
+ >
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>
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
+
1213
+ </div>
1214
+ </Transition>
1215
+ </Teleport>
1216
+ </div>
1217
+ </template>