@innertia-solutions/innertia-nuxt 0.1.8 → 0.1.9
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.
- package/components/Table/Standard.vue +14 -6
- package/components/Tree/Node.vue +155 -0
- package/components/Tree/Standard.vue +623 -0
- package/layouts/auth.vue +3 -1
- package/package.json +1 -1
|
@@ -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
|
|
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>
|
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
|
-
<
|
|
58
|
+
<div :key="$route.fullPath">
|
|
59
|
+
<slot />
|
|
60
|
+
</div>
|
|
59
61
|
</Transition>
|
|
60
62
|
</div>
|
|
61
63
|
</div>
|