@innertia-solutions/innertia-nuxt 0.1.8 → 0.1.10

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.
@@ -281,13 +281,21 @@ watch(previewRow, async (row) => {
281
281
  })
282
282
 
283
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 {
284
+ if (!previewEnabled.value) {
289
285
  emit('row-click', row)
286
+ return
287
+ }
288
+
289
+ // Si la misma fila ya tiene el preview abierto, no hacer nada.
290
+ // Comparación stringificada para tolerar mismatch de tipo (string vs number).
291
+ const currentId = previewRow.value?.id
292
+ const incomingId = row?.id
293
+ if (currentId != null && incomingId != null && String(currentId) === String(incomingId)) {
294
+ return
290
295
  }
296
+
297
+ collapseDock()
298
+ previewRow.value = row
291
299
  }
292
300
 
293
301
  // Persist preview row in session cache when table cache is enabled
@@ -715,7 +723,7 @@ defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, close
715
723
  </div>
716
724
 
717
725
  <!-- Tabla -->
718
- <div class="overflow-hidden border-t border-b border-card-line">
726
+ <div class="overflow-hidden border border-card-line rounded-xl">
719
727
  <Table
720
728
  ref="tableRef"
721
729
  :endpoint="resolvedEndpoint"
@@ -0,0 +1,155 @@
1
+ <script setup>
2
+ import { IconChevronDown, IconChevronRight, IconLoader2 } from '@tabler/icons-vue'
3
+
4
+ const props = defineProps({
5
+ node: { type: Object, required: true },
6
+ columns: { type: Array, required: true },
7
+ depth: { type: Number, default: 0 },
8
+ expanded: { type: Object, required: true },
9
+ loadingSet: { type: Object, required: true },
10
+ childrenById: { type: Object, required: true },
11
+ rowHref: { type: [String, Function], default: null },
12
+ clickableRows: { type: Boolean, default: true },
13
+ nameColKey: { type: String, default: 'name' },
14
+
15
+ // Checkbox selection
16
+ checkable: { type: Boolean, default: false },
17
+ checkedSet: { type: Object, default: null }, // Set of node ids
18
+
19
+ // Column visibility — map { [colKey]: boolean }. Si null, todas visibles.
20
+ columnVisibility: { type: Object, default: null },
21
+ })
22
+
23
+ const emit = defineEmits(['toggle', 'row-click', 'check'])
24
+
25
+ const isExpanded = computed(() => props.expanded.has(props.node.id))
26
+ const isLoading = computed(() => props.loadingSet.has(props.node.id))
27
+ const hasChildren = computed(() => !!props.node.has_children)
28
+ const isChecked = computed(() => props.checkable && props.checkedSet?.has(props.node.id))
29
+
30
+ const children = computed(() => {
31
+ if (Array.isArray(props.node.children)) return props.node.children
32
+ return props.childrenById[props.node.id] ?? []
33
+ })
34
+
35
+ const visibleColumns = computed(() => {
36
+ if (!props.columnVisibility) return props.columns
37
+ return props.columns.filter(c => props.columnVisibility[c.key] !== false)
38
+ })
39
+
40
+ const onToggle = (e) => {
41
+ e?.stopPropagation?.()
42
+ emit('toggle', props.node)
43
+ }
44
+
45
+ const onRowClick = (e) => {
46
+ if (!props.clickableRows) return
47
+ if (props.rowHref) return
48
+ emit('row-click', props.node)
49
+ }
50
+
51
+ const onCheck = (e) => {
52
+ e?.stopPropagation?.()
53
+ emit('check', props.node)
54
+ }
55
+
56
+ const href = computed(() => {
57
+ if (!props.rowHref) return null
58
+ return typeof props.rowHref === 'function' ? props.rowHref(props.node) : props.rowHref
59
+ })
60
+
61
+ const indentPx = computed(() => props.depth * 20)
62
+ </script>
63
+
64
+ <template>
65
+ <!-- Row -->
66
+ <tr
67
+ class="bg-card hover:bg-layer-hover transition-colors"
68
+ :class="[
69
+ clickableRows ? 'cursor-pointer' : '',
70
+ isChecked ? 'bg-primary/5' : '',
71
+ ]"
72
+ @click="onRowClick"
73
+ >
74
+ <!-- Checkbox column -->
75
+ <td v-if="checkable" class="w-10 px-4 py-2.5 align-middle" @click.stop>
76
+ <input
77
+ type="checkbox"
78
+ :checked="isChecked"
79
+ class="size-4 rounded border-card-line text-primary focus:ring-primary/30 cursor-pointer"
80
+ @change="onCheck"
81
+ />
82
+ </td>
83
+
84
+ <td
85
+ v-for="col in visibleColumns"
86
+ :key="col.key"
87
+ class="px-4 py-2.5 text-sm align-middle"
88
+ >
89
+ <!-- First column: indent + chevron + name -->
90
+ <template v-if="col.key === nameColKey">
91
+ <div class="flex items-center min-w-0" :style="{ paddingLeft: indentPx + 'px' }">
92
+ <button
93
+ v-if="hasChildren"
94
+ type="button"
95
+ class="size-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground mr-1.5 shrink-0"
96
+ @click.stop="onToggle"
97
+ >
98
+ <IconLoader2 v-if="isLoading" class="size-3.5 animate-spin" />
99
+ <IconChevronDown v-else-if="isExpanded" class="size-3.5" />
100
+ <IconChevronRight v-else class="size-3.5" />
101
+ </button>
102
+ <span v-else class="size-5 mr-1.5 shrink-0" />
103
+
104
+ <component
105
+ :is="href ? 'NuxtLink' : 'span'"
106
+ :to="href || undefined"
107
+ class="min-w-0 flex items-center gap-x-2 truncate"
108
+ :class="href ? 'hover:text-primary' : ''"
109
+ @click.stop
110
+ >
111
+ <slot :name="`cell-${col.key}`" :row="node" :value="node[col.key]">
112
+ <span class="truncate font-medium text-foreground">{{ node[col.key] }}</span>
113
+ </slot>
114
+ </component>
115
+ </div>
116
+ </template>
117
+
118
+ <!-- Other columns -->
119
+ <template v-else>
120
+ <div class="truncate text-muted-foreground-1">
121
+ <slot :name="`cell-${col.key}`" :row="node" :value="node[col.key]">
122
+ {{ node[col.key] ?? '—' }}
123
+ </slot>
124
+ </div>
125
+ </template>
126
+ </td>
127
+ </tr>
128
+
129
+ <!-- Children rows -->
130
+ <template v-if="isExpanded && children.length">
131
+ <TreeNode
132
+ v-for="child in children"
133
+ :key="child.id"
134
+ :node="child"
135
+ :columns="columns"
136
+ :depth="depth + 1"
137
+ :expanded="expanded"
138
+ :loading-set="loadingSet"
139
+ :children-by-id="childrenById"
140
+ :row-href="rowHref"
141
+ :clickable-rows="clickableRows"
142
+ :name-col-key="nameColKey"
143
+ :checkable="checkable"
144
+ :checked-set="checkedSet"
145
+ :column-visibility="columnVisibility"
146
+ @toggle="(n) => emit('toggle', n)"
147
+ @row-click="(n) => emit('row-click', n)"
148
+ @check="(n) => emit('check', n)"
149
+ >
150
+ <template v-for="col in columns" :key="col.key" #[`cell-${col.key}`]="slotProps">
151
+ <slot :name="`cell-${col.key}`" v-bind="slotProps" />
152
+ </template>
153
+ </TreeNode>
154
+ </template>
155
+ </template>
@@ -0,0 +1,623 @@
1
+ <script setup>
2
+ import {
3
+ IconLoader2, IconSearch, IconRefresh, IconPlus,
4
+ IconLayoutColumns, IconChevronDown, IconX, IconDownload,
5
+ IconFileTypeXls, IconFileTypeCsv, IconFileTypePdf, IconCodeDots,
6
+ } from '@tabler/icons-vue'
7
+
8
+ const props = defineProps({
9
+ // Required: backend route that returns DataTree-shaped responses.
10
+ endpoint: { type: String, required: true },
11
+
12
+ // Columns shape (compatible with Table.Standard):
13
+ // { key, label, size?, sortable?, filterType?: 'text'|'select'|'daterange', filterOptions?, hidden? }
14
+ // The first column is the "name" column and gets indented + chevron.
15
+ columns: { type: Array, required: true },
16
+
17
+ // Optional name (used for cache keys / export filename).
18
+ name: { type: String, default: '' },
19
+
20
+ // Extra body params merged into the request.
21
+ params: { type: Object, default: () => ({}) },
22
+
23
+ // Filters reactive (igual a Table.Standard).
24
+ filters: { type: Array, default: () => [] },
25
+
26
+ // Toolbar visibility.
27
+ showSearch: { type: Boolean, default: true },
28
+ searchPlaceholder: { type: String, default: 'Buscar...' },
29
+ showFilters: { type: Boolean, default: true },
30
+ showColumns: { type: Boolean, default: true },
31
+ showExport: { type: Boolean, default: true },
32
+ showReloadButton: { type: Boolean, default: true },
33
+
34
+ // Checkbox selection (multi-select).
35
+ checkable: { type: Boolean, default: false },
36
+
37
+ // Row interaction.
38
+ rowHref: { type: [String, Function], default: null },
39
+ clickableRows: { type: Boolean, default: true },
40
+
41
+ // Empty state.
42
+ emptyMessage: { type: String, default: 'No hay registros.' },
43
+ })
44
+
45
+ const emit = defineEmits([
46
+ 'row-click', 'loaded', 'expand',
47
+ 'update:checked', 'export',
48
+ ])
49
+
50
+ const api = useApi()
51
+
52
+ // ─── State ────────────────────────────────────────────────────────────────────
53
+ const roots = ref([])
54
+ const meta = ref(null)
55
+ const loading = ref(false)
56
+ const isFetching = ref(false)
57
+ const search = ref('')
58
+ const expandedSet = ref(new Set())
59
+ const loadingSet = ref(new Set())
60
+ const childrenById = ref({})
61
+ const checkedSet = ref(new Set())
62
+
63
+ // Filters — { [colKey]: value }
64
+ const activeFilters = ref({})
65
+
66
+ // Column visibility — { [colKey]: boolean }
67
+ const columnVisibility = ref(
68
+ Object.fromEntries(props.columns.map(c => [c.key, c.hidden !== true]))
69
+ )
70
+
71
+ // ─── Derived ──────────────────────────────────────────────────────────────────
72
+ const filterableColumns = computed(() =>
73
+ props.filters?.length ? props.filters : props.columns.filter(c => c.filterType)
74
+ )
75
+ const hasFilterableColumns = computed(() => filterableColumns.value.length > 0)
76
+
77
+ const activeFilterList = computed(() =>
78
+ Object.entries(activeFilters.value)
79
+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
80
+ .map(([key, value]) => {
81
+ const col = props.columns.find(c => c.key === key) ?? filterableColumns.value.find(c => c.key === key)
82
+ const label = col?.label ?? key
83
+ let displayVal = value
84
+ if (col?.filterType === 'select' && Array.isArray(col?.filterOptions)) {
85
+ const opt = col.filterOptions.find(o => o.value === value)
86
+ if (opt) displayVal = opt.label
87
+ }
88
+ return { key, col, label, displayVal: String(displayVal) }
89
+ })
90
+ )
91
+
92
+ const visibleColumns = computed(() =>
93
+ props.columns.filter(c => columnVisibility.value[c.key] !== false)
94
+ )
95
+
96
+ const nameColKey = computed(() => props.columns[0]?.key ?? 'name')
97
+ const totalRendered = computed(() => meta.value?.total_nodes ?? 0)
98
+
99
+ // ─── Fetching ─────────────────────────────────────────────────────────────────
100
+ const buildBody = (extra = {}) => ({
101
+ ...props.params,
102
+ search: search.value.trim(),
103
+ filters: { ...activeFilters.value },
104
+ ...extra,
105
+ })
106
+
107
+ const fetchInitial = async () => {
108
+ loading.value = true
109
+ isFetching.value = true
110
+ try {
111
+ const res = await api.post(props.endpoint, buildBody())
112
+ roots.value = res?.data ?? []
113
+ meta.value = res?.meta ?? null
114
+ childrenById.value = {}
115
+ emit('loaded', res)
116
+ } catch (e) {
117
+ console.error('[Tree.Standard] initial fetch failed:', e)
118
+ roots.value = []
119
+ } finally {
120
+ loading.value = false
121
+ isFetching.value = false
122
+ }
123
+ }
124
+
125
+ const fetchChildren = async (nodeId) => {
126
+ if (childrenById.value[nodeId]) return
127
+ if (loadingSet.value.has(nodeId)) return
128
+ loadingSet.value = new Set([...loadingSet.value, nodeId])
129
+ try {
130
+ const res = await api.post(props.endpoint, buildBody({ expand: nodeId }))
131
+ childrenById.value[nodeId] = res?.data ?? []
132
+ emit('expand', { nodeId, children: childrenById.value[nodeId] })
133
+ } catch (e) {
134
+ console.error(`[Tree.Standard] expand fetch failed for ${nodeId}:`, e)
135
+ childrenById.value[nodeId] = []
136
+ } finally {
137
+ const next = new Set(loadingSet.value)
138
+ next.delete(nodeId)
139
+ loadingSet.value = next
140
+ }
141
+ }
142
+
143
+ const toggle = async (node) => {
144
+ const id = node.id
145
+ const next = new Set(expandedSet.value)
146
+ if (next.has(id)) {
147
+ next.delete(id)
148
+ } else {
149
+ next.add(id)
150
+ const needsLazy = node.has_children
151
+ && (!Object.prototype.hasOwnProperty.call(node, 'children'))
152
+ if (needsLazy) await fetchChildren(id)
153
+ }
154
+ expandedSet.value = next
155
+ }
156
+
157
+ // ─── Search (debounced) ───────────────────────────────────────────────────────
158
+ let searchTimer = null
159
+ watch(search, () => {
160
+ if (searchTimer) clearTimeout(searchTimer)
161
+ searchTimer = setTimeout(() => fetchInitial(), 250)
162
+ })
163
+
164
+ // ─── Filters ──────────────────────────────────────────────────────────────────
165
+ const showFilterPanel = ref(false)
166
+ const filterAddBtnRef = ref(null)
167
+
168
+ const openFilterMenu = () => {
169
+ showFilterPanel.value = !showFilterPanel.value
170
+ }
171
+
172
+ const updateFilters = (next) => {
173
+ activeFilters.value = { ...next }
174
+ fetchInitial()
175
+ }
176
+
177
+ const removeFilter = (key) => {
178
+ const next = { ...activeFilters.value }
179
+ delete next[key]
180
+ activeFilters.value = next
181
+ fetchInitial()
182
+ }
183
+
184
+ // ─── Column visibility ────────────────────────────────────────────────────────
185
+ const showColumnPanel = ref(false)
186
+
187
+ const toggleColumnVisibility = (key) => {
188
+ columnVisibility.value = {
189
+ ...columnVisibility.value,
190
+ [key]: !columnVisibility.value[key],
191
+ }
192
+ }
193
+
194
+ const visibleColumnCount = computed(() =>
195
+ Object.values(columnVisibility.value).filter(Boolean).length
196
+ )
197
+
198
+ // ─── Checkbox selection ───────────────────────────────────────────────────────
199
+ const flattenedIds = computed(() => {
200
+ const acc = []
201
+ const walk = (nodes) => {
202
+ for (const n of nodes) {
203
+ acc.push(n.id)
204
+ const kids = Array.isArray(n.children) ? n.children : (childrenById.value[n.id] ?? [])
205
+ if (kids.length) walk(kids)
206
+ }
207
+ }
208
+ walk(roots.value)
209
+ return acc
210
+ })
211
+
212
+ const allChecked = computed(() =>
213
+ flattenedIds.value.length > 0
214
+ && flattenedIds.value.every(id => checkedSet.value.has(id))
215
+ )
216
+ const someChecked = computed(() =>
217
+ flattenedIds.value.some(id => checkedSet.value.has(id))
218
+ && !allChecked.value
219
+ )
220
+
221
+ const toggleCheck = (node) => {
222
+ const next = new Set(checkedSet.value)
223
+ if (next.has(node.id)) next.delete(node.id)
224
+ else next.add(node.id)
225
+ checkedSet.value = next
226
+ emit('update:checked', [...next])
227
+ }
228
+
229
+ const toggleCheckAll = () => {
230
+ if (allChecked.value) {
231
+ checkedSet.value = new Set()
232
+ } else {
233
+ checkedSet.value = new Set(flattenedIds.value)
234
+ }
235
+ emit('update:checked', [...checkedSet.value])
236
+ }
237
+
238
+ const clearChecked = () => {
239
+ checkedSet.value = new Set()
240
+ emit('update:checked', [])
241
+ }
242
+
243
+ // ─── Export ───────────────────────────────────────────────────────────────────
244
+ const showExportPanel = ref(false)
245
+ const exportFormat = ref('xlsx')
246
+ const exportFilename = ref(props.name || 'export')
247
+
248
+ watch(() => props.name, (v) => { if (v) exportFilename.value = v })
249
+
250
+ const exportFormats = [
251
+ { value: 'xlsx', label: 'Excel', icon: IconFileTypeXls },
252
+ { value: 'csv', label: 'CSV', icon: IconFileTypeCsv },
253
+ { value: 'pdf', label: 'PDF', icon: IconFileTypePdf },
254
+ { value: 'json', label: 'JSON', icon: IconCodeDots },
255
+ ]
256
+
257
+ /**
258
+ * Flatten visible tree (con depth como prefijo en la columna del nombre) y delega
259
+ * al consumidor via emit('export', { format, filename, rows, columns }).
260
+ * El parent puede engancharlo a un endpoint backend de export — o ignorar y manejar
261
+ * client-side (xlsx-js, csv-stringify, etc.).
262
+ */
263
+ const performExport = () => {
264
+ const rows = []
265
+ const walk = (nodes, depth = 0) => {
266
+ for (const n of nodes) {
267
+ const row = { ...n }
268
+ row[nameColKey.value] = `${' '.repeat(depth)}${n[nameColKey.value] ?? ''}`
269
+ rows.push(row)
270
+ const kids = Array.isArray(n.children) ? n.children : (childrenById.value[n.id] ?? [])
271
+ if (kids.length && expandedSet.value.has(n.id)) walk(kids, depth + 1)
272
+ }
273
+ }
274
+ walk(roots.value)
275
+
276
+ emit('export', {
277
+ format: exportFormat.value,
278
+ filename: exportFilename.value,
279
+ rows,
280
+ columns: visibleColumns.value,
281
+ })
282
+ showExportPanel.value = false
283
+ }
284
+
285
+ // ─── Lifecycle ────────────────────────────────────────────────────────────────
286
+ onMounted(fetchInitial)
287
+
288
+ defineExpose({
289
+ refresh: fetchInitial,
290
+ clearChecked,
291
+ getChecked: () => [...checkedSet.value],
292
+ })
293
+ </script>
294
+
295
+ <template>
296
+ <div class="relative">
297
+
298
+ <!-- ── Toolbar ────────────────────────────────────────────────────────── -->
299
+ <div class="flex flex-wrap items-center gap-2 mb-2">
300
+
301
+ <!-- Search -->
302
+ <div v-if="showSearch" class="flex-1 min-w-48 max-w-xs">
303
+ <Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
304
+ </div>
305
+
306
+ <!-- + Filtros -->
307
+ <div v-if="showFilters && hasFilterableColumns" ref="filterAddBtnRef" class="relative">
308
+ <button
309
+ type="button"
310
+ @click="openFilterMenu"
311
+ :class="[
312
+ 'inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-lg border transition-colors',
313
+ activeFilterList.length
314
+ ? 'border-primary/40 bg-primary/10 text-primary'
315
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
316
+ ]"
317
+ >
318
+ <IconPlus class="size-3.5" />
319
+ Filtros{{ activeFilterList.length ? ` (${activeFilterList.length})` : '' }}
320
+ </button>
321
+
322
+ <!-- Filter panel -->
323
+ <Transition
324
+ enter-active-class="transition ease-out duration-150"
325
+ enter-from-class="opacity-0 -translate-y-1"
326
+ enter-to-class="opacity-100 translate-y-0"
327
+ leave-active-class="transition ease-in duration-100"
328
+ leave-from-class="opacity-100"
329
+ leave-to-class="opacity-0"
330
+ >
331
+ <div
332
+ v-if="showFilterPanel"
333
+ class="absolute left-0 top-full mt-1 z-30 w-80 bg-card border border-card-line rounded-xl shadow-xl p-4"
334
+ @click.stop
335
+ >
336
+ <TableFilter :model-value="activeFilters" :columns="filterableColumns" @update:model-value="updateFilters" />
337
+ <div class="flex justify-end gap-2 mt-3">
338
+ <button
339
+ type="button"
340
+ @click="showFilterPanel = false"
341
+ class="text-xs text-muted-foreground hover:text-foreground"
342
+ >Cerrar</button>
343
+ </div>
344
+ </div>
345
+ </Transition>
346
+ </div>
347
+
348
+ <!-- Slot for custom toolbar buttons -->
349
+ <slot name="toolbar" :checked="[...checkedSet]" :clear-checked="clearChecked" />
350
+
351
+ <!-- Secondary actions -->
352
+ <div class="ml-auto flex items-center gap-1">
353
+
354
+ <!-- Columns dropdown -->
355
+ <div v-if="showColumns" class="relative">
356
+ <button
357
+ type="button"
358
+ @click="showColumnPanel = !showColumnPanel"
359
+ title="Columnas"
360
+ :class="[
361
+ 'p-1.5 inline-flex items-center justify-center rounded-lg border transition-colors',
362
+ showColumnPanel
363
+ ? 'border-primary/40 bg-primary/10 text-primary'
364
+ : 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground',
365
+ ]"
366
+ >
367
+ <IconLayoutColumns class="size-4" />
368
+ </button>
369
+
370
+ <Transition
371
+ enter-active-class="transition ease-out duration-150"
372
+ enter-from-class="opacity-0 -translate-y-1"
373
+ enter-to-class="opacity-100 translate-y-0"
374
+ leave-active-class="transition ease-in duration-100"
375
+ leave-from-class="opacity-100"
376
+ leave-to-class="opacity-0"
377
+ >
378
+ <div
379
+ v-if="showColumnPanel"
380
+ class="absolute right-0 top-full mt-1 z-30 w-56 bg-card border border-card-line rounded-xl shadow-xl p-2"
381
+ @click.stop
382
+ >
383
+ <p class="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
384
+ Columnas ({{ visibleColumnCount }}/{{ columns.length }})
385
+ </p>
386
+ <ul class="mt-1 space-y-0.5">
387
+ <li v-for="col in columns" :key="col.key">
388
+ <label class="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted-hover cursor-pointer text-sm">
389
+ <input
390
+ type="checkbox"
391
+ :checked="columnVisibility[col.key] !== false"
392
+ :disabled="col.key === nameColKey"
393
+ class="size-3.5 rounded border-card-line text-primary focus:ring-primary/30"
394
+ @change="toggleColumnVisibility(col.key)"
395
+ />
396
+ <span class="text-foreground">{{ col.label }}</span>
397
+ </label>
398
+ </li>
399
+ </ul>
400
+ </div>
401
+ </Transition>
402
+ </div>
403
+
404
+ <!-- Export dropdown -->
405
+ <div v-if="showExport" class="relative">
406
+ <button
407
+ type="button"
408
+ @click="showExportPanel = !showExportPanel"
409
+ title="Exportar"
410
+ :class="[
411
+ 'p-1.5 inline-flex items-center justify-center rounded-lg border transition-colors',
412
+ showExportPanel
413
+ ? 'border-primary/40 bg-primary/10 text-primary'
414
+ : 'border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground',
415
+ ]"
416
+ >
417
+ <IconDownload class="size-4" />
418
+ </button>
419
+
420
+ <Transition
421
+ enter-active-class="transition ease-out duration-150"
422
+ enter-from-class="opacity-0 -translate-y-1"
423
+ enter-to-class="opacity-100 translate-y-0"
424
+ leave-active-class="transition ease-in duration-100"
425
+ leave-from-class="opacity-100"
426
+ leave-to-class="opacity-0"
427
+ >
428
+ <div
429
+ v-if="showExportPanel"
430
+ class="absolute right-0 top-full mt-1 z-30 w-72 bg-card border border-card-line rounded-xl shadow-xl p-3 space-y-3"
431
+ @click.stop
432
+ >
433
+ <div>
434
+ <label class="block text-xs font-medium text-muted-foreground mb-1">Nombre del archivo</label>
435
+ <input
436
+ v-model="exportFilename"
437
+ type="text"
438
+ class="w-full text-sm px-2 py-1.5 border border-card-line rounded-md bg-transparent focus:outline-none focus:border-primary/50"
439
+ />
440
+ </div>
441
+ <div>
442
+ <p class="text-xs font-medium text-muted-foreground mb-1">Formato</p>
443
+ <div class="grid grid-cols-2 gap-1.5">
444
+ <button
445
+ v-for="f in exportFormats"
446
+ :key="f.value"
447
+ type="button"
448
+ @click="exportFormat = f.value"
449
+ :class="[
450
+ 'flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md border transition-colors',
451
+ exportFormat === f.value
452
+ ? 'border-primary/40 bg-primary/10 text-primary'
453
+ : 'border-card-line text-foreground hover:bg-muted-hover',
454
+ ]"
455
+ >
456
+ <component :is="f.icon" class="size-4" stroke="1.5" />
457
+ {{ f.label }}
458
+ </button>
459
+ </div>
460
+ </div>
461
+ <div class="flex justify-end gap-2">
462
+ <button
463
+ type="button"
464
+ @click="showExportPanel = false"
465
+ class="text-xs text-muted-foreground hover:text-foreground"
466
+ >Cancelar</button>
467
+ <button
468
+ type="button"
469
+ @click="performExport"
470
+ class="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary-hover"
471
+ >Exportar</button>
472
+ </div>
473
+ </div>
474
+ </Transition>
475
+ </div>
476
+
477
+ <!-- Reload -->
478
+ <button
479
+ v-if="showReloadButton"
480
+ type="button"
481
+ @click="fetchInitial"
482
+ :disabled="isFetching"
483
+ title="Recargar"
484
+ class="p-1.5 inline-flex items-center justify-center rounded-lg border border-transparent text-muted-foreground hover:border-card-line hover:bg-muted-hover hover:text-foreground transition-colors disabled:opacity-50"
485
+ >
486
+ <IconRefresh class="size-4" :class="isFetching ? 'animate-spin' : ''" />
487
+ </button>
488
+ </div>
489
+ </div>
490
+
491
+ <!-- ── Filter chips ───────────────────────────────────────────────────── -->
492
+ <div v-if="activeFilterList.length" class="flex flex-wrap items-center gap-1.5 mb-2">
493
+ <div
494
+ v-for="chip in activeFilterList"
495
+ :key="chip.key"
496
+ class="inline-flex items-center text-xs rounded-lg border border-card-line bg-card overflow-hidden"
497
+ >
498
+ <span class="px-2.5 py-1 text-foreground font-medium border-r border-card-line bg-surface">{{ chip.label }}</span>
499
+ <span class="px-2 py-1 text-primary font-medium">{{ chip.displayVal }}</span>
500
+ <button
501
+ type="button"
502
+ @click.stop="removeFilter(chip.key)"
503
+ 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"
504
+ >
505
+ <IconX class="size-3" />
506
+ </button>
507
+ </div>
508
+ </div>
509
+
510
+ <!-- ── Selection bar (when items checked) ─────────────────────────────── -->
511
+ <div
512
+ v-if="checkable && checkedSet.size > 0"
513
+ class="flex items-center justify-between gap-2 mb-2 px-3 py-2 rounded-lg bg-primary/5 border border-primary/20"
514
+ >
515
+ <span class="text-sm text-primary font-medium">
516
+ {{ checkedSet.size }} seleccionado{{ checkedSet.size === 1 ? '' : 's' }}
517
+ </span>
518
+ <div class="flex items-center gap-2">
519
+ <slot name="selection-actions" :checked="[...checkedSet]" :clear="clearChecked" />
520
+ <button
521
+ type="button"
522
+ @click="clearChecked"
523
+ class="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
524
+ >
525
+ <IconX class="size-3" /> Limpiar
526
+ </button>
527
+ </div>
528
+ </div>
529
+
530
+ <!-- ── Table wrapper (rounded) ────────────────────────────────────────── -->
531
+ <div class="overflow-x-auto border border-card-line rounded-xl">
532
+ <table class="w-full divide-y divide-card-line" style="table-layout: auto">
533
+
534
+ <!-- Header -->
535
+ <thead class="relative z-10 bg-card">
536
+ <tr class="bg-card">
537
+ <th
538
+ v-if="checkable"
539
+ scope="col"
540
+ class="w-10 px-4 py-3"
541
+ >
542
+ <input
543
+ type="checkbox"
544
+ :checked="allChecked"
545
+ :indeterminate.prop="someChecked"
546
+ class="size-4 rounded border-card-line text-primary focus:ring-primary/30 cursor-pointer"
547
+ @change="toggleCheckAll"
548
+ />
549
+ </th>
550
+ <th
551
+ v-for="col in visibleColumns"
552
+ :key="col.key"
553
+ scope="col"
554
+ :style="col.size ? { width: col.size + 'px' } : {}"
555
+ >
556
+ <div class="px-4 py-3 flex items-center gap-x-1 text-xs font-medium text-muted-foreground">
557
+ <span class="truncate">{{ col.label }}</span>
558
+ </div>
559
+ </th>
560
+ </tr>
561
+ </thead>
562
+
563
+ <!-- Body -->
564
+ <tbody class="divide-y divide-card-line">
565
+
566
+ <!-- Loading skeleton -->
567
+ <template v-if="loading">
568
+ <tr v-for="i in 5" :key="`sk-${i}`" class="animate-pulse bg-card">
569
+ <td v-if="checkable" class="px-4 py-3"><div class="h-4 w-4 bg-muted/60 rounded" /></td>
570
+ <td v-for="col in visibleColumns" :key="col.key" class="px-4 py-3">
571
+ <div class="h-3 bg-muted/60 rounded w-3/4" />
572
+ </td>
573
+ </tr>
574
+ </template>
575
+
576
+ <!-- Empty -->
577
+ <tr v-else-if="!roots.length">
578
+ <td :colspan="visibleColumns.length + (checkable ? 1 : 0)" class="py-12 text-center text-sm text-muted-foreground bg-card">
579
+ {{ emptyMessage }}
580
+ </td>
581
+ </tr>
582
+
583
+ <!-- Tree -->
584
+ <template v-else>
585
+ <TreeNode
586
+ v-for="root in roots"
587
+ :key="root.id"
588
+ :node="root"
589
+ :columns="columns"
590
+ :depth="0"
591
+ :expanded="expandedSet"
592
+ :loading-set="loadingSet"
593
+ :children-by-id="childrenById"
594
+ :row-href="rowHref"
595
+ :clickable-rows="clickableRows"
596
+ :name-col-key="nameColKey"
597
+ :checkable="checkable"
598
+ :checked-set="checkedSet"
599
+ :column-visibility="columnVisibility"
600
+ @toggle="toggle"
601
+ @check="toggleCheck"
602
+ @row-click="(n) => emit('row-click', n)"
603
+ >
604
+ <template v-for="col in columns" :key="col.key" #[`cell-${col.key}`]="slotProps">
605
+ <slot :name="`cell-${col.key}`" v-bind="slotProps" />
606
+ </template>
607
+ </TreeNode>
608
+ </template>
609
+ </tbody>
610
+ </table>
611
+ </div>
612
+
613
+ <!-- ── Footer ─────────────────────────────────────────────────────────── -->
614
+ <div class="flex items-center justify-between gap-2 mt-2 px-1 text-xs text-muted-foreground">
615
+ <div class="flex items-center gap-2">
616
+ <IconLoader2 v-if="isFetching && !loading" class="size-3.5 animate-spin" />
617
+ <span>{{ totalRendered }} {{ totalRendered === 1 ? 'nodo' : 'nodos' }}</span>
618
+ </div>
619
+ <div></div>
620
+ </div>
621
+
622
+ </div>
623
+ </template>
@@ -11,10 +11,14 @@ export function useTable(tableOrName?: TableConfig) {
11
11
  return;
12
12
  }
13
13
 
14
- const fullCacheKey = `table_cache_${tableName}`;
15
-
16
14
  try {
17
- sessionStorage.removeItem(fullCacheKey);
15
+ const prefix = `full_table_${tableName}`
16
+ for (const key of Object.keys(sessionStorage)) {
17
+ if (key === prefix || key.startsWith(prefix + '_')) {
18
+ sessionStorage.removeItem(key)
19
+ }
20
+ }
21
+ sessionStorage.removeItem(`table_cache_${tableName}`)
18
22
  } catch (error) {
19
23
  console.warn('[useTable] Error invalidating cache:', error);
20
24
  }
package/layouts/auth.vue CHANGED
@@ -55,7 +55,9 @@ const branding = computed(() => appConfig.innertia?.branding ?? { name: 'Innerti
55
55
  <div class="flex flex-1 items-center justify-center px-6 py-10">
56
56
  <div class="w-full max-w-sm">
57
57
  <Transition name="auth-content" mode="out-in">
58
- <slot />
58
+ <div :key="$route.fullPath">
59
+ <slot />
60
+ </div>
59
61
  </Transition>
60
62
  </div>
61
63
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",