@innertia-solutions/nuxt-theme-spark 0.1.139 → 0.1.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { IconSearch, IconAdjustmentsHorizontal, IconLayoutColumns, IconGripVertical, IconMinus, IconMaximize, IconX } 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 },
@@ -30,11 +30,9 @@ const forwardedSlots = computed(() => {
30
30
  return Object.fromEntries(Object.entries(slots).filter(([k]) => !excluded.has(k)))
31
31
  })
32
32
 
33
- const search = ref('')
33
+ const search = ref('')
34
34
  const activeFilters = ref({})
35
- const showFilterPanel = ref(false)
36
- const filterPanelRef = ref(null)
37
- const tableRef = ref(null)
35
+ const tableRef = ref(null)
38
36
 
39
37
  // ─── Filter config ─────────────────────────────────────────────────────────────
40
38
  const filtersConfig = computed(() =>
@@ -43,15 +41,153 @@ const filtersConfig = computed(() =>
43
41
 
44
42
  const hasFilterableColumns = computed(() => filtersConfig.value.length > 0)
45
43
 
46
- const activeFilterCount = computed(() =>
47
- 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
+ })
82
+ )
83
+
84
+ const activeFilterCount = computed(() => activeFilterList.value.length)
85
+
86
+ // Columns NOT yet filtered — what appears in the picker (already-active columns are hidden)
87
+ const availableFilterColumns = computed(() =>
88
+ filtersConfig.value.filter(col => {
89
+ const v = activeFilters.value[col.key]
90
+ if (col.filterType === 'daterange') return !v?.from && !v?.to
91
+ return v === null || v === undefined || v === ''
92
+ })
48
93
  )
49
94
 
95
+ // Convert activeFilters to enriched [{field, operator, value}] for the backend DataTable
96
+ const enrichedFilters = computed(() => {
97
+ const result = []
98
+ for (const col of filtersConfig.value) {
99
+ const v = activeFilters.value[col.key]
100
+ if (v === null || v === undefined || v === '') continue
101
+ if (col.filterType === 'daterange') {
102
+ if (!v?.from && !v?.to) continue
103
+ if (v.from) result.push({ field: col.key, operator: 'after', value: v.from })
104
+ if (v.to) result.push({ field: col.key, operator: 'before', value: v.to })
105
+ } else if (col.filterType === 'select') {
106
+ result.push({ field: col.key, operator: 'is', value: v })
107
+ } else {
108
+ result.push({ field: col.key, operator: 'contains', value: v })
109
+ }
110
+ }
111
+ return result
112
+ })
113
+
50
114
  const mergedParams = computed(() => ({
51
115
  ...props.params,
52
- ...activeFilters.value,
116
+ ...(enrichedFilters.value.length ? { filters: enrichedFilters.value } : {}),
53
117
  }))
54
118
 
