@innertia-solutions/innertia-nuxt 0.1.34 → 0.1.35

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,7 +1,7 @@
1
1
  <script setup>
2
2
  import {
3
3
  IconLoader2, IconSearch, IconBolt, IconReload, IconPlus, IconLayoutColumns,
4
- IconChevronDown, IconX,
4
+ IconChevronDown, IconChevronLeft, IconCheck, IconX,
5
5
  } from '@tabler/icons-vue'
6
6
  import Forms from '../Forms/Input.vue'
7
7
 
@@ -63,6 +63,18 @@ const props = defineProps({
63
63
  showReloadButton: { type: Boolean, default: true },
64
64
  showAddButton: { type: Boolean, default: false },
65
65
 
66
+ // Filtros integrados (mismo patrón que Tree.Standard): [{ key, label, filterType, filterOptions }]
67
+ // Solo en endpoint mode; se envían como lista enriquecida [{field, operator, value}]
68
+ // (el shape que procesa DataTable en el backend).
69
+ filters: { type: Array, default: () => [] },
70
+ showFilters: { type: Boolean, default: true },
71
+
72
+ // Realtime (solo endpoint mode): canal de broadcast a escuchar; el board se
73
+ // recarga al recibir cualquiera de los eventos. Por convención DomainEventKey,
74
+ // si no se pasan eventos se escucha '<canal>.updated'.
75
+ realtimeChannel: { type: String, default: null },
76
+ realtimeEvents: { type: Array, default: null },
77
+
66
78
  // Caché en sessionStorage
67
79
  cached: { type: Boolean, default: false },
68
80
 
@@ -153,6 +165,185 @@ watch(
153
165
  { deep: true, immediate: true },
154
166
  )
155
167
 
168
+ // ─── Filtros estilo Notion (mismo UI/estado que Table.Standard) ──────────────
169
+ // activeFilters: { [key]: { value, operator } } — daterange: { from, to }
170
+ const activeFilters = ref({})
171
+ const showFilterPanel = ref(false)
172
+ const filterMenuStep = ref('columns') // 'columns' | 'value'
173
+ const pendingCol = ref(null)
174
+ const pendingValue = ref(null)
175
+ const pendingDateOp = ref('before')
176
+ const pendingOperator = ref('contains')
177
+ const filterMenuRef = ref(null)
178
+ const filterAddBtnRef = ref(null)
179
+ const filterMenuStyle = ref({})
180
+ const pendingValueInputRef = ref(null)
181
+
182
+ const hasFilterableColumns = computed(() => props.filters.length > 0)
183
+
184
+ const textOps = [
185
+ { value: 'contains', label: 'contiene' },
186
+ { value: 'starts_with', label: 'empieza con' },
187
+ { value: 'equals', label: 'es igual a' },
188
+ ]
189
+ const selectOps = [
190
+ { value: 'is', label: 'es' },
191
+ { value: 'is_not', label: 'no es' },
192
+ ]
193
+ const dateOps = [
194
+ { value: 'before', label: 'antes de' },
195
+ { value: 'after', label: 'después de' },
196
+ { value: 'between', label: 'entre' },
197
+ ]
198
+
199
+ const activeFilterList = computed(() =>
200
+ props.filters
201
+ .filter(col => {
202
+ const v = activeFilters.value[col.key]
203
+ if (!v) return false
204
+ if (col.filterType === 'daterange') return v?.from || v?.to
205
+ return v?.value !== null && v?.value !== undefined && v?.value !== ''
206
+ })
207
+ .map(col => {
208
+ const v = activeFilters.value[col.key]
209
+ let displayOp = '', displayVal = ''
210
+ if (col.filterType === 'daterange') {
211
+ if (v.from && v.to) { displayOp = 'entre'; displayVal = `${v.from} y ${v.to}` }
212
+ else if (v.from) { displayOp = 'después de'; displayVal = v.from }
213
+ else { displayOp = 'antes de'; displayVal = v.to }
214
+ } else if (col.filterType === 'select') {
215
+ const op = selectOps.find(o => o.value === v.operator) ?? selectOps[0]
216
+ displayOp = op.label
217
+ displayVal = col.filterOptions?.find(o => o.value === v.value)?.label ?? v.value
218
+ } else {
219
+ const op = textOps.find(o => o.value === v.operator) ?? textOps[0]
220
+ displayOp = op.label
221
+ displayVal = v.value
222
+ }
223
+ return { key: col.key, label: col.label, displayOp, displayVal, col }
224
+ })
225
+ )
226
+
227
+ // Columnas sin filtro activo — lo que aparece en el picker.
228
+ const availableFilterColumns = computed(() =>
229
+ props.filters.filter(col => {
230
+ const v = activeFilters.value[col.key]
231
+ if (!v) return true
232
+ if (col.filterType === 'daterange') return !v.from && !v.to
233
+ return !v.value
234
+ })
235
+ )
236
+
237
+ // DataTable espera la lista enriquecida [{field, operator, value}].
238
+ const enrichedFilters = computed(() => {
239
+ const result = []
240
+ for (const col of props.filters) {
241
+ const v = activeFilters.value[col.key]
242
+ if (!v) continue
243
+ if (col.filterType === 'daterange') {
244
+ if (!v.from && !v.to) continue
245
+ if (v.from) result.push({ field: col.key, operator: 'after', value: v.from })
246
+ if (v.to) result.push({ field: col.key, operator: 'before', value: v.to })
247
+ } else {
248
+ if (!v.value) continue
249
+ result.push({ field: col.key, operator: v.operator ?? 'contains', value: v.value })
250
+ }
251
+ }
252
+ return result
253
+ })
254
+
255
+ const removeFilter = (key) => {
256
+ const u = { ...activeFilters.value }; delete u[key]; activeFilters.value = u
257
+ fetchAll()
258
+ }
259
+
260
+ const updateFilterMenuPosition = () => {
261
+ const rect = filterAddBtnRef.value?.getBoundingClientRect()
262
+ if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
263
+ }
264
+
265
+ const openFilterMenu = async () => {
266
+ filterMenuStep.value = 'columns'
267
+ pendingCol.value = null
268
+ showFilterPanel.value = true
269
+ await nextTick()
270
+ updateFilterMenuPosition()
271
+ }
272
+
273
+ const toggleFilterMenu = async () => {
274
+ if (showFilterPanel.value) { closeFilterMenu() } else { await openFilterMenu() }
275
+ }
276
+
277
+ const closeFilterMenu = () => {
278
+ showFilterPanel.value = false
279
+ filterMenuStep.value = 'columns'
280
+ pendingCol.value = null
281
+ pendingValue.value = null
282
+ }
283
+
284
+ const selectFilterColumn = (col) => {
285
+ pendingCol.value = col
286
+ const existing = activeFilters.value[col.key]
287
+ if (col.filterType === 'daterange') {
288
+ if (existing?.from && existing?.to) { pendingDateOp.value = 'between'; pendingValue.value = { from: existing.from, to: existing.to, singleDate: '' } }
289
+ else if (existing?.from) { pendingDateOp.value = 'after'; pendingValue.value = { singleDate: existing.from, from: '', to: '' } }
290
+ else if (existing?.to) { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: existing.to, from: '', to: '' } }
291
+ else { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: '', from: '', to: '' } }
292
+ } else {
293
+ pendingOperator.value = existing?.operator ?? (col.filterType === 'select' ? 'is' : 'contains')
294
+ pendingValue.value = existing?.value ?? ''
295
+ }
296
+ filterMenuStep.value = 'value'
297
+ }
298
+
299
+ const applyPendingFilter = () => {
300
+ if (!pendingCol.value) return
301
+ const col = pendingCol.value
302
+ let v
303
+ if (col.filterType === 'daterange') {
304
+ if (pendingDateOp.value === 'between') v = { from: pendingValue.value.from, to: pendingValue.value.to }
305
+ else if (pendingDateOp.value === 'after') v = { from: pendingValue.value.singleDate }
306
+ else v = { to: pendingValue.value.singleDate }
307
+ } else {
308
+ v = { value: pendingValue.value, operator: pendingOperator.value }
309
+ }
310
+ activeFilters.value = { ...activeFilters.value, [col.key]: v }
311
+ closeFilterMenu()
312
+ fetchAll()
313
+ }
314
+
315
+ const openEditFilter = async (col) => {
316
+ selectFilterColumn(col)
317
+ showFilterPanel.value = true
318
+ await nextTick()
319
+ updateFilterMenuPosition()
320
+ }
321
+
322
+ const onFilterMenuOutsideClick = (e) => {
323
+ if (filterMenuRef.value && !filterMenuRef.value.contains(e.target) &&
324
+ filterAddBtnRef.value && !filterAddBtnRef.value.contains(e.target)) {
325
+ closeFilterMenu()
326
+ }
327
+ }
328
+ watch(showFilterPanel, v => {
329
+ if (v) {
330
+ document.addEventListener('mousedown', onFilterMenuOutsideClick)
331
+ window.addEventListener('scroll', updateFilterMenuPosition, true)
332
+ window.addEventListener('resize', updateFilterMenuPosition)
333
+ } else {
334
+ document.removeEventListener('mousedown', onFilterMenuOutsideClick)
335
+ window.removeEventListener('scroll', updateFilterMenuPosition, true)
336
+ window.removeEventListener('resize', updateFilterMenuPosition)
337
+ }
338
+ })
339
+
340
+ watch(filterMenuStep, async (step) => {
341
+ if (step === 'value' && pendingCol.value?.filterType === 'text') {
342
+ await nextTick()
343
+ pendingValueInputRef.value?.focus()
344
+ }
345
+ })
346
+
156
347
  // ─── Fetch (endpoint mode) ───────────────────────────────────────────────────
157
348
  const cacheKey = computed(() => {
158
349
  if (!props.cached || !props.name) return null
@@ -163,6 +354,7 @@ const buildBody = () => ({
163
354
  ...props.params,
164
355
  per_page: props.perPage,
165
356
  search: search.value.trim(),
357
+ ...(enrichedFilters.value.length ? { filters: enrichedFilters.value } : {}),
166
358
  })
167
359
 
168
360
  const fetchAll = async () => {
@@ -195,6 +387,7 @@ const saveToCache = () => {
195
387
  meta: meta.value,
196
388
  stateVisibility: stateVisibility.value,
197
389
  search: search.value,
390
+ filters: activeFilters.value,
198
391
  timestamp: Date.now(),
199
392
  }))
200
393
  } catch (_) { /* quota exceeded, ignore */ }
@@ -214,6 +407,7 @@ const loadFromCache = () => {
214
407
  meta.value = cached.meta ?? null
215
408
  stateVisibility.value = cached.stateVisibility ?? stateVisibility.value
216
409
  search.value = cached.search ?? ''
410
+ activeFilters.value = cached.filters ?? {}
217
411
  isDataFromCache.value = true
218
412
  return true
219
413
  } catch { return false }
@@ -340,10 +534,20 @@ const onDocMousedown = (e) => {
340
534
  closePreview()
341
535
  }
342
536
 
537
+ // ─── Realtime ────────────────────────────────────────────────────────────────
538
+ const realtime = props.realtimeChannel ? useRealtime() : null
539
+ async function setupRealtime() {
540
+ if (!realtime || isItemsMode.value) return
541
+ await realtime.connect()
542
+ const events = props.realtimeEvents ?? [`${props.realtimeChannel}.updated`]
543
+ realtime.subscribe(props.realtimeChannel, Object.fromEntries(events.map(e => [e, () => fetchAll()])))
544
+ }
545
+
343
546
  // ─── Lifecycle ───────────────────────────────────────────────────────────────
344
547
  onMounted(() => {
345
548
  window.addEventListener('keydown', onEsc)
346
549
  document.addEventListener('mousedown', onDocMousedown)
550
+ setupRealtime()
347
551
  if (isItemsMode.value) return // rows ya cargados por el watcher immediate
348
552
  if (loadFromCache()) return
349
553
  fetchAll()
@@ -352,6 +556,7 @@ onMounted(() => {
352
556
  onBeforeUnmount(() => {
353
557
  window.removeEventListener('keydown', onEsc)
354
558
  document.removeEventListener('mousedown', onDocMousedown)
559
+ if (realtime && props.realtimeChannel) realtime.unsubscribe(props.realtimeChannel)
355
560
  })
356
561
 
357
562
  defineExpose({ reload: fetchAll, rows })
@@ -367,6 +572,23 @@ defineExpose({ reload: fetchAll, rows })
367
572
  <Forms v-model="search" type="text" :placeholder="searchPlaceholder" :icon-right="IconSearch" size="sm" />
368
573
  </div>
369
574
 
575
+ <!-- + Filtros (solo endpoint mode) -->
576
+ <div v-if="showFilters && hasFilterableColumns && !isItemsMode" ref="filterAddBtnRef" class="relative">
577
+ <button
578
+ type="button"
579
+ @click="toggleFilterMenu"
580
+ :class="[
581
+ 'inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-control border transition-colors',
582
+ activeFilterList.length
583
+ ? 'border-primary/40 bg-primary/10 text-primary'
584
+ : 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
585
+ ]"
586
+ >
587
+ <IconPlus class="size-3.5" />
588
+ Filtros{{ activeFilterList.length ? ` (${activeFilterList.length})` : '' }}
589
+ </button>
590
+ </div>
591
+
370
592
  <!-- + Nuevo -->
371
593
  <button
372
594
  v-if="showAddButton"
@@ -435,6 +657,33 @@ defineExpose({ reload: fetchAll, rows })
435
657
  </div>
436
658
  </div>
437
659
 
660
+ <!-- ── Filter chips (mismo formato que Table.Standard) ─────────────── -->
661
+ <div v-if="activeFilterList.length" class="flex flex-wrap items-center gap-1.5 mb-2">
662
+ <div
663
+ v-for="chip in activeFilterList"
664
+ :key="chip.key"
665
+ class="inline-flex items-center text-xs rounded-control border border-card-line bg-card overflow-hidden"
666
+ >
667
+ <span class="px-2.5 py-1 text-foreground font-medium border-r border-card-line bg-surface">{{ chip.label }}</span>
668
+ <span class="px-2 py-1 text-muted-foreground">{{ chip.displayOp }}</span>
669
+ <button
670
+ type="button"
671
+ @click.stop="openEditFilter(chip.col)"
672
+ class="inline-flex items-center gap-1 px-2 py-1 text-primary font-medium hover:bg-primary/10 transition-colors border-x border-card-line"
673
+ >
674
+ {{ chip.displayVal }}
675
+ <IconChevronDown class="size-3 opacity-60" />
676
+ </button>
677
+ <button
678
+ type="button"
679
+ @click.stop="removeFilter(chip.key)"
680
+ 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"
681
+ >
682
+ <IconX class="size-3" />
683
+ </button>
684
+ </div>
685
+ </div>
686
+
438
687
  <!-- ── Board wrapper (rounded, con scroll horizontal) ─────────────── -->
439
688
  <div
440
689
  :class="[
@@ -558,6 +807,159 @@ defineExpose({ reload: fetchAll, rows })
558
807
  </div>
559
808
  </div>
560
809
 
810
+ <!-- ── Filter menu — teleported (mismo UI two-step que Table.Standard) ── -->
811
+ <Teleport to="body">
812
+ <Transition
813
+ enter-active-class="transition ease-out duration-150"
814
+ enter-from-class="opacity-0 translate-y-1 scale-95"
815
+ enter-to-class="opacity-100 translate-y-0 scale-100"
816
+ leave-active-class="transition ease-in duration-100"
817
+ leave-from-class="opacity-100 translate-y-0 scale-100"
818
+ leave-to-class="opacity-0 translate-y-1 scale-95"
819
+ >
820
+ <div
821
+ v-if="showFilterPanel"
822
+ ref="filterMenuRef"
823
+ class="fixed z-[60] bg-dropdown border border-dropdown-line rounded-popover shadow-2xl overflow-hidden"
824
+ :style="filterMenuStyle"
825
+ >
826
+
827
+ <!-- Step 1: column picker -->
828
+ <template v-if="filterMenuStep === 'columns'">
829
+ <p class="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest px-3 pt-3 pb-1.5">
830
+ Filtrar por
831
+ </p>
832
+ <div class="pb-2 min-w-48">
833
+ <template v-if="availableFilterColumns.length">
834
+ <button
835
+ v-for="col in availableFilterColumns"
836
+ :key="col.key"
837
+ type="button"
838
+ @click.stop="selectFilterColumn(col)"
839
+ class="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-foreground hover:bg-muted-hover transition-colors text-left group"
840
+ >
841
+ <span>{{ col.label }}</span>
842
+ <span class="text-[11px] text-muted-foreground-2 group-hover:text-muted-foreground transition-colors capitalize">
843
+ {{ col.filterType === 'daterange' ? 'fecha' : col.filterType === 'select' ? 'opción' : 'texto' }}
844
+ </span>
845
+ </button>
846
+ </template>
847
+ <p v-else class="px-3 py-3 text-xs text-muted-foreground italic">
848
+ Todos los filtros están configurados
849
+ </p>
850
+ </div>
851
+ </template>
852
+
853
+ <!-- Step 2: value config -->
854
+ <template v-else-if="filterMenuStep === 'value' && pendingCol">
855
+
856
+ <!-- Header: back + field name + operator selector -->
857
+ <div class="flex items-center gap-1.5 px-2 py-2 border-b border-dropdown-line">
858
+ <button
859
+ type="button"
860
+ @click.stop="filterMenuStep = 'columns'"
861
+ class="inline-flex items-center justify-center size-6 rounded-control text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors shrink-0"
862
+ >
863
+ <IconChevronLeft class="size-4" />
864
+ </button>
865
+ <span class="text-sm font-medium text-foreground">{{ pendingCol.label }}</span>
866
+
867
+ <!-- Operator: native select for text -->
868
+ <select
869
+ v-if="pendingCol.filterType === 'text'"
870
+ v-model="pendingOperator"
871
+ class="ml-auto text-xs bg-dropdown border border-dropdown-line rounded-control px-2 py-1 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
872
+ >
873
+ <option v-for="op in textOps" :key="op.value" :value="op.value">{{ op.label }}</option>
874
+ </select>
875
+
876
+ <!-- Operator: segmented toggle for select (es / no es) -->
877
+ <div v-else-if="pendingCol.filterType === 'select'" class="ml-auto flex rounded-control border border-dropdown-line overflow-hidden text-xs">
878
+ <button
879
+ v-for="op in selectOps"
880
+ :key="op.value"
881
+ type="button"
882
+ @click.stop="pendingOperator = op.value"
883
+ :class="[
884
+ 'px-2.5 py-1 transition-colors',
885
+ pendingOperator === op.value
886
+ ? 'bg-primary/10 text-primary'
887
+ : 'text-muted-foreground hover:bg-muted-hover'
888
+ ]"
889
+ >
890
+ {{ op.label }}
891
+ </button>
892
+ </div>
893
+
894
+ <!-- Operator: inline select for daterange -->
895
+ <select
896
+ v-else-if="pendingCol.filterType === 'daterange'"
897
+ v-model="pendingDateOp"
898
+ class="ml-auto text-xs bg-dropdown border border-dropdown-line rounded-control px-2 py-1 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
899
+ >
900
+ <option v-for="op in dateOps" :key="op.value" :value="op.value">{{ op.label }}</option>
901
+ </select>
902
+ </div>
903
+
904
+ <div class="p-3 min-w-56 space-y-2">
905
+
906
+ <!-- ── TEXT ── -->
907
+ <input
908
+ v-if="pendingCol.filterType === 'text'"
909
+ ref="pendingValueInputRef"
910
+ v-model="pendingValue"
911
+ type="text"
912
+ @keydown.enter.stop="applyPendingFilter"
913
+ @keydown.escape.stop="closeFilterMenu"
914
+ placeholder="Valor..."
915
+ class="w-full rounded-control border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60 focus:border-primary"
916
+ />
917
+
918
+ <!-- ── SELECT ── -->
919
+ <div v-else-if="pendingCol.filterType === 'select'" class="space-y-0.5 max-h-52 overflow-y-auto -mx-1">
920
+ <button
921
+ v-for="opt in pendingCol.filterOptions"
922
+ :key="opt.value"
923
+ type="button"
924
+ @click.stop="pendingValue = opt.value; applyPendingFilter()"
925
+ :class="[
926
+ 'w-full flex items-center gap-2 px-2.5 py-2 text-sm rounded-control transition-colors text-left',
927
+ pendingValue === opt.value
928
+ ? 'bg-primary/10 text-primary'
929
+ : 'hover:bg-muted-hover text-foreground'
930
+ ]"
931
+ >
932
+ <span class="flex-1">{{ opt.label }}</span>
933
+ <IconCheck v-if="pendingValue === opt.value" class="size-3.5 shrink-0 text-primary/70" />
934
+ </button>
935
+ </div>
936
+
937
+ <!-- ── DATERANGE ── -->
938
+ <template v-else-if="pendingCol.filterType === 'daterange'">
939
+ <template v-if="pendingDateOp === 'between'">
940
+ <input type="date" v-model="pendingValue.from" class="w-full rounded-control border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
941
+ <input type="date" v-model="pendingValue.to" class="w-full rounded-control border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
942
+ </template>
943
+ <input v-else type="date" v-model="pendingValue.singleDate" class="w-full rounded-control border border-dropdown-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60" />
944
+ </template>
945
+
946
+ <!-- Apply (not for select — auto-applies on click) -->
947
+ <button
948
+ v-if="pendingCol.filterType !== 'select'"
949
+ type="button"
950
+ @click.stop="applyPendingFilter"
951
+ class="w-full py-1.5 text-sm font-medium rounded-control bg-primary text-white hover:bg-primary/90 active:bg-primary/80 transition-colors"
952
+ >
953
+ Aplicar
954
+ </button>
955
+
956
+ </div>
957
+ </template>
958
+
959
+ </div>
960
+ </Transition>
961
+ </Teleport>
962
+
561
963
  <!-- ── Preview compartido (panel overlay + chip flotante + history) ── -->
562
964
  <DataPreview
563
965
  ref="dataPreviewRef"
@@ -53,8 +53,9 @@ defineExpose({ reload, clearCache })
53
53
  @row-click="emit('row-click', $event)"
54
54
  @loaded="emit('loaded', $event)"
55
55
  >
56
- <!-- Card slot: pass through for custom card rendering -->
57
- <template v-if="$slots.card" #card="slotProps">
56
+ <!-- Card slot: el grid de DataTable expone el slot `grid-item`; exponemos
57
+ `card` como API pública del wrapper y lo reenviamos a `grid-item`. -->
58
+ <template v-if="$slots.card" #grid-item="slotProps">
58
59
  <slot name="card" v-bind="slotProps" />
59
60
  </template>
60
61
  </DataTable>
@@ -15,6 +15,10 @@ const props = defineProps({
15
15
  searchPlaceholder: { type: String, default: 'Buscar...' },
16
16
  showSearch: { type: Boolean, default: true },
17
17
  showFilters: { type: Boolean, default: true },
18
+ // Realtime: canal de broadcast a escuchar; la tabla hace reload() al recibir
19
+ // cualquiera de los eventos. Sin realtimeEvents se escucha '<canal>.updated'.
20
+ realtimeChannel: { type: String, default: null },
21
+ realtimeEvents: { type: Array, default: null },
18
22
  showExport: { type: Boolean, default: true },
19
23
  showColumns: { type: Boolean, default: true },
20
24
  /** Mostrar selector "Filas por página" en el footer. Default false. */
@@ -411,10 +415,20 @@ onMounted(() => {
411
415
  })
412
416
  })
413
417
 
418
+ // Realtime opcional: recarga (preservando UI) al recibir eventos del canal.
419
+ const realtime = props.realtimeChannel ? useRealtime() : null
420
+ async function setupRealtime() {
421
+ if (!realtime) return
422
+ await realtime.connect()
423
+ const events = props.realtimeEvents ?? [`${props.realtimeChannel}.updated`]
424
+ realtime.subscribe(props.realtimeChannel, Object.fromEntries(events.map(e => [e, () => reload()])))
425
+ }
426
+
414
427
  onMounted(async () => {
415
428
  previewEnabled.value = !!slots.preview
416
429
  window.addEventListener('keydown', onEsc)
417
430
  document.addEventListener('mousedown', onDocMousedown)
431
+ setupRealtime()
418
432
 
419
433
  // Load column preferences from backend
420
434
  if (props.persistPreferences && resolvedName.value) {
@@ -434,6 +448,7 @@ onMounted(async () => {
434
448
  onBeforeUnmount(() => {
435
449
  window.removeEventListener('keydown', onEsc)
436
450
  document.removeEventListener('mousedown', onDocMousedown)
451
+ if (realtime && props.realtimeChannel) realtime.unsubscribe(props.realtimeChannel)
437
452
  })
438
453
 
439
454
  // ─── Column panel ─────────────────────────────────────────────────────────────
@@ -203,6 +203,9 @@ const fetchInitial = async () => {
203
203
  roots.value = res?.data ?? []
204
204
  meta.value = res?.meta ?? null
205
205
  childrenById.value = {}
206
+ // Colapsar todo: sin esto, un nodo expandido antes de cambiar search/params
207
+ // queda con el chevron abierto pero sin hijos (childrenById se vació).
208
+ expandedSet.value = new Set()
206
209
  if (props.cached) saveToCache()
207
210
  emit('loaded', res)
208
211
  } catch (e) {
@@ -338,6 +341,9 @@ watch(search, () => {
338
341
  searchTimer = setTimeout(() => fetchInitial(), 250)
339
342
  })
340
343
 
344
+ // Refetch cuando cambian los params externos (p.ej. filtros del consumer).
345
+ watch(() => props.params, () => fetchInitial(), { deep: true })
346
+
341
347
  // ─── Filters ──────────────────────────────────────────────────────────────────
342
348
  const showFilterPanel = ref(false)
343
349
  const filterAddBtnRef = ref(null)
@@ -1,23 +1,26 @@
1
1
  // useAppStore + useInnertiaMode auto-importados.
2
- // Server-only: en app mode hace fetch de /status (branding + features + demo) y
3
- // puebla useAppStore. Equivalente app-mode de 02.validate-tenant (saas).
4
- // Solo activo en mode === 'app'.
2
+ // En app mode hace fetch de /status (branding + features + demo) y puebla
3
+ // useAppStore. Equivalente app-mode de 02.validate-tenant (saas).
4
+ // Corre en server (SSR) o en client (páginas con ssr:false, p.ej. routeRules
5
+ // '/backoffice/**': { ssr: false }) — donde el server nunca pobló el store.
5
6
  export default defineNuxtRouteMiddleware(async () => {
6
7
  if (!useInnertiaMode().isApp()) return
7
- if (!import.meta.server) return
8
8
 
9
9
  const appStore = useAppStore()
10
- if (appStore.branding?.name) return // ya poblado este request
10
+ if (appStore.branding?.name) return // ya poblado
11
11
 
12
12
  const config = useRuntimeConfig()
13
- const internalUrl = (config as any).apiInternalUrl || 'http://api:80'
13
+ // Misma resolución dual que useApi: server → red interna; client → proxy público.
14
+ const base = import.meta.server
15
+ ? ((config as any).apiInternalUrl || 'http://api:80')
16
+ : ((config.public as any).apiBaseUrl || '/api')
14
17
 
15
18
  try {
16
19
  const data = await $fetch<{
17
20
  ok: boolean
18
21
  branding?: { name?: string; demo?: { email: string; password: string } | null }
19
22
  features?: Record<string, any>
20
- }>(`${internalUrl}/status`, {
23
+ }>(`${base}/status`, {
21
24
  headers: { Accept: 'application/json' },
22
25
  timeout: 5000,
23
26
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",