@innertia-solutions/innertia-nuxt 0.1.33 → 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:
|
|
57
|
-
|
|
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) {
|
|
@@ -220,7 +223,8 @@ const fetchInitial = async () => {
|
|
|
220
223
|
* Útil tras mutar un nodo (ej. marcar revisado) sin remontar el componente.
|
|
221
224
|
*/
|
|
222
225
|
const reload = async () => {
|
|
223
|
-
loading
|
|
226
|
+
// No tocamos `loading` (eso dispara el skeleton y hace desaparecer el árbol).
|
|
227
|
+
// Solo `isFetching` → spinner sutil, el árbol queda visible durante el refresh.
|
|
224
228
|
isFetching.value = true
|
|
225
229
|
isDataFromCache.value = false
|
|
226
230
|
try {
|
|
@@ -244,7 +248,6 @@ const reload = async () => {
|
|
|
244
248
|
} catch (e) {
|
|
245
249
|
console.error('[Tree.Standard] reload failed:', e)
|
|
246
250
|
} finally {
|
|
247
|
-
loading.value = false
|
|
248
251
|
isFetching.value = false
|
|
249
252
|
}
|
|
250
253
|
}
|
|
@@ -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)
|
|
@@ -630,6 +636,9 @@ defineExpose({
|
|
|
630
636
|
<!-- Secondary actions -->
|
|
631
637
|
<div class="ml-auto flex items-center gap-1">
|
|
632
638
|
|
|
639
|
+
<!-- Spinner de refresco (reload preservando expansión) -->
|
|
640
|
+
<IconLoader2 v-if="isFetching && !loading" class="size-4 text-muted-foreground animate-spin" />
|
|
641
|
+
|
|
633
642
|
<!-- Instant + Reload SIEMPRE primero cuando infoPosition === 'none' -->
|
|
634
643
|
<InfoToolbar
|
|
635
644
|
v-if="infoPosition === 'none'"
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
// useAppStore + useInnertiaMode auto-importados.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
10
|
+
if (appStore.branding?.name) return // ya poblado
|
|
11
11
|
|
|
12
12
|
const config = useRuntimeConfig()
|
|
13
|
-
|
|
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
|
-
}>(`${
|
|
23
|
+
}>(`${base}/status`, {
|
|
21
24
|
headers: { Accept: 'application/json' },
|
|
22
25
|
timeout: 5000,
|
|
23
26
|
})
|