@innertia-solutions/nuxt-theme-spark 0.1.138 → 0.1.140

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.
@@ -25,10 +25,13 @@ const iconColorClass = computed(() => ({
25
25
  </script>
26
26
 
27
27
  <template>
28
- <div class="space-y-4">
28
+ <div class="relative space-y-2">
29
29
 
30
30
  <!-- Page header card -->
31
31
  <div v-if="title" class="sticky top-0 z-20 -mx-3 -mt-3 px-3 pt-3 bg-background-1">
32
+ <div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 px-1 pt-2 pb-0.5">
33
+ <slot name="breadcrumb" />
34
+ </div>
32
35
  <div class="flex items-center justify-between bg-card border border-card-line rounded-2xl shadow-sm px-4 py-3">
33
36
  <div class="flex items-center gap-x-4 min-w-0">
34
37
  <div v-if="iconComponent" class="shrink-0 size-10 rounded-xl flex items-center justify-center border border-current/15" :class="iconColorClass">
@@ -42,9 +45,6 @@ const iconColorClass = computed(() => ({
42
45
  <p class="text-sm text-muted-foreground">{{ description }}</p>
43
46
  </template>
44
47
  </div>
45
- <div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 mt-0.5">
46
- <slot name="breadcrumb" />
47
- </div>
48
48
  </div>
49
49
  </div>
50
50
  <div v-if="$slots.actions" class="flex items-center gap-x-2 shrink-0 ms-4">
@@ -59,7 +59,7 @@ const iconColorClass = computed(() => ({
59
59
  </div>
60
60
 
61
61
  <!-- Page content -->
62
- <slot />
62
+ <div class="relative"><slot /></div>
63
63
 
64
64
  </div>
65
65
  </template>
@@ -22,22 +22,22 @@ const iconSizeClasses = computed(() => ({ xs:"size-2", sm:"size-3", md:"size-4",
22
22
 
23
23
  const severityClasses = computed(() => {
24
24
  if (props.variant === "dropdown") {
25
- const d = { primary:"text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20", secondary:"text-foreground hover:bg-muted-hover", success:"text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20", danger:"text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20", warning:"text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20", info:"text-cyan-600 hover:bg-cyan-50 dark:text-cyan-400 dark:hover:bg-cyan-900/20" }
25
+ const d = { primary: "text-primary hover:bg-primary/10", secondary:"text-foreground hover:bg-muted-hover", success:"text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20", danger:"text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20", warning:"text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20", info:"text-cyan-600 hover:bg-cyan-50 dark:text-cyan-400 dark:hover:bg-cyan-900/20" }
26
26
  return d[props.severity] || d.secondary
27
27
  }
28
28
  const base = "rounded-lg border transition-colors"
29
- const v = { primary:"border-blue-600 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-500 dark:bg-blue-900/20 dark:text-blue-300 dark:hover:bg-blue-900/35", secondary:"border-slate-300 bg-slate-50 text-slate-700 hover:bg-muted-hover dark:border-card-line dark:bg-card dark:text-muted-foreground-1 dark:hover:bg-muted-hover", success:"border-emerald-600 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/35", danger:"border-red-600 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-500 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/35", warning:"border-yellow-600 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 dark:border-yellow-500 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/35", info:"border-cyan-600 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 dark:border-cyan-500 dark:bg-cyan-900/20 dark:text-cyan-300 dark:hover:bg-cyan-900/35" }
29
+ const v = { primary: "border-primary bg-primary/10 text-primary hover:bg-primary/20 dark:bg-primary/15 dark:hover:bg-primary/25", secondary:"border-slate-300 bg-slate-50 text-slate-700 hover:bg-muted-hover dark:border-card-line dark:bg-card dark:text-muted-foreground-1 dark:hover:bg-muted-hover", success:"border-emerald-600 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/35", danger:"border-red-600 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-500 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/35", warning:"border-yellow-600 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 dark:border-yellow-500 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/35", info:"border-cyan-600 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 dark:border-cyan-500 dark:bg-cyan-900/20 dark:text-cyan-300 dark:hover:bg-cyan-900/35" }
30
30
  return `${base} ${v[props.severity] || v.primary}`
31
31
  })
32
32
 
33
33
  const buttonClasses = computed(() => {
34
34
  if (props.variant === "dropdown") {
35
35
  const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
36
- return `w-full flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm transition-colors text-left ${severityClasses.value} ${dis} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 ${props.class}`
36
+ return `w-full flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm transition-colors text-left ${severityClasses.value} ${dis} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${props.class}`
37
37
  }
38
38
  const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
39
39
  const cursor = props.type === "link" ? "cursor-pointer" : ""
40
- return `${sizeClasses.value} ${severityClasses.value} ${dis} focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 ${cursor} inline-flex justify-center items-center gap-x-2 whitespace-nowrap ${props.class}`
40
+ return `${sizeClasses.value} ${severityClasses.value} ${dis} focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${cursor} inline-flex justify-center items-center gap-x-2 whitespace-nowrap ${props.class}`
41
41
  })
42
42
 
43
43
  const displayText = computed(() => props.loading ? props.loadingText : props.text)
@@ -0,0 +1,64 @@
1
+ <script setup>
2
+ import { IconX } from '@tabler/icons-vue'
3
+
4
+ const { docked, undock, expandDock, activeDockId } = useDockedPreviews()
5
+ const router = useRouter()
6
+ const route = useRoute()
7
+
8
+ async function open(item, event) {
9
+ // Misma ruta → la tabla está montada, mostrar float encima del tab
10
+ if (route.path === item.route) {
11
+ const rect = event.currentTarget.getBoundingClientRect()
12
+ expandDock(item.id, rect)
13
+ return
14
+ }
15
+ // Ruta diferente → navegar y restaurar como preview completo
16
+ await router.push(item.route)
17
+ await nextTick()
18
+ useNuxtApp().hooks.callHook('preview:restore', item)
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <Transition
24
+ enter-active-class="transition ease-out duration-200"
25
+ enter-from-class="opacity-0 translate-y-4"
26
+ enter-to-class="opacity-100 translate-y-0"
27
+ leave-active-class="transition ease-in duration-150"
28
+ leave-from-class="opacity-100 translate-y-0"
29
+ leave-to-class="opacity-0 translate-y-4"
30
+ >
31
+ <div
32
+ v-if="docked.length"
33
+ class="fixed bottom-0 left-0 right-0 z-50 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-md border-t border-card-line shadow-lg"
34
+ >
35
+ <span class="text-xs text-muted-foreground shrink-0 mr-1">Minimizados</span>
36
+
37
+ <div class="flex items-center gap-2 flex-1 overflow-x-auto">
38
+ <button
39
+ v-for="item in docked"
40
+ :key="item.id"
41
+ type="button"
42
+ class="group inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all shrink-0"
43
+ :class="activeDockId === item.id
44
+ ? 'border-primary/50 bg-primary/10 text-primary shadow-sm'
45
+ : 'border-card-line bg-surface hover:bg-muted-hover text-foreground'"
46
+ @click="open(item, $event)"
47
+ >
48
+ <span class="size-5 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
49
+ {{ (item.label?.[0] ?? '?').toUpperCase() }}
50
+ </span>
51
+ <span class="font-medium max-w-32 truncate">{{ item.label }}</span>
52
+ <span v-if="item.subtitle" class="text-muted-foreground text-xs max-w-28 truncate hidden sm:inline">{{ item.subtitle }}</span>
53
+
54
+ <span
55
+ class="size-4 inline-flex items-center justify-center rounded hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-600 dark:hover:text-red-400 transition-colors ml-0.5"
56
+ @click.stop="undock(item.id)"
57
+ >
58
+ <IconX class="size-3" />
59
+ </span>
60
+ </button>
61
+ </div>
62
+ </div>
63
+ </Transition>
64
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { IconSearch, IconAdjustmentsHorizontal, IconLayoutColumns, IconGripVertical } from '@tabler/icons-vue'
2
+ import { IconSearch, IconLayoutColumns, IconGripVertical, IconMinus, IconMaximize, IconX, IconPlus, IconChevronLeft, IconCheck, IconChevronDown } from '@tabler/icons-vue'
3
3
 
4
4
  const props = defineProps({
5
5
  table: { type: Object, default: null },
@@ -17,6 +17,7 @@ const props = defineProps({
17
17
  showExport: { type: Boolean, default: true },
18
18
  filters: { type: Array, default: () => [] },
19
19
  splitRatio: { type: Number, default: 60 },
20
+ autoClosePreview: { type: Boolean, default: true },
20
21
  })
21
22
 
22
23
  const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
@@ -29,11 +30,9 @@ const forwardedSlots = computed(() => {
29
30
  return Object.fromEntries(Object.entries(slots).filter(([k]) => !excluded.has(k)))
30
31
  })
31
32
 
32
- const search = ref('')
33
+ const search = ref('')
33
34
  const activeFilters = ref({})
34
- const showFilterPanel = ref(false)
35
- const filterPanelRef = ref(null)
36
- const tableRef = ref(null)
35
+ const tableRef = ref(null)
37
36
 
38
37
  // ─── Filter config ─────────────────────────────────────────────────────────────
39
38
  const filtersConfig = computed(() =>
@@ -42,15 +41,125 @@ const filtersConfig = computed(() =>
42
41
 
43
42
  const hasFilterableColumns = computed(() => filtersConfig.value.length > 0)
44
43
 
45
- const activeFilterCount = computed(() =>
46
- Object.values(activeFilters.value).filter(v => v !== null && v !== undefined && v !== '').length
44
+ // ─── Notion-style filter ───────────────────────────────────────────────────────
45
+ const showFilterPanel = ref(false)
46
+ const filterMenuStep = ref('columns') // 'columns' | 'value'
47
+ const pendingCol = ref(null)
48
+ const pendingValue = ref(null) // string for text/select, { singleDate, from, to } for daterange
49
+ const pendingDateOp = ref('before') // 'before' | 'after' | 'between'
50
+ const filterMenuRef = ref(null)
51
+ const filterAddBtnRef = ref(null)
52
+ const filterMenuStyle = ref({})
53
+
54
+ const dateOps = [
55
+ { value: 'before', label: 'antes de' },
56
+ { value: 'after', label: 'después de' },
57
+ { value: 'between', label: 'entre' },
58
+ ]
59
+
60
+ const activeFilterList = computed(() =>
61
+ filtersConfig.value
62
+ .filter(col => {
63
+ const v = activeFilters.value[col.key]
64
+ if (col.filterType === 'daterange') return v?.from || v?.to
65
+ return v !== null && v !== undefined && v !== ''
66
+ })
67
+ .map(col => {
68
+ const v = activeFilters.value[col.key]
69
+ let displayOp = '', displayVal = ''
70
+ if (col.filterType === 'daterange') {
71
+ if (v.from && v.to) { displayOp = 'entre'; displayVal = `${v.from} y ${v.to}` }
72
+ else if (v.from) { displayOp = 'después de'; displayVal = v.from }
73
+ else { displayOp = 'antes de'; displayVal = v.to }
74
+ } else if (col.filterType === 'select') {
75
+ displayOp = 'es'
76
+ displayVal = col.filterOptions?.find(o => o.value === v)?.label ?? v
77
+ } else {
78
+ displayOp = 'contiene'; displayVal = v
79
+ }
80
+ return { key: col.key, label: col.label, displayOp, displayVal, col }
81
+ })
47
82
  )
48
83
 
84
+ const activeFilterCount = computed(() => activeFilterList.value.length)
85
+
49
86
  const mergedParams = computed(() => ({
50
87
  ...props.params,
51
88
  ...activeFilters.value,
52
89
  }))
53
90
 
91
+ const removeFilter = (key) => {
92
+ const u = { ...activeFilters.value }; delete u[key]; activeFilters.value = u
93
+ }
94
+
95
+ const openFilterMenu = async () => {
96
+ filterMenuStep.value = 'columns'
97
+ pendingCol.value = null
98
+ showFilterPanel.value = true
99
+ await nextTick()
100
+ const rect = filterAddBtnRef.value?.getBoundingClientRect()
101
+ if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
102
+ }
103
+
104
+ const toggleFilterMenu = async () => {
105
+ if (showFilterPanel.value) { closeFilterMenu() } else { await openFilterMenu() }
106
+ }
107
+
108
+ const closeFilterMenu = () => {
109
+ showFilterPanel.value = false
110
+ filterMenuStep.value = 'columns'
111
+ pendingCol.value = null
112
+ pendingValue.value = null
113
+ }
114
+
115
+ const selectFilterColumn = (col) => {
116
+ pendingCol.value = col
117
+ const existing = activeFilters.value[col.key]
118
+ if (col.filterType === 'daterange') {
119
+ if (existing?.from && existing?.to) { pendingDateOp.value = 'between'; pendingValue.value = { from: existing.from, to: existing.to, singleDate: '' } }
120
+ else if (existing?.from) { pendingDateOp.value = 'after'; pendingValue.value = { singleDate: existing.from, from: '', to: '' } }
121
+ else if (existing?.to) { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: existing.to, from: '', to: '' } }
122
+ else { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: '', from: '', to: '' } }
123
+ } else {
124
+ pendingValue.value = existing ?? ''
125
+ }
126
+ filterMenuStep.value = 'value'
127
+ }
128
+
129
+ const applyPendingFilter = () => {
130
+ if (!pendingCol.value) return
131
+ const col = pendingCol.value
132
+ let v
133
+ if (col.filterType === 'daterange') {
134
+ if (pendingDateOp.value === 'between') v = { from: pendingValue.value.from, to: pendingValue.value.to }
135
+ else if (pendingDateOp.value === 'after') v = { from: pendingValue.value.singleDate }
136
+ else v = { to: pendingValue.value.singleDate }
137
+ } else {
138
+ v = pendingValue.value
139
+ }
140
+ activeFilters.value = { ...activeFilters.value, [col.key]: v || null }
141
+ closeFilterMenu()
142
+ }
143
+
144
+ const openEditFilter = async (col) => {
145
+ selectFilterColumn(col)
146
+ showFilterPanel.value = true
147
+ await nextTick()
148
+ const rect = filterAddBtnRef.value?.getBoundingClientRect()
149
+ if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
150
+ }
151
+
152
+ const onFilterMenuOutsideClick = (e) => {
153
+ if (filterMenuRef.value && !filterMenuRef.value.contains(e.target) &&
154
+ filterAddBtnRef.value && !filterAddBtnRef.value.contains(e.target)) {
155
+ closeFilterMenu()
156
+ }
157
+ }
158
+ watch(showFilterPanel, v => {
159
+ if (v) document.addEventListener('mousedown', onFilterMenuOutsideClick)
160
+ else document.removeEventListener('mousedown', onFilterMenuOutsideClick)
161
+ })
162
+
54
163
  // ─── Preview panel ─────────────────────────────────────────────────────────────
55
164
  const previewRow = ref(null)
56
165
  const currentRatio = ref(props.splitRatio)
@@ -61,6 +170,7 @@ const paginationHeight = ref(0)
61
170
  const previewCacheKey = computed(() => `table-preview-${resolvedName.value}`)
62
171
 
63
172
  const previewFromCache = ref(false)
173
+ const previewPanelRef = ref(null)
64
174
  const closePreview = () => { previewRow.value = null }
65
175
 
66
176
  const previewTab = ref('datos')
@@ -76,6 +186,7 @@ watch(previewRow, () => { previewTab.value = 'datos' })
76
186
 
77
187
  const handleRowClick = (row) => {
78
188
  if (previewEnabled.value) {
189
+ collapseDock()
79
190
  previewRow.value = previewRow.value?.id === row.id ? null : row
80
191
  } else {
81
192
  emit('row-click', row)
@@ -129,10 +240,74 @@ const startResize = (e) => {
129
240
  window.addEventListener('mouseup', onUp)
130
241
  }
131
242
 
132
- const onEsc = (e) => { if (e.key === 'Escape' && previewRow.value) closePreview() }
243
+ const onEsc = (e) => { if (e.key === 'Escape') { if (previewRow.value) closePreview(); else collapseDock() } }
244
+
245
+ // ─── Auto-close preview on outside click ──────────────────────────────────────
246
+ const onDocMousedown = (e) => {
247
+ if (props.autoClosePreview && previewRow.value && previewPanelRef.value && !previewPanelRef.value.contains(e.target)) {
248
+ closePreview()
249
+ }
250
+ }
251
+
252
+ // ─── Dock (minimizar preview) ──────────────────────────────────────────────────
253
+ const {
254
+ docked,
255
+ dock, undock: undockItem, isActive,
256
+ activeDockId, activeDockRect,
257
+ expandDock, collapseDock,
258
+ } = useDockedPreviews()
259
+ const route = useRoute()
260
+
261
+ function minimizePreview() {
262
+ if (!previewRow.value) return
263
+ const label = previewRow.value.name ?? previewRow.value.title ?? previewRow.value.email ?? String(previewRow.value.id)
264
+ const subtitle = previewRow.value.email ?? previewRow.value.description ?? null
265
+ dock({
266
+ id: `${resolvedName.value}-${previewRow.value.id}`,
267
+ label,
268
+ subtitle,
269
+ row: { ...previewRow.value },
270
+ tableName: resolvedName.value,
271
+ route: route.path,
272
+ })
273
+ closePreview()
274
+ }
275
+
276
+ // Item que debe mostrarse como mini-preview flotante (pertenece a esta tabla)
277
+ const floatingItem = computed(() =>
278
+ activeDockId.value
279
+ ? docked.value.find(d => d.id === activeDockId.value && d.tableName === resolvedName.value) ?? null
280
+ : null
281
+ )
282
+
283
+ // Posición del panel flotante: centrado sobre el tab que lo abrió
284
+ const floatingPanelStyle = computed(() => {
285
+ const rect = activeDockRect.value
286
+ const panelW = 384
287
+ const bottom = 52
288
+ if (!rect || typeof window === 'undefined') return { bottom: bottom + 'px', right: '16px' }
289
+ const tabCenter = rect.left + rect.width / 2
290
+ let right = window.innerWidth - tabCenter - panelW / 2
291
+ right = Math.max(8, Math.min(right, window.innerWidth - panelW - 8))
292
+ return { bottom: bottom + 'px', right: right + 'px' }
293
+ })
294
+
295
+ function expandToFull(item) {
296
+ previewRow.value = item.row
297
+ undockItem(item.id)
298
+ }
299
+
300
+ // Escuchar evento de restauración (fallback cuando la tabla no estaba montada)
301
+ onMounted(() => {
302
+ useNuxtApp().hooks.hook('preview:restore', (item) => {
303
+ if (item.tableName === resolvedName.value) previewRow.value = item.row
304
+ })
305
+ })
306
+
133
307
  onMounted(async () => {
134
308
  previewEnabled.value = !!slots.preview
135
309
  window.addEventListener('keydown', onEsc)
310
+ document.addEventListener('mousedown', onDocMousedown)
136
311
  // Restore preview from session cache — mark as from-cache to skip enter animation
137
312
  if (props.cached && previewEnabled.value) {
138
313
  try {
@@ -148,6 +323,7 @@ onMounted(async () => {
148
323
  })
149
324
  onBeforeUnmount(() => {
150
325
  window.removeEventListener('keydown', onEsc)
326
+ document.removeEventListener('mousedown', onDocMousedown)
151
327
  paginationObserver?.disconnect()
152
328
  })
153
329
 
@@ -192,11 +368,6 @@ const onColumnPanelOutsideClick = (e) => {
192
368
  showColumnPanel.value = false
193
369
  }
194
370
  }
195
- const onFilterPanelOutsideClick = (e) => {
196
- if (filterPanelRef.value && !filterPanelRef.value.contains(e.target)) {
197
- showFilterPanel.value = false
198
- }
199
- }
200
371
 
201
372
  watch(showColumnPanel, async (v) => {
202
373
  if (v) {
@@ -213,10 +384,6 @@ watch(showColumnPanel, async (v) => {
213
384
  document.removeEventListener('mousedown', onColumnPanelOutsideClick)
214
385
  }
215
386
  })
216
- watch(showFilterPanel, (v) => {
217
- if (v) document.addEventListener('mousedown', onFilterPanelOutsideClick)
218
- else document.removeEventListener('mousedown', onFilterPanelOutsideClick)
219
- })
220
387
 
221
388
  // ─── Expose ───────────────────────────────────────────────────────────────────
222
389
  const getSelectedRows = () => tableRef.value?.getSelectedRows()
@@ -224,177 +391,366 @@ const reload = () => tableRef.value?.reload()
224
391
  const clearCache = () => tableRef.value?.clearCache()
225
392
  const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
226
393
 
227
- defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef })
394
+ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview })
228
395
  </script>
229
396
 
230
397
  <template>
231
398
  <div class="relative" ref="containerRef">
232
399
 
233
- <!-- Card único -->
234
- <div class="bg-card border border-card-line rounded-2xl shadow-sm overflow-hidden">
235
-
236
- <!-- Toolbar -->
237
- <div class="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-card-line">
238
- <div v-if="showSearch" class="flex-1 min-w-48">
239
- <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
240
- </div>
400
+ <!-- Toolbar row (no card) -->
401
+ <div class="flex flex-wrap items-center gap-2 mb-2">
241
402
 
242
- <div v-if="showFilters && hasFilterableColumns" class="relative">
243
- <button
244
- type="button"
245
- @click="showFilterPanel = !showFilterPanel"
246
- :class="[
247
- 'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
248
- showFilterPanel || activeFilterCount > 0
249
- ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:border-blue-500 dark:text-blue-300'
250
- : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
251
- ]"
252
- >
253
- <IconAdjustmentsHorizontal class="size-4" stroke="1.5" />
254
- Filtros{{ activeFilterCount > 0 ? ` (${activeFilterCount})` : '' }}
255
- </button>
256
-
257
- <!-- Filter panel — anchored below button -->
258
- <Transition
259
- enter-active-class="transition ease-out duration-150"
260
- enter-from-class="opacity-0 translate-y-1 scale-95"
261
- enter-to-class="opacity-100 translate-y-0 scale-100"
262
- leave-active-class="transition ease-in duration-100"
263
- leave-from-class="opacity-100 translate-y-0 scale-100"
264
- leave-to-class="opacity-0 translate-y-1 scale-95"
265
- >
266
- <div
267
- v-if="showFilterPanel"
268
- ref="filterPanelRef"
269
- class="absolute top-full left-0 z-50 mt-1.5 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 min-w-56 max-h-96 overflow-y-auto"
270
- >
271
- <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-3 px-1">Filtros</p>
272
- <TableFilter v-model="activeFilters" :columns="filtersConfig" />
273
- </div>
274
- </Transition>
275
- </div>
276
-
277
- <slot name="toolbar" />
403
+ <!-- Search -->
404
+ <div v-if="showSearch" class="flex-1 min-w-48 max-w-xs">
405
+ <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
406
+ </div>
278
407
 
408
+ <!-- + Filtros button -->
409
+ <div v-if="showFilters && hasFilterableColumns" ref="filterAddBtnRef" class="relative">
279
410
  <button
280
- ref="columnButtonRef"
281
411
  type="button"
282
- @click="showColumnPanel = !showColumnPanel"
412
+ @click="toggleFilterMenu"
283
413
  :class="[
284
- 'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
285
- showColumnPanel
286
- ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:border-blue-500 dark:text-blue-300'
414
+ 'inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-lg border transition-colors',
415
+ activeFilterList.length
416
+ ? 'border-indigo-300 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:border-indigo-700 dark:text-indigo-300'
287
417
  : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
288
418
  ]"
289
419
  >
290
- <IconLayoutColumns class="size-4" />
291
- Columnas
420
+ <IconPlus class="size-3.5" />
421
+ Filtros{{ activeFilterList.length ? ` (${activeFilterList.length})` : '' }}
292
422
  </button>
293
-
294
- <TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
295
423
  </div>
296
424
 
297
- <!-- Contenido: tabla siempre full width + preview overlay -->
298
- <div class="relative overflow-hidden">
299
-
300
- <!-- Tabla -->
301
- <Table
302
- ref="tableRef"
303
- :endpoint="resolvedEndpoint"
304
- :columns="columns"
305
- :name="resolvedName"
306
- :params="mergedParams"
307
- :search="search"
308
- :checkable="checkable"
309
- :cached="cached"
310
- :show-reload-button="showReloadButton"
311
- :click-row-to-open="clickRowToOpen"
312
- :preview-row-id="previewRow?.id ?? null"
313
- :preview-mode="!!previewEnabled"
314
- @row-click="handleRowClick"
315
- @loaded="handleLoaded"
316
- @page-change="closePreview"
317
- @per-page-change="closePreview"
425
+ <!-- Slot for custom toolbar buttons -->
426
+ <slot name="toolbar" />
427
+
428
+ <!-- Columnas button -->
429
+ <button
430
+ ref="columnButtonRef"
431
+ type="button"
432
+ @click="showColumnPanel = !showColumnPanel"
433
+ :class="[
434
+ 'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
435
+ showColumnPanel
436
+ ? 'border-indigo-300 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:border-indigo-700 dark:text-indigo-300'
437
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
438
+ ]"
439
+ >
440
+ <IconLayoutColumns class="size-4" />
441
+ Columnas
442
+ </button>
443
+
444
+ <TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
445
+ </div>
446
+
447
+ <!-- Filter chips row (shown when filters active) -->
448
+ <div v-if="activeFilterList.length" class="flex flex-wrap items-center gap-1.5 mb-2">
449
+ <div
450
+ v-for="chip in activeFilterList"
451
+ :key="chip.key"
452
+ class="inline-flex items-center text-xs rounded-lg border border-card-line bg-card overflow-hidden"
453
+ >
454
+ <span class="px-2.5 py-1 text-foreground font-medium border-r border-card-line bg-surface">{{ chip.label }}</span>
455
+ <span class="px-2 py-1 text-muted-foreground">{{ chip.displayOp }}</span>
456
+ <button
457
+ type="button"
458
+ @click.stop="openEditFilter(chip.col)"
459
+ class="inline-flex items-center gap-1 px-2 py-1 text-indigo-600 dark:text-indigo-400 font-medium hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors border-x border-card-line"
318
460
  >
319
- <template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
320
- <slot :name="name" v-bind="slotProps ?? {}" />
321
- </template>
322
- </Table>
323
-
324
- <!-- Preview panel overlay — slides in from right, tapa la tabla -->
325
- <Transition
326
- :enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
327
- :enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
328
- :enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
329
- leave-active-class="transition ease-in duration-150"
330
- leave-from-class="opacity-100 translate-x-0"
331
- leave-to-class="opacity-0 translate-x-6"
461
+ {{ chip.displayVal }}
462
+ <IconChevronDown class="size-3 opacity-60" />
463
+ </button>
464
+ <button
465
+ type="button"
466
+ @click.stop="removeFilter(chip.key)"
467
+ 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"
332
468
  >
333
- <div
334
- v-if="previewRow && previewEnabled"
335
- 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)]"
336
- :style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
337
- >
338
- <!-- Resize handle -->
339
- <div
340
- class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-indigo-300 dark:hover:bg-indigo-600 transition-colors"
341
- @mousedown="startResize"
342
- />
343
- <!-- Preview -->
344
- <div class="flex flex-col flex-1 overflow-hidden">
469
+ <IconX class="size-3" />
470
+ </button>
471
+ </div>
472
+ </div>
345
473
 
346
- <!-- Fixed header always visible regardless of active tab -->
347
- <div v-if="$slots['preview-header']" class="shrink-0 border-b border-card-line">
348
- <slot name="preview-header" :row="previewRow" :close="closePreview" />
349
- </div>
474
+ <!-- Table + preview overlay inside a minimal border box -->
475
+ <div class="relative overflow-hidden rounded-xl border border-card-line">
476
+
477
+ <!-- Tabla -->
478
+ <Table
479
+ ref="tableRef"
480
+ :endpoint="resolvedEndpoint"
481
+ :columns="columns"
482
+ :name="resolvedName"
483
+ :params="mergedParams"
484
+ :search="search"
485
+ :checkable="checkable"
486
+ :cached="cached"
487
+ :show-reload-button="showReloadButton"
488
+ :click-row-to-open="clickRowToOpen"
489
+ :preview-row-id="previewRow?.id ?? null"
490
+ :preview-mode="!!previewEnabled"
491
+ @row-click="handleRowClick"
492
+ @loaded="handleLoaded"
493
+ @page-change="closePreview"
494
+ @per-page-change="closePreview"
495
+ >
496
+ <template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
497
+ <slot :name="name" v-bind="slotProps ?? {}" />
498
+ </template>
499
+ </Table>
350
500
 
351
- <!-- Scrollable content -->
352
- <div class="flex-1 overflow-y-auto min-h-0">
353
- <slot v-if="previewTab === 'datos'" name="preview" :row="previewRow" :close="closePreview" />
354
- <Table.PreviewTimeline
355
- v-else-if="previewTab === 'bitacora' && resolvedHistoryEndpoint"
356
- :endpoint="resolvedHistoryEndpoint"
357
- />
501
+ <!-- Preview panel overlay — slides in from right, tapa la tabla -->
502
+ <Transition
503
+ :enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
504
+ :enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
505
+ :enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
506
+ leave-active-class="transition ease-in duration-150"
507
+ leave-from-class="opacity-100 translate-x-0"
508
+ leave-to-class="opacity-0 translate-x-6"
509
+ >
510
+ <div
511
+ v-if="previewRow && previewEnabled"
512
+ ref="previewPanelRef"
513
+ 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)]"
514
+ :style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
515
+ >
516
+ <!-- Resize handle -->
517
+ <div
518
+ class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-indigo-300 dark:hover:bg-indigo-600 transition-colors"
519
+ @mousedown="startResize"
520
+ />
521
+ <!-- Preview -->
522
+ <div class="flex flex-col flex-1 overflow-hidden">
523
+
524
+ <!-- Barra de acciones del preview -->
525
+ <div class="shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-card-line">
526
+ <div class="flex-1 min-w-0">
527
+ <slot name="preview-header" :row="previewRow" :close="closePreview" />
358
528
  </div>
359
-
360
- <!-- Tabs — bottom -->
361
- <div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
529
+ <div class="flex items-center gap-1 shrink-0">
362
530
  <button
363
531
  type="button"
364
- @click="previewTab = 'datos'"
365
- :class="[
366
- 'flex-1 py-2.5 text-xs font-semibold transition-colors border-r border-card-line border-t-2 -mt-px',
367
- previewTab === 'datos'
368
- ? 'border-t-card text-foreground'
369
- : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
370
- ]"
532
+ class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
533
+ title="Minimizar"
534
+ @click.stop="minimizePreview"
371
535
  >
372
- Datos
536
+ <IconMinus class="size-3.5" />
373
537
  </button>
374
538
  <button
375
539
  type="button"
376
- @click="resolvedHistoryEndpoint && (previewTab = 'bitacora')"
377
- :disabled="!resolvedHistoryEndpoint"
378
- :class="[
379
- 'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
380
- !resolvedHistoryEndpoint
381
- ? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
382
- : previewTab === 'bitacora'
383
- ? 'border-t-card text-foreground'
384
- : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
385
- ]"
540
+ class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
541
+ title="Cerrar"
542
+ @click.stop="closePreview"
386
543
  >
387
- Bitácora
544
+ <IconX class="size-3.5" />
388
545
  </button>
389
546
  </div>
547
+ </div>
390
548
 
549
+ <!-- Scrollable content -->
550
+ <div class="flex-1 overflow-y-auto min-h-0">
551
+ <slot v-if="previewTab === 'datos'" name="preview" :row="previewRow" :close="closePreview" />
552
+ <Table.PreviewTimeline
553
+ v-else-if="previewTab === 'bitacora' && resolvedHistoryEndpoint"
554
+ :endpoint="resolvedHistoryEndpoint"
555
+ />
391
556
  </div>
557
+
558
+ <!-- Tabs — bottom -->
559
+ <div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
560
+ <button
561
+ type="button"
562
+ @click="previewTab = 'datos'"
563
+ :class="[
564
+ 'flex-1 py-2.5 text-xs font-semibold transition-colors border-r border-card-line border-t-2 -mt-px',
565
+ previewTab === 'datos'
566
+ ? 'border-t-card text-foreground'
567
+ : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
568
+ ]"
569
+ >
570
+ Datos
571
+ </button>
572
+ <button
573
+ type="button"
574
+ @click="resolvedHistoryEndpoint && (previewTab = 'bitacora')"
575
+ :disabled="!resolvedHistoryEndpoint"
576
+ :class="[
577
+ 'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
578
+ !resolvedHistoryEndpoint
579
+ ? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
580
+ : previewTab === 'bitacora'
581
+ ? 'border-t-card text-foreground'
582
+ : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
583
+ ]"
584
+ >
585
+ Bitácora
586
+ </button>
587
+ </div>
588
+
392
589
  </div>
393
- </Transition>
590
+ </div>
591
+ </Transition>
394
592
 
395
- </div>
396
593
  </div>
397
594
 
595
+ <!-- ── Floating mini-preview (dock expand, estilo Gmail) ── -->
596
+ <Teleport to="body">
597
+ <Transition
598
+ enter-active-class="transition ease-out duration-200"
599
+ enter-from-class="opacity-0 translate-y-4"
600
+ enter-to-class="opacity-100 translate-y-0"
601
+ leave-active-class="transition ease-in duration-150"
602
+ leave-from-class="opacity-100 translate-y-0"
603
+ leave-to-class="opacity-0 translate-y-4"
604
+ >
605
+ <div
606
+ v-if="floatingItem"
607
+ class="fixed z-[60] w-96 flex flex-col bg-card border border-card-line rounded-t-xl shadow-2xl overflow-hidden"
608
+ :style="{ ...floatingPanelStyle, maxHeight: 'min(480px, calc(100vh - 60px))' }"
609
+ >
610
+ <div class="flex items-center gap-2 px-3 py-2.5 border-b border-card-line shrink-0 bg-surface select-none">
611
+ <span class="size-6 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
612
+ {{ (floatingItem.label?.[0] ?? '?').toUpperCase() }}
613
+ </span>
614
+ <div class="flex-1 min-w-0">
615
+ <p class="text-sm font-semibold text-foreground truncate leading-tight">{{ floatingItem.label }}</p>
616
+ <p v-if="floatingItem.subtitle" class="text-xs text-muted-foreground truncate">{{ floatingItem.subtitle }}</p>
617
+ </div>
618
+ <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)">
619
+ <IconMaximize class="size-3.5" />
620
+ </button>
621
+ <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()">
622
+ <IconMinus class="size-3.5" />
623
+ </button>
624
+ <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)">
625
+ <IconX class="size-3.5" />
626
+ </button>
627
+ </div>
628
+ <div class="flex-1 overflow-y-auto min-h-0">
629
+ <slot name="preview" :row="floatingItem.row" :close="() => undockItem(floatingItem.id)" />
630
+ </div>
631
+ </div>
632
+ </Transition>
633
+ </Teleport>
634
+
635
+ <!-- Filter menu — teleported to body -->
636
+ <Teleport to="body">
637
+ <Transition
638
+ enter-active-class="transition ease-out duration-150"
639
+ enter-from-class="opacity-0 translate-y-1 scale-95"
640
+ enter-to-class="opacity-100 translate-y-0 scale-100"
641
+ leave-active-class="transition ease-in duration-100"
642
+ leave-from-class="opacity-100 translate-y-0 scale-100"
643
+ leave-to-class="opacity-0 translate-y-1 scale-95"
644
+ >
645
+ <div
646
+ v-if="showFilterPanel"
647
+ ref="filterMenuRef"
648
+ class="fixed z-[60] bg-dropdown border border-dropdown-line rounded-xl shadow-2xl min-w-52 overflow-hidden"
649
+ :style="filterMenuStyle"
650
+ >
651
+
652
+ <!-- Step 1: column picker -->
653
+ <template v-if="filterMenuStep === 'columns'">
654
+ <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-3 pt-2.5 pb-1">Filtrar por</p>
655
+ <div class="pb-1.5">
656
+ <button
657
+ v-for="col in filtersConfig"
658
+ :key="col.key"
659
+ type="button"
660
+ @click.stop="selectFilterColumn(col)"
661
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted-hover transition-colors"
662
+ >
663
+ <span class="flex-1 text-left text-foreground">{{ col.label }}</span>
664
+ <span v-if="activeFilters[col.key]" class="text-[10px] font-semibold text-indigo-500 uppercase">activo</span>
665
+ </button>
666
+ </div>
667
+ </template>
668
+
669
+ <!-- Step 2: value input -->
670
+ <template v-else-if="filterMenuStep === 'value' && pendingCol">
671
+ <div class="flex items-center gap-2 px-3 py-2 border-b border-card-line bg-surface">
672
+ <button
673
+ type="button"
674
+ @click.stop="filterMenuStep = 'columns'"
675
+ class="text-muted-foreground hover:text-foreground transition-colors"
676
+ >
677
+ <IconChevronLeft class="size-4" />
678
+ </button>
679
+ <span class="text-sm font-medium text-foreground">{{ pendingCol.label }}</span>
680
+ </div>
681
+ <div class="p-3 space-y-2.5">
682
+
683
+ <!-- text -->
684
+ <input
685
+ v-if="pendingCol.filterType === 'text'"
686
+ v-model="pendingValue"
687
+ type="text"
688
+ autofocus
689
+ @keydown.enter.stop="applyPendingFilter"
690
+ @keydown.escape.stop="closeFilterMenu"
691
+ placeholder="Buscar..."
692
+ class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
693
+ />
694
+
695
+ <!-- select -->
696
+ <div v-else-if="pendingCol.filterType === 'select'" class="space-y-0.5">
697
+ <button
698
+ v-for="opt in pendingCol.filterOptions"
699
+ :key="opt.value"
700
+ type="button"
701
+ @click.stop="pendingValue = opt.value; applyPendingFilter()"
702
+ :class="[
703
+ 'w-full flex items-center gap-2 px-2.5 py-1.5 text-sm rounded-lg transition-colors text-left',
704
+ pendingValue === opt.value
705
+ ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300'
706
+ : 'hover:bg-muted-hover text-foreground'
707
+ ]"
708
+ >
709
+ <span class="flex-1">{{ opt.label }}</span>
710
+ <IconCheck v-if="pendingValue === opt.value" class="size-3.5 shrink-0 text-indigo-500" />
711
+ </button>
712
+ </div>
713
+
714
+ <!-- daterange -->
715
+ <div v-else-if="pendingCol.filterType === 'daterange'" class="space-y-2">
716
+ <div class="flex gap-1">
717
+ <button
718
+ v-for="op in dateOps"
719
+ :key="op.value"
720
+ type="button"
721
+ @click.stop="pendingDateOp = op.value"
722
+ :class="[
723
+ 'flex-1 py-1 text-xs rounded-lg border transition-colors',
724
+ pendingDateOp === op.value
725
+ ? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300'
726
+ : 'border-card-line text-muted-foreground hover:bg-muted-hover'
727
+ ]"
728
+ >
729
+ {{ op.label }}
730
+ </button>
731
+ </div>
732
+ <template v-if="pendingDateOp === 'between'">
733
+ <input type="date" v-model="pendingValue.from" class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" />
734
+ <input type="date" v-model="pendingValue.to" class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" />
735
+ </template>
736
+ <input v-else type="date" v-model="pendingValue.singleDate" class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" />
737
+ </div>
738
+
739
+ <button
740
+ v-if="pendingCol.filterType !== 'select'"
741
+ type="button"
742
+ @click.stop="applyPendingFilter"
743
+ class="w-full py-1.5 text-sm font-medium text-center rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
744
+ >
745
+ Aplicar
746
+ </button>
747
+ </div>
748
+ </template>
749
+
750
+ </div>
751
+ </Transition>
752
+ </Teleport>
753
+
398
754
  <!-- Column panel — teleported to body to escape overflow-hidden -->
399
755
  <Teleport to="body">
400
756
  <Transition
package/nuxt.config.ts CHANGED
@@ -2,6 +2,10 @@ import tailwindcss from '@tailwindcss/vite'
2
2
 
3
3
  export default defineNuxtConfig({
4
4
  extends: ['@innertia-solutions/nuxt-core'],
5
+ modules: [
6
+ '@pinia/nuxt',
7
+ 'pinia-plugin-persistedstate/nuxt', // required for dockedPreviews store persistence
8
+ ],
5
9
  css: ['@innertia-solutions/nuxt-theme-spark/spark.css'],
6
10
  components: [
7
11
  { path: './components', pathPrefix: true, prefix: '' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.138",
3
+ "version": "0.1.140",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -29,6 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@innertia-solutions/nuxt-core": "^0.1.4",
32
+ "@pinia/nuxt": "^0.11.3",
33
+ "pinia-plugin-persistedstate": "^4.7.1",
32
34
  "@tabler/icons-vue": "^3.44.0",
33
35
  "@tailwindcss/aspect-ratio": "^0.4.2",
34
36
  "@tailwindcss/forms": "^0.5.10",
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Sincroniza el store de previews minimizados entre pestañas del navegador.
3
+ * Escucha los eventos `storage` que dispara localStorage cuando otra pestaña escribe.
4
+ */
5
+ export default defineNuxtPlugin(() => {
6
+ const store = useDockedPreviewsStore()
7
+
8
+ window.addEventListener('storage', (event) => {
9
+ if (event.key !== 'docked-previews' || !event.newValue) return
10
+ try {
11
+ const persisted = JSON.parse(event.newValue)
12
+ store.hydrate(persisted.items ?? [])
13
+ } catch {
14
+ // JSON inválido — ignorar
15
+ }
16
+ })
17
+ })
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Composable para manejar los previews minimizados.
3
+ *
4
+ * - `docked` / `dock` / `undock` / `isActive` → Pinia store (persiste en localStorage, sync entre tabs)
5
+ * - `activeDockId` / `activeDockRect` / expand / collapse → estado de UI efímero (no persistido)
6
+ */
7
+ export function useDockedPreviews() {
8
+ const store = useDockedPreviewsStore()
9
+
10
+ // ─── UI state (no persiste) ───────────────────────────────────────────────────
11
+ const activeDockId = useState('docked-active-id', () => null)
12
+ const activeDockRect = useState('docked-active-rect', () => null)
13
+
14
+ // ─── Acceso reactivo a los items persistidos ──────────────────────────────────
15
+ const docked = computed(() => store.items)
16
+
17
+ // ─── Dock / undock ────────────────────────────────────────────────────────────
18
+ const dock = (payload) => store.add(payload)
19
+
20
+ const undock = (id) => {
21
+ store.remove(id)
22
+ if (activeDockId.value === id) {
23
+ activeDockId.value = null
24
+ activeDockRect.value = null
25
+ }
26
+ }
27
+
28
+ const isActive = (id) => !!store.items.find(d => d.id === id)
29
+
30
+ // ─── Panel flotante ───────────────────────────────────────────────────────────
31
+ const expandDock = (id, rect = null) => {
32
+ if (activeDockId.value === id) {
33
+ activeDockId.value = null
34
+ activeDockRect.value = null
35
+ } else {
36
+ activeDockId.value = id
37
+ activeDockRect.value = rect ? { left: rect.left, width: rect.width } : null
38
+ }
39
+ }
40
+
41
+ const collapseDock = () => {
42
+ activeDockId.value = null
43
+ activeDockRect.value = null
44
+ }
45
+
46
+ return {
47
+ docked,
48
+ activeDockId,
49
+ activeDockRect,
50
+ dock,
51
+ undock,
52
+ isActive,
53
+ expandDock,
54
+ collapseDock,
55
+ }
56
+ }
@@ -0,0 +1,34 @@
1
+ import { defineStore } from 'pinia'
2
+
3
+ // Storage SSR-safe: null en servidor, localStorage en cliente
4
+ const clientStorage = typeof window !== 'undefined' ? window.localStorage : null
5
+
6
+ /**
7
+ * Store persistido en localStorage para los previews minimizados.
8
+ * Sobrevive recargas y se sincroniza entre pestañas (via plugin dockedPreviewsSync).
9
+ */
10
+ export const useDockedPreviewsStore = defineStore('docked-previews', {
11
+ state: () => ({
12
+ items: [],
13
+ }),
14
+
15
+ actions: {
16
+ add({ id, label, subtitle, row, tableName, route }) {
17
+ if (this.items.find(d => d.id === id)) return
18
+ this.items.push({ id, label, subtitle: subtitle ?? null, row, tableName, route })
19
+ },
20
+
21
+ remove(id) {
22
+ this.items = this.items.filter(d => d.id !== id)
23
+ },
24
+
25
+ /** Sincroniza el estado desde otra pestaña (llamado por el plugin de storage). */
26
+ hydrate(items) {
27
+ this.items = items
28
+ },
29
+ },
30
+
31
+ persist: {
32
+ storage: clientStorage,
33
+ },
34
+ })