119
+ const removeFilter = (key) => {
120
+ const u = { ...activeFilters.value }; delete u[key]; activeFilters.value = u
121
+ }
122
+
123
+ const openFilterMenu = async () => {
124
+ filterMenuStep.value = 'columns'
125
+ pendingCol.value = null
126
+ showFilterPanel.value = true
127
+ await nextTick()
128
+ const rect = filterAddBtnRef.value?.getBoundingClientRect()
129
+ if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
130
+ }
131
+
132
+ const toggleFilterMenu = async () => {
133
+ if (showFilterPanel.value) { closeFilterMenu() } else { await openFilterMenu() }
134
+ }
135
+
136
+ const closeFilterMenu = () => {
137
+ showFilterPanel.value = false
138
+ filterMenuStep.value = 'columns'
139
+ pendingCol.value = null
140
+ pendingValue.value = null
141
+ }
142
+
143
+ const selectFilterColumn = (col) => {
144
+ pendingCol.value = col
145
+ const existing = activeFilters.value[col.key]
146
+ if (col.filterType === 'daterange') {
147
+ if (existing?.from && existing?.to) { pendingDateOp.value = 'between'; pendingValue.value = { from: existing.from, to: existing.to, singleDate: '' } }
148
+ else if (existing?.from) { pendingDateOp.value = 'after'; pendingValue.value = { singleDate: existing.from, from: '', to: '' } }
149
+ else if (existing?.to) { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: existing.to, from: '', to: '' } }
150
+ else { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: '', from: '', to: '' } }
151
+ } else {
152
+ pendingValue.value = existing ?? ''
153
+ }
154
+ filterMenuStep.value = 'value'
155
+ }
156
+
157
+ const applyPendingFilter = () => {
158
+ if (!pendingCol.value) return
159
+ const col = pendingCol.value
160
+ let v
161
+ if (col.filterType === 'daterange') {
162
+ if (pendingDateOp.value === 'between') v = { from: pendingValue.value.from, to: pendingValue.value.to }
163
+ else if (pendingDateOp.value === 'after') v = { from: pendingValue.value.singleDate }
164
+ else v = { to: pendingValue.value.singleDate }
165
+ } else {
166
+ v = pendingValue.value
167
+ }
168
+ activeFilters.value = { ...activeFilters.value, [col.key]: v || null }
169
+ closeFilterMenu()
170
+ }
171
+
172
+ const openEditFilter = async (col) => {
173
+ selectFilterColumn(col)
174
+ showFilterPanel.value = true
175
+ await nextTick()
176
+ const rect = filterAddBtnRef.value?.getBoundingClientRect()
177
+ if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
178
+ }
179
+
180
+ const onFilterMenuOutsideClick = (e) => {
181
+ if (filterMenuRef.value && !filterMenuRef.value.contains(e.target) &&
182
+ filterAddBtnRef.value && !filterAddBtnRef.value.contains(e.target)) {
183
+ closeFilterMenu()
184
+ }
185
+ }
186
+ watch(showFilterPanel, v => {
187
+ if (v) document.addEventListener('mousedown', onFilterMenuOutsideClick)
188
+ else document.removeEventListener('mousedown', onFilterMenuOutsideClick)
189
+ })
190
+
55
191
  // ─── Preview panel ─────────────────────────────────────────────────────────────
56
192
  const previewRow = ref(null)
57
193
  const currentRatio = ref(props.splitRatio)
@@ -260,11 +396,6 @@ const onColumnPanelOutsideClick = (e) => {
260
396
  showColumnPanel.value = false
261
397
  }
262
398
  }
263
- const onFilterPanelOutsideClick = (e) => {
264
- if (filterPanelRef.value && !filterPanelRef.value.contains(e.target)) {
265
- showFilterPanel.value = false
266
- }
267
- }
268
399
 
269
400
  watch(showColumnPanel, async (v) => {
270
401
  if (v) {
@@ -281,10 +412,6 @@ watch(showColumnPanel, async (v) => {
281
412
  document.removeEventListener('mousedown', onColumnPanelOutsideClick)
282
413
  }
283
414
  })
284
- watch(showFilterPanel, (v) => {
285
- if (v) document.addEventListener('mousedown', onFilterPanelOutsideClick)
286
- else document.removeEventListener('mousedown', onFilterPanelOutsideClick)
287
- })
288
415
 
289
416
  // ─── Expose ───────────────────────────────────────────────────────────────────
290
417
  const getSelectedRows = () => tableRef.value?.getSelectedRows()
@@ -298,190 +425,201 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, close
298
425
  <template>
299
426
  <div class="relative" ref="containerRef">
300
427
 
301
- <!-- Card único -->
302
- <div class="bg-card border border-card-line rounded-2xl shadow-sm overflow-hidden">
428
+ <!-- Toolbar row (no card) -->
429
+ <div class="flex flex-wrap items-center gap-2 mb-2">
303
430
 
304
- <!-- Toolbar -->
305
- <div class="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-card-line">
306
- <div v-if="showSearch" class="flex-1 min-w-48">
307
- <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
308
- </div>
431
+ <!-- Search -->
432
+ <div v-if="showSearch" class="flex-1 min-w-48 max-w-xs">
433
+ <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
434
+ </div>
309
435
 
310
- <div v-if="showFilters && hasFilterableColumns" class="relative">
311
- <button
312
- type="button"
313
- @click="showFilterPanel = !showFilterPanel"
314
- :class="[
315
- 'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
316
- showFilterPanel || activeFilterCount > 0
317
- ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:border-blue-500 dark:text-blue-300'
318
- : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
319
- ]"
320
- >
321
- <IconAdjustmentsHorizontal class="size-4" stroke="1.5" />
322
- Filtros{{ activeFilterCount > 0 ? ` (${activeFilterCount})` : '' }}
323
- </button>
324
-
325
- <!-- Filter panel — anchored below button -->
326
- <Transition
327
- enter-active-class="transition ease-out duration-150"
328
- enter-from-class="opacity-0 translate-y-1 scale-95"
329
- enter-to-class="opacity-100 translate-y-0 scale-100"
330
- leave-active-class="transition ease-in duration-100"
331
- leave-from-class="opacity-100 translate-y-0 scale-100"
332
- leave-to-class="opacity-0 translate-y-1 scale-95"
333
- >
334
- <div
335
- v-if="showFilterPanel"
336
- ref="filterPanelRef"
337
- 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"
338
- >
339
- <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-3 px-1">Filtros</p>
340
- <TableFilter v-model="activeFilters" :columns="filtersConfig" />
341
- </div>
342
- </Transition>
343
- </div>
436
+ <!-- + Filtros button -->
437
+ <div v-if="showFilters && hasFilterableColumns" ref="filterAddBtnRef" class="relative">
438
+ <button
439
+ type="button"
440
+ @click="toggleFilterMenu"
441
+ :class="[
442
+ 'inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-lg border transition-colors',
443
+ activeFilterList.length
444
+ ? 'border-indigo-300 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:border-indigo-700 dark:text-indigo-300'
445
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
446
+ ]"
447
+ >
448
+ <IconPlus class="size-3.5" />
449
+ Filtros{{ activeFilterList.length ? ` (${activeFilterList.length})` : '' }}
450
+ </button>
451
+ </div>
344
452
 
345
- <slot name="toolbar" />
453
+ <!-- Slot for custom toolbar buttons -->
454
+ <slot name="toolbar" />
346
455
 
456
+ <!-- Secondary actions: pushed to the right, icon-only style -->
457
+ <div class="ml-auto flex items-center gap-1">
347
458
  <button
348
459
  ref="columnButtonRef"
349
460
  type="button"
350
461
  @click="showColumnPanel = !showColumnPanel"
462
+ :title="'Columnas'"
351
463
  :class="[
352
- 'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
464
+ 'p-1.5 inline-flex items-center justify-center rounded-lg border transition-colors',
353
465
  showColumnPanel
354
- ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:border-blue-500 dark:text-blue-300'
355
- : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
466
+ ? 'border-indigo-300 bg-indigo-50 text-indigo-600 dark:bg-indigo-900/20 dark:border-indigo-700 dark:text-indigo-300'
467
+ : 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground'
356
468
  ]"
357
469
  >
358
470
  <IconLayoutColumns class="size-4" />
359
- Columnas
360
471
  </button>
361
472
 
362
473
  <TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
363
474
  </div>
475
+ </div>
364
476
 
365
- <!-- Contenido: tabla siempre full width + preview overlay -->
366
- <div class="relative overflow-hidden">
367
-
368
- <!-- Tabla -->
369
- <Table
370
- ref="tableRef"
371
- :endpoint="resolvedEndpoint"
372
- :columns="columns"
373
- :name="resolvedName"
374
- :params="mergedParams"
375
- :search="search"
376
- :checkable="checkable"
377
- :cached="cached"
378
- :show-reload-button="showReloadButton"
379
- :click-row-to-open="clickRowToOpen"
380
- :preview-row-id="previewRow?.id ?? null"
381
- :preview-mode="!!previewEnabled"
382
- @row-click="handleRowClick"
383
- @loaded="handleLoaded"
384
- @page-change="closePreview"
385
- @per-page-change="closePreview"
477
+ <!-- Filter chips row (shown when filters active) -->
478
+ <div v-if="activeFilterList.length" class="flex flex-wrap items-center gap-1.5 mb-2">
479
+ <div
480
+ v-for="chip in activeFilterList"
481
+ :key="chip.key"
482
+ class="inline-flex items-center text-xs rounded-lg border border-card-line bg-card overflow-hidden"
483
+ >
484
+ <span class="px-2.5 py-1 text-foreground font-medium border-r border-card-line bg-surface">{{ chip.label }}</span>
485
+ <span class="px-2 py-1 text-muted-foreground">{{ chip.displayOp }}</span>
486
+ <button
487
+ type="button"
488
+ @click.stop="openEditFilter(chip.col)"
489
+ 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"
386
490
  >
387
- <template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
388
- <slot :name="name" v-bind="slotProps ?? {}" />
389
- </template>
390
- </Table>
391
-
392
- <!-- Preview panel overlay — slides in from right, tapa la tabla -->
393
- <Transition
394
- :enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
395
- :enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
396
- :enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
397
- leave-active-class="transition ease-in duration-150"
398
- leave-from-class="opacity-100 translate-x-0"
399
- leave-to-class="opacity-0 translate-x-6"
491
+ {{ chip.displayVal }}
492
+ <IconChevronDown class="size-3 opacity-60" />
493
+ </button>
494
+ <button
495
+ type="button"
496
+ @click.stop="removeFilter(chip.key)"
497
+ 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"
400
498
  >
401
- <div
402
- v-if="previewRow && previewEnabled"
403
- ref="previewPanelRef"
404
- 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)]"
405
- :style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
406
- >
407
- <!-- Resize handle -->
408
- <div
409
- class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-indigo-300 dark:hover:bg-indigo-600 transition-colors"
410
- @mousedown="startResize"
411
- />
412
- <!-- Preview -->
413
- <div class="flex flex-col flex-1 overflow-hidden">
499
+ <IconX class="size-3" />
500
+ </button>
501
+ </div>
502
+ </div>
414
503
 
415
- <!-- Barra de acciones del preview -->
416
- <div class="shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-card-line">
417
- <div class="flex-1 min-w-0">
418
- <slot name="preview-header" :row="previewRow" :close="closePreview" />
419
- </div>
420
- <div class="flex items-center gap-1 shrink-0">
421
- <button
422
- type="button"
423
- class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
424
- title="Minimizar"
425
- @click.stop="minimizePreview"
426
- >
427
- <IconMinus class="size-3.5" />
428
- </button>
429
- <button
430
- type="button"
431
- class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
432
- title="Cerrar"
433
- @click.stop="closePreview"
434
- >
435
- <IconX class="size-3.5" />
436
- </button>
437
- </div>
438
- </div>
504
+ <!-- Table + preview overlay inside a minimal border box -->
505
+ <div class="relative overflow-hidden rounded-xl border border-card-line">
506
+
507
+ <!-- Tabla -->
508
+ <Table
509
+ ref="tableRef"
510
+ :endpoint="resolvedEndpoint"
511
+ :columns="columns"
512
+ :name="resolvedName"
513
+ :params="mergedParams"
514
+ :search="search"
515
+ :checkable="checkable"
516
+ :cached="cached"
517
+ :show-reload-button="showReloadButton"
518
+ :click-row-to-open="clickRowToOpen"
519
+ :preview-row-id="previewRow?.id ?? null"
520
+ :preview-mode="!!previewEnabled"
521
+ @row-click="handleRowClick"
522
+ @loaded="handleLoaded"
523
+ @page-change="closePreview"
524
+ @per-page-change="closePreview"
525
+ >
526
+ <template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
527
+ <slot :name="name" v-bind="slotProps ?? {}" />
528
+ </template>
529
+ </Table>
439
530
 
440
- <!-- Scrollable content -->
441
- <div class="flex-1 overflow-y-auto min-h-0">
442
- <slot v-if="previewTab === 'datos'" name="preview" :row="previewRow" :close="closePreview" />
443
- <Table.PreviewTimeline
444
- v-else-if="previewTab === 'bitacora' && resolvedHistoryEndpoint"
445
- :endpoint="resolvedHistoryEndpoint"
446
- />
531
+ <!-- Preview panel overlay — slides in from right, tapa la tabla -->
532
+ <Transition
533
+ :enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
534
+ :enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
535
+ :enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
536
+ leave-active-class="transition ease-in duration-150"
537
+ leave-from-class="opacity-100 translate-x-0"
538
+ leave-to-class="opacity-0 translate-x-6"
539
+ >
540
+ <div
541
+ v-if="previewRow && previewEnabled"
542
+ ref="previewPanelRef"
543
+ 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)]"
544
+ :style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
545
+ >
546
+ <!-- Resize handle -->
547
+ <div
548
+ class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-indigo-300 dark:hover:bg-indigo-600 transition-colors"
549
+ @mousedown="startResize"
550
+ />
551
+ <!-- Preview -->
552
+ <div class="flex flex-col flex-1 overflow-hidden">
553
+
554
+ <!-- Barra de acciones del preview -->
555
+ <div class="shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-card-line">
556
+ <div class="flex-1 min-w-0">
557
+ <slot name="preview-header" :row="previewRow" :close="closePreview" />
447
558
  </div>
448
-
449
- <!-- Tabs — bottom -->
450
- <div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
559
+ <div class="flex items-center gap-1 shrink-0">
451
560
  <button
452
561
  type="button"
453
- @click="previewTab = 'datos'"
454
- :class="[
455
- 'flex-1 py-2.5 text-xs font-semibold transition-colors border-r border-card-line border-t-2 -mt-px',
456
- previewTab === 'datos'
457
- ? 'border-t-card text-foreground'
458
- : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
459
- ]"
562
+ class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
563
+ title="Minimizar"
564
+ @click.stop="minimizePreview"
460
565
  >
461
- Datos
566
+ <IconMinus class="size-3.5" />
462
567
  </button>
463
568
  <button
464
569
  type="button"
465
- @click="resolvedHistoryEndpoint && (previewTab = 'bitacora')"
466
- :disabled="!resolvedHistoryEndpoint"
467
- :class="[
468
- 'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
469
- !resolvedHistoryEndpoint
470
- ? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
471
- : previewTab === 'bitacora'
472
- ? 'border-t-card text-foreground'
473
- : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
474
- ]"
570
+ class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
571
+ title="Cerrar"
572
+ @click.stop="closePreview"
475
573
  >
476
- Bitácora
574
+ <IconX class="size-3.5" />
477
575
  </button>
478
576
  </div>
577
+ </div>
578
+
579
+ <!-- Scrollable content -->
580
+ <div class="flex-1 overflow-y-auto min-h-0">
581
+ <slot v-if="previewTab === 'datos'" name="preview" :row="previewRow" :close="closePreview" />
582
+ <Table.PreviewTimeline
583
+ v-else-if="previewTab === 'bitacora' && resolvedHistoryEndpoint"
584
+ :endpoint="resolvedHistoryEndpoint"
585
+ />
586
+ </div>
479
587
 
588
+ <!-- Tabs — bottom -->
589
+ <div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
590
+ <button
591
+ type="button"
592
+ @click="previewTab = 'datos'"
593
+ :class="[
594
+ 'flex-1 py-2.5 text-xs font-semibold transition-colors border-r border-card-line border-t-2 -mt-px',
595
+ previewTab === 'datos'
596
+ ? 'border-t-card text-foreground'
597
+ : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
598
+ ]"
599
+ >
600
+ Datos
601
+ </button>
602
+ <button
603
+ type="button"
604
+ @click="resolvedHistoryEndpoint && (previewTab = 'bitacora')"
605
+ :disabled="!resolvedHistoryEndpoint"
606
+ :class="[
607
+ 'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
608
+ !resolvedHistoryEndpoint
609
+ ? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
610
+ : previewTab === 'bitacora'
611
+ ? 'border-t-card text-foreground'
612
+ : 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
613
+ ]"
614
+ >
615
+ Bitácora
616
+ </button>
480
617
  </div>
618
+
481
619
  </div>
482
- </Transition>
620
+ </div>
621
+ </Transition>
483
622
 
484
- </div>
485
623
  </div>
486
624
 
487
625
  <!-- ── Floating mini-preview (dock expand, estilo Gmail) ── -->
@@ -524,6 +662,129 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, close
524
662
  </Transition>
525
663
  </Teleport>
526
664
 
665
+ <!-- Filter menu — teleported to body -->
666
+ <Teleport to="body">
667
+ <Transition
668
+ enter-active-class="transition ease-out duration-150"
669
+ enter-from-class="opacity-0 translate-y-1 scale-95"
670
+ enter-to-class="opacity-100 translate-y-0 scale-100"
671
+ leave-active-class="transition ease-in duration-100"
672
+ leave-from-class="opacity-100 translate-y-0 scale-100"
673
+ leave-to-class="opacity-0 translate-y-1 scale-95"
674
+ >
675
+ <div
676
+ v-if="showFilterPanel"
677
+ ref="filterMenuRef"
678
+ class="fixed z-[60] bg-dropdown border border-dropdown-line rounded-xl shadow-2xl min-w-52 overflow-hidden"
679
+ :style="filterMenuStyle"
680
+ >
681
+
682
+ <!-- Step 1: column picker (only non-filtered columns shown) -->
683
+ <template v-if="filterMenuStep === 'columns'">
684
+ <p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-3 pt-2.5 pb-1">Filtrar por</p>
685
+ <div class="pb-1.5">
686
+ <template v-if="availableFilterColumns.length">
687
+ <button
688
+ v-for="col in availableFilterColumns"
689
+ :key="col.key"
690
+ type="button"
691
+ @click.stop="selectFilterColumn(col)"
692
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted-hover transition-colors text-left text-foreground"
693
+ >
694
+ {{ col.label }}
695
+ </button>
696
+ </template>
697
+ <p v-else class="px-3 py-3 text-xs text-muted-foreground italic">
698
+ Todos los filtros están configurados
699
+ </p>
700
+ </div>
701
+ </template>
702
+
703
+ <!-- Step 2: value input -->
704
+ <template v-else-if="filterMenuStep === 'value' && pendingCol">
705
+ <div class="flex items-center gap-2 px-3 py-2 border-b border-card-line bg-surface">
706
+ <button
707
+ type="button"
708
+ @click.stop="filterMenuStep = 'columns'"
709
+ class="text-muted-foreground hover:text-foreground transition-colors"
710
+ >
711
+ <IconChevronLeft class="size-4" />
712
+ </button>
713
+ <span class="text-sm font-medium text-foreground">{{ pendingCol.label }}</span>
714
+ </div>
715
+ <div class="p-3 space-y-2.5">
716
+
717
+ <!-- text -->
718
+ <input
719
+ v-if="pendingCol.filterType === 'text'"
720
+ v-model="pendingValue"
721
+ type="text"
722
+ autofocus
723
+ @keydown.enter.stop="applyPendingFilter"
724
+ @keydown.escape.stop="closeFilterMenu"
725
+ placeholder="Buscar..."
726
+ 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"
727
+ />
728
+
729
+ <!-- select -->
730
+ <div v-else-if="pendingCol.filterType === 'select'" class="space-y-0.5">
731
+ <button
732
+ v-for="opt in pendingCol.filterOptions"
733
+ :key="opt.value"
734
+ type="button"
735
+ @click.stop="pendingValue = opt.value; applyPendingFilter()"
736
+ :class="[
737
+ 'w-full flex items-center gap-2 px-2.5 py-1.5 text-sm rounded-lg transition-colors text-left',
738
+ pendingValue === opt.value
739
+ ? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300'
740
+ : 'hover:bg-muted-hover text-foreground'
741
+ ]"
742
+ >
743
+ <span class="flex-1">{{ opt.label }}</span>
744
+ <IconCheck v-if="pendingValue === opt.value" class="size-3.5 shrink-0 text-indigo-500" />
745
+ </button>
746
+ </div>
747
+
748
+ <!-- daterange -->
749
+ <div v-else-if="pendingCol.filterType === 'daterange'" class="space-y-2">
750
+ <div class="flex gap-1">
751
+ <button
752
+ v-for="op in dateOps"
753
+ :key="op.value"
754
+ type="button"
755
+ @click.stop="pendingDateOp = op.value"
756
+ :class="[
757
+ 'flex-1 py-1 text-xs rounded-lg border transition-colors',
758
+ pendingDateOp === op.value
759
+ ? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300'
760
+ : 'border-card-line text-muted-foreground hover:bg-muted-hover'
761
+ ]"
762
+ >
763
+ {{ op.label }}
764
+ </button>
765
+ </div>
766
+ <template v-if="pendingDateOp === 'between'">
767
+ <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" />
768
+ <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" />
769
+ </template>
770
+ <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" />
771
+ </div>
772
+
773
+ <button
774
+ v-if="pendingCol.filterType !== 'select'"
775
+ type="button"
776
+ @click.stop="applyPendingFilter"
777
+ 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"
778
+ >
779
+ Aplicar
780
+ </button>
781
+ </div>
782
+ </template>
783
+
784
+ </div>
785
+ </Transition>
786
+ </Teleport>
787
+
527
788
  <!-- Column panel — teleported to body to escape overflow-hidden -->
528
789
  <Teleport to="body">
529
790
  <Transition
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/nuxt-theme-spark",
3
- "version": "0.1.139",
3
+ "version": "0.1.141",
4
4
  "description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
5
5
  "keywords": [
6
6
  "nuxt",