@innertia-solutions/nuxt-theme-spark 0.1.138 → 0.1.140
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/Admin/Page.vue +5 -5
- package/components/App/Button.vue +4 -4
- package/components/App/PreviewDock.vue +64 -0
- package/components/Table/Standard.vue +508 -152
- package/nuxt.config.ts +4 -0
- package/package.json +3 -1
- package/plugins/dockedPreviewsSync.client.js +17 -0
- package/shared/composables/useDockedPreviews.js +56 -0
- package/shared/stores/dockedPreviews.js +34 -0
|
@@ -25,10 +25,13 @@ const iconColorClass = computed(() => ({
|
|
|
25
25
|
</script>
|
|
26
26
|
|
|
27
27
|
<template>
|
|
28
|
-
<div class="space-y-
|
|
28
|
+
<div class="relative space-y-2">
|
|
29
29
|
|
|
30
30
|
<!-- Page header card -->
|
|
31
31
|
<div v-if="title" class="sticky top-0 z-20 -mx-3 -mt-3 px-3 pt-3 bg-background-1">
|
|
32
|
+
<div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 px-1 pt-2 pb-0.5">
|
|
33
|
+
<slot name="breadcrumb" />
|
|
34
|
+
</div>
|
|
32
35
|
<div class="flex items-center justify-between bg-card border border-card-line rounded-2xl shadow-sm px-4 py-3">
|
|
33
36
|
<div class="flex items-center gap-x-4 min-w-0">
|
|
34
37
|
<div v-if="iconComponent" class="shrink-0 size-10 rounded-xl flex items-center justify-center border border-current/15" :class="iconColorClass">
|
|
@@ -42,9 +45,6 @@ const iconColorClass = computed(() => ({
|
|
|
42
45
|
<p class="text-sm text-muted-foreground">{{ description }}</p>
|
|
43
46
|
</template>
|
|
44
47
|
</div>
|
|
45
|
-
<div v-if="$slots.breadcrumb" class="flex items-center gap-x-1 mt-0.5">
|
|
46
|
-
<slot name="breadcrumb" />
|
|
47
|
-
</div>
|
|
48
48
|
</div>
|
|
49
49
|
</div>
|
|
50
50
|
<div v-if="$slots.actions" class="flex items-center gap-x-2 shrink-0 ms-4">
|
|
@@ -59,7 +59,7 @@ const iconColorClass = computed(() => ({
|
|
|
59
59
|
</div>
|
|
60
60
|
|
|
61
61
|
<!-- Page content -->
|
|
62
|
-
<slot
|
|
62
|
+
<div class="relative"><slot /></div>
|
|
63
63
|
|
|
64
64
|
</div>
|
|
65
65
|
</template>
|
|
@@ -22,22 +22,22 @@ const iconSizeClasses = computed(() => ({ xs:"size-2", sm:"size-3", md:"size-4",
|
|
|
22
22
|
|
|
23
23
|
const severityClasses = computed(() => {
|
|
24
24
|
if (props.variant === "dropdown") {
|
|
25
|
-
const d = { primary:"text-
|
|
25
|
+
const d = { primary: "text-primary hover:bg-primary/10", secondary:"text-foreground hover:bg-muted-hover", success:"text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20", danger:"text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20", warning:"text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20", info:"text-cyan-600 hover:bg-cyan-50 dark:text-cyan-400 dark:hover:bg-cyan-900/20" }
|
|
26
26
|
return d[props.severity] || d.secondary
|
|
27
27
|
}
|
|
28
28
|
const base = "rounded-lg border transition-colors"
|
|
29
|
-
const v = { primary:"border-
|
|
29
|
+
const v = { primary: "border-primary bg-primary/10 text-primary hover:bg-primary/20 dark:bg-primary/15 dark:hover:bg-primary/25", secondary:"border-slate-300 bg-slate-50 text-slate-700 hover:bg-muted-hover dark:border-card-line dark:bg-card dark:text-muted-foreground-1 dark:hover:bg-muted-hover", success:"border-emerald-600 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500 dark:bg-emerald-900/20 dark:text-emerald-300 dark:hover:bg-emerald-900/35", danger:"border-red-600 bg-red-50 text-red-700 hover:bg-red-100 dark:border-red-500 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/35", warning:"border-yellow-600 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 dark:border-yellow-500 dark:bg-yellow-900/20 dark:text-yellow-300 dark:hover:bg-yellow-900/35", info:"border-cyan-600 bg-cyan-50 text-cyan-700 hover:bg-cyan-100 dark:border-cyan-500 dark:bg-cyan-900/20 dark:text-cyan-300 dark:hover:bg-cyan-900/35" }
|
|
30
30
|
return `${base} ${v[props.severity] || v.primary}`
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
const buttonClasses = computed(() => {
|
|
34
34
|
if (props.variant === "dropdown") {
|
|
35
35
|
const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
|
|
36
|
-
return `w-full flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm transition-colors text-left ${severityClasses.value} ${dis} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-
|
|
36
|
+
return `w-full flex items-center gap-x-3.5 py-2 px-3 rounded-lg text-sm transition-colors text-left ${severityClasses.value} ${dis} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${props.class}`
|
|
37
37
|
}
|
|
38
38
|
const dis = props.type === "button" ? "disabled:opacity-50 disabled:pointer-events-none" : isDisabled.value ? "opacity-50 pointer-events-none" : ""
|
|
39
39
|
const cursor = props.type === "link" ? "cursor-pointer" : ""
|
|
40
|
-
return `${sizeClasses.value} ${severityClasses.value} ${dis} focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-
|
|
40
|
+
return `${sizeClasses.value} ${severityClasses.value} ${dis} focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary/40 dark:focus:ring-offset-gray-800 ${cursor} inline-flex justify-center items-center gap-x-2 whitespace-nowrap ${props.class}`
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
const displayText = computed(() => props.loading ? props.loadingText : props.text)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconX } from '@tabler/icons-vue'
|
|
3
|
+
|
|
4
|
+
const { docked, undock, expandDock, activeDockId } = useDockedPreviews()
|
|
5
|
+
const router = useRouter()
|
|
6
|
+
const route = useRoute()
|
|
7
|
+
|
|
8
|
+
async function open(item, event) {
|
|
9
|
+
// Misma ruta → la tabla está montada, mostrar float encima del tab
|
|
10
|
+
if (route.path === item.route) {
|
|
11
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
12
|
+
expandDock(item.id, rect)
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
// Ruta diferente → navegar y restaurar como preview completo
|
|
16
|
+
await router.push(item.route)
|
|
17
|
+
await nextTick()
|
|
18
|
+
useNuxtApp().hooks.callHook('preview:restore', item)
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<Transition
|
|
24
|
+
enter-active-class="transition ease-out duration-200"
|
|
25
|
+
enter-from-class="opacity-0 translate-y-4"
|
|
26
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
27
|
+
leave-active-class="transition ease-in duration-150"
|
|
28
|
+
leave-from-class="opacity-100 translate-y-0"
|
|
29
|
+
leave-to-class="opacity-0 translate-y-4"
|
|
30
|
+
>
|
|
31
|
+
<div
|
|
32
|
+
v-if="docked.length"
|
|
33
|
+
class="fixed bottom-0 left-0 right-0 z-50 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-md border-t border-card-line shadow-lg"
|
|
34
|
+
>
|
|
35
|
+
<span class="text-xs text-muted-foreground shrink-0 mr-1">Minimizados</span>
|
|
36
|
+
|
|
37
|
+
<div class="flex items-center gap-2 flex-1 overflow-x-auto">
|
|
38
|
+
<button
|
|
39
|
+
v-for="item in docked"
|
|
40
|
+
:key="item.id"
|
|
41
|
+
type="button"
|
|
42
|
+
class="group inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all shrink-0"
|
|
43
|
+
:class="activeDockId === item.id
|
|
44
|
+
? 'border-primary/50 bg-primary/10 text-primary shadow-sm'
|
|
45
|
+
: 'border-card-line bg-surface hover:bg-muted-hover text-foreground'"
|
|
46
|
+
@click="open(item, $event)"
|
|
47
|
+
>
|
|
48
|
+
<span class="size-5 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
|
|
49
|
+
{{ (item.label?.[0] ?? '?').toUpperCase() }}
|
|
50
|
+
</span>
|
|
51
|
+
<span class="font-medium max-w-32 truncate">{{ item.label }}</span>
|
|
52
|
+
<span v-if="item.subtitle" class="text-muted-foreground text-xs max-w-28 truncate hidden sm:inline">{{ item.subtitle }}</span>
|
|
53
|
+
|
|
54
|
+
<span
|
|
55
|
+
class="size-4 inline-flex items-center justify-center rounded hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-600 dark:hover:text-red-400 transition-colors ml-0.5"
|
|
56
|
+
@click.stop="undock(item.id)"
|
|
57
|
+
>
|
|
58
|
+
<IconX class="size-3" />
|
|
59
|
+
</span>
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</Transition>
|
|
64
|
+
</template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { IconSearch,
|
|
2
|
+
import { IconSearch, IconLayoutColumns, IconGripVertical, IconMinus, IconMaximize, IconX, IconPlus, IconChevronLeft, IconCheck, IconChevronDown } from '@tabler/icons-vue'
|
|
3
3
|
|
|
4
4
|
const props = defineProps({
|
|
5
5
|
table: { type: Object, default: null },
|
|
@@ -17,6 +17,7 @@ const props = defineProps({
|
|
|
17
17
|
showExport: { type: Boolean, default: true },
|
|
18
18
|
filters: { type: Array, default: () => [] },
|
|
19
19
|
splitRatio: { type: Number, default: 60 },
|
|
20
|
+
autoClosePreview: { type: Boolean, default: true },
|
|
20
21
|
})
|
|
21
22
|
|
|
22
23
|
const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
|
|
@@ -29,11 +30,9 @@ const forwardedSlots = computed(() => {
|
|
|
29
30
|
return Object.fromEntries(Object.entries(slots).filter(([k]) => !excluded.has(k)))
|
|
30
31
|
})
|
|
31
32
|
|
|
32
|
-
const search
|
|
33
|
+
const search = ref('')
|
|
33
34
|
const activeFilters = ref({})
|
|
34
|
-
const
|
|
35
|
-
const filterPanelRef = ref(null)
|
|
36
|
-
const tableRef = ref(null)
|
|
35
|
+
const tableRef = ref(null)
|
|
37
36
|
|
|
38
37
|
// ─── Filter config ─────────────────────────────────────────────────────────────
|
|
39
38
|
const filtersConfig = computed(() =>
|
|
@@ -42,15 +41,125 @@ const filtersConfig = computed(() =>
|
|
|
42
41
|
|
|
43
42
|
const hasFilterableColumns = computed(() => filtersConfig.value.length > 0)
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// ─── Notion-style filter ───────────────────────────────────────────────────────
|
|
45
|
+
const showFilterPanel = ref(false)
|
|
46
|
+
const filterMenuStep = ref('columns') // 'columns' | 'value'
|
|
47
|
+
const pendingCol = ref(null)
|
|
48
|
+
const pendingValue = ref(null) // string for text/select, { singleDate, from, to } for daterange
|
|
49
|
+
const pendingDateOp = ref('before') // 'before' | 'after' | 'between'
|
|
50
|
+
const filterMenuRef = ref(null)
|
|
51
|
+
const filterAddBtnRef = ref(null)
|
|
52
|
+
const filterMenuStyle = ref({})
|
|
53
|
+
|
|
54
|
+
const dateOps = [
|
|
55
|
+
{ value: 'before', label: 'antes de' },
|
|
56
|
+
{ value: 'after', label: 'después de' },
|
|
57
|
+
{ value: 'between', label: 'entre' },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
const activeFilterList = computed(() =>
|
|
61
|
+
filtersConfig.value
|
|
62
|
+
.filter(col => {
|
|
63
|
+
const v = activeFilters.value[col.key]
|
|
64
|
+
if (col.filterType === 'daterange') return v?.from || v?.to
|
|
65
|
+
return v !== null && v !== undefined && v !== ''
|
|
66
|
+
})
|
|
67
|
+
.map(col => {
|
|
68
|
+
const v = activeFilters.value[col.key]
|
|
69
|
+
let displayOp = '', displayVal = ''
|
|
70
|
+
if (col.filterType === 'daterange') {
|
|
71
|
+
if (v.from && v.to) { displayOp = 'entre'; displayVal = `${v.from} y ${v.to}` }
|
|
72
|
+
else if (v.from) { displayOp = 'después de'; displayVal = v.from }
|
|
73
|
+
else { displayOp = 'antes de'; displayVal = v.to }
|
|
74
|
+
} else if (col.filterType === 'select') {
|
|
75
|
+
displayOp = 'es'
|
|
76
|
+
displayVal = col.filterOptions?.find(o => o.value === v)?.label ?? v
|
|
77
|
+
} else {
|
|
78
|
+
displayOp = 'contiene'; displayVal = v
|
|
79
|
+
}
|
|
80
|
+
return { key: col.key, label: col.label, displayOp, displayVal, col }
|
|
81
|
+
})
|
|
47
82
|
)
|
|
48
83
|
|
|
84
|
+
const activeFilterCount = computed(() => activeFilterList.value.length)
|
|
85
|
+
|
|
49
86
|
const mergedParams = computed(() => ({
|
|
50
87
|
...props.params,
|
|
51
88
|
...activeFilters.value,
|
|
52
89
|
}))
|
|
53
90
|
|
|
91
|
+
const removeFilter = (key) => {
|
|
92
|
+
const u = { ...activeFilters.value }; delete u[key]; activeFilters.value = u
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const openFilterMenu = async () => {
|
|
96
|
+
filterMenuStep.value = 'columns'
|
|
97
|
+
pendingCol.value = null
|
|
98
|
+
showFilterPanel.value = true
|
|
99
|
+
await nextTick()
|
|
100
|
+
const rect = filterAddBtnRef.value?.getBoundingClientRect()
|
|
101
|
+
if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const toggleFilterMenu = async () => {
|
|
105
|
+
if (showFilterPanel.value) { closeFilterMenu() } else { await openFilterMenu() }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const closeFilterMenu = () => {
|
|
109
|
+
showFilterPanel.value = false
|
|
110
|
+
filterMenuStep.value = 'columns'
|
|
111
|
+
pendingCol.value = null
|
|
112
|
+
pendingValue.value = null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const selectFilterColumn = (col) => {
|
|
116
|
+
pendingCol.value = col
|
|
117
|
+
const existing = activeFilters.value[col.key]
|
|
118
|
+
if (col.filterType === 'daterange') {
|
|
119
|
+
if (existing?.from && existing?.to) { pendingDateOp.value = 'between'; pendingValue.value = { from: existing.from, to: existing.to, singleDate: '' } }
|
|
120
|
+
else if (existing?.from) { pendingDateOp.value = 'after'; pendingValue.value = { singleDate: existing.from, from: '', to: '' } }
|
|
121
|
+
else if (existing?.to) { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: existing.to, from: '', to: '' } }
|
|
122
|
+
else { pendingDateOp.value = 'before'; pendingValue.value = { singleDate: '', from: '', to: '' } }
|
|
123
|
+
} else {
|
|
124
|
+
pendingValue.value = existing ?? ''
|
|
125
|
+
}
|
|
126
|
+
filterMenuStep.value = 'value'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const applyPendingFilter = () => {
|
|
130
|
+
if (!pendingCol.value) return
|
|
131
|
+
const col = pendingCol.value
|
|
132
|
+
let v
|
|
133
|
+
if (col.filterType === 'daterange') {
|
|
134
|
+
if (pendingDateOp.value === 'between') v = { from: pendingValue.value.from, to: pendingValue.value.to }
|
|
135
|
+
else if (pendingDateOp.value === 'after') v = { from: pendingValue.value.singleDate }
|
|
136
|
+
else v = { to: pendingValue.value.singleDate }
|
|
137
|
+
} else {
|
|
138
|
+
v = pendingValue.value
|
|
139
|
+
}
|
|
140
|
+
activeFilters.value = { ...activeFilters.value, [col.key]: v || null }
|
|
141
|
+
closeFilterMenu()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const openEditFilter = async (col) => {
|
|
145
|
+
selectFilterColumn(col)
|
|
146
|
+
showFilterPanel.value = true
|
|
147
|
+
await nextTick()
|
|
148
|
+
const rect = filterAddBtnRef.value?.getBoundingClientRect()
|
|
149
|
+
if (rect) filterMenuStyle.value = { top: rect.bottom + 4 + 'px', left: rect.left + 'px' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const onFilterMenuOutsideClick = (e) => {
|
|
153
|
+
if (filterMenuRef.value && !filterMenuRef.value.contains(e.target) &&
|
|
154
|
+
filterAddBtnRef.value && !filterAddBtnRef.value.contains(e.target)) {
|
|
155
|
+
closeFilterMenu()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
watch(showFilterPanel, v => {
|
|
159
|
+
if (v) document.addEventListener('mousedown', onFilterMenuOutsideClick)
|
|
160
|
+
else document.removeEventListener('mousedown', onFilterMenuOutsideClick)
|
|
161
|
+
})
|
|
162
|
+
|
|
54
163
|
// ─── Preview panel ─────────────────────────────────────────────────────────────
|
|
55
164
|
const previewRow = ref(null)
|
|
56
165
|
const currentRatio = ref(props.splitRatio)
|
|
@@ -61,6 +170,7 @@ const paginationHeight = ref(0)
|
|
|
61
170
|
const previewCacheKey = computed(() => `table-preview-${resolvedName.value}`)
|
|
62
171
|
|
|
63
172
|
const previewFromCache = ref(false)
|
|
173
|
+
const previewPanelRef = ref(null)
|
|
64
174
|
const closePreview = () => { previewRow.value = null }
|
|
65
175
|
|
|
66
176
|
const previewTab = ref('datos')
|
|
@@ -76,6 +186,7 @@ watch(previewRow, () => { previewTab.value = 'datos' })
|
|
|
76
186
|
|
|
77
187
|
const handleRowClick = (row) => {
|
|
78
188
|
if (previewEnabled.value) {
|
|
189
|
+
collapseDock()
|
|
79
190
|
previewRow.value = previewRow.value?.id === row.id ? null : row
|
|
80
191
|
} else {
|
|
81
192
|
emit('row-click', row)
|
|
@@ -129,10 +240,74 @@ const startResize = (e) => {
|
|
|
129
240
|
window.addEventListener('mouseup', onUp)
|
|
130
241
|
}
|
|
131
242
|
|
|
132
|
-
const onEsc = (e) => { if (e.key === 'Escape'
|
|
243
|
+
const onEsc = (e) => { if (e.key === 'Escape') { if (previewRow.value) closePreview(); else collapseDock() } }
|
|
244
|
+
|
|
245
|
+
// ─── Auto-close preview on outside click ──────────────────────────────────────
|
|
246
|
+
const onDocMousedown = (e) => {
|
|
247
|
+
if (props.autoClosePreview && previewRow.value && previewPanelRef.value && !previewPanelRef.value.contains(e.target)) {
|
|
248
|
+
closePreview()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Dock (minimizar preview) ──────────────────────────────────────────────────
|
|
253
|
+
const {
|
|
254
|
+
docked,
|
|
255
|
+
dock, undock: undockItem, isActive,
|
|
256
|
+
activeDockId, activeDockRect,
|
|
257
|
+
expandDock, collapseDock,
|
|
258
|
+
} = useDockedPreviews()
|
|
259
|
+
const route = useRoute()
|
|
260
|
+
|
|
261
|
+
function minimizePreview() {
|
|
262
|
+
if (!previewRow.value) return
|
|
263
|
+
const label = previewRow.value.name ?? previewRow.value.title ?? previewRow.value.email ?? String(previewRow.value.id)
|
|
264
|
+
const subtitle = previewRow.value.email ?? previewRow.value.description ?? null
|
|
265
|
+
dock({
|
|
266
|
+
id: `${resolvedName.value}-${previewRow.value.id}`,
|
|
267
|
+
label,
|
|
268
|
+
subtitle,
|
|
269
|
+
row: { ...previewRow.value },
|
|
270
|
+
tableName: resolvedName.value,
|
|
271
|
+
route: route.path,
|
|
272
|
+
})
|
|
273
|
+
closePreview()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Item que debe mostrarse como mini-preview flotante (pertenece a esta tabla)
|
|
277
|
+
const floatingItem = computed(() =>
|
|
278
|
+
activeDockId.value
|
|
279
|
+
? docked.value.find(d => d.id === activeDockId.value && d.tableName === resolvedName.value) ?? null
|
|
280
|
+
: null
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
// Posición del panel flotante: centrado sobre el tab que lo abrió
|
|
284
|
+
const floatingPanelStyle = computed(() => {
|
|
285
|
+
const rect = activeDockRect.value
|
|
286
|
+
const panelW = 384
|
|
287
|
+
const bottom = 52
|
|
288
|
+
if (!rect || typeof window === 'undefined') return { bottom: bottom + 'px', right: '16px' }
|
|
289
|
+
const tabCenter = rect.left + rect.width / 2
|
|
290
|
+
let right = window.innerWidth - tabCenter - panelW / 2
|
|
291
|
+
right = Math.max(8, Math.min(right, window.innerWidth - panelW - 8))
|
|
292
|
+
return { bottom: bottom + 'px', right: right + 'px' }
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
function expandToFull(item) {
|
|
296
|
+
previewRow.value = item.row
|
|
297
|
+
undockItem(item.id)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Escuchar evento de restauración (fallback cuando la tabla no estaba montada)
|
|
301
|
+
onMounted(() => {
|
|
302
|
+
useNuxtApp().hooks.hook('preview:restore', (item) => {
|
|
303
|
+
if (item.tableName === resolvedName.value) previewRow.value = item.row
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
133
307
|
onMounted(async () => {
|
|
134
308
|
previewEnabled.value = !!slots.preview
|
|
135
309
|
window.addEventListener('keydown', onEsc)
|
|
310
|
+
document.addEventListener('mousedown', onDocMousedown)
|
|
136
311
|
// Restore preview from session cache — mark as from-cache to skip enter animation
|
|
137
312
|
if (props.cached && previewEnabled.value) {
|
|
138
313
|
try {
|
|
@@ -148,6 +323,7 @@ onMounted(async () => {
|
|
|
148
323
|
})
|
|
149
324
|
onBeforeUnmount(() => {
|
|
150
325
|
window.removeEventListener('keydown', onEsc)
|
|
326
|
+
document.removeEventListener('mousedown', onDocMousedown)
|
|
151
327
|
paginationObserver?.disconnect()
|
|
152
328
|
})
|
|
153
329
|
|
|
@@ -192,11 +368,6 @@ const onColumnPanelOutsideClick = (e) => {
|
|
|
192
368
|
showColumnPanel.value = false
|
|
193
369
|
}
|
|
194
370
|
}
|
|
195
|
-
const onFilterPanelOutsideClick = (e) => {
|
|
196
|
-
if (filterPanelRef.value && !filterPanelRef.value.contains(e.target)) {
|
|
197
|
-
showFilterPanel.value = false
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
371
|
|
|
201
372
|
watch(showColumnPanel, async (v) => {
|
|
202
373
|
if (v) {
|
|
@@ -213,10 +384,6 @@ watch(showColumnPanel, async (v) => {
|
|
|
213
384
|
document.removeEventListener('mousedown', onColumnPanelOutsideClick)
|
|
214
385
|
}
|
|
215
386
|
})
|
|
216
|
-
watch(showFilterPanel, (v) => {
|
|
217
|
-
if (v) document.addEventListener('mousedown', onFilterPanelOutsideClick)
|
|
218
|
-
else document.removeEventListener('mousedown', onFilterPanelOutsideClick)
|
|
219
|
-
})
|
|
220
387
|
|
|
221
388
|
// ─── Expose ───────────────────────────────────────────────────────────────────
|
|
222
389
|
const getSelectedRows = () => tableRef.value?.getSelectedRows()
|
|
@@ -224,177 +391,366 @@ const reload = () => tableRef.value?.reload()
|
|
|
224
391
|
const clearCache = () => tableRef.value?.clearCache()
|
|
225
392
|
const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
|
|
226
393
|
|
|
227
|
-
defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef })
|
|
394
|
+
defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview })
|
|
228
395
|
</script>
|
|
229
396
|
|
|
230
397
|
<template>
|
|
231
398
|
<div class="relative" ref="containerRef">
|
|
232
399
|
|
|
233
|
-
<!--
|
|
234
|
-
<div class="
|
|
235
|
-
|
|
236
|
-
<!-- Toolbar -->
|
|
237
|
-
<div class="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-card-line">
|
|
238
|
-
<div v-if="showSearch" class="flex-1 min-w-48">
|
|
239
|
-
<Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
|
|
240
|
-
</div>
|
|
400
|
+
<!-- Toolbar row (no card) -->
|
|
401
|
+
<div class="flex flex-wrap items-center gap-2 mb-2">
|
|
241
402
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
:class="[
|
|
247
|
-
'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
|
|
248
|
-
showFilterPanel || activeFilterCount > 0
|
|
249
|
-
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:border-blue-500 dark:text-blue-300'
|
|
250
|
-
: 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
|
|
251
|
-
]"
|
|
252
|
-
>
|
|
253
|
-
<IconAdjustmentsHorizontal class="size-4" stroke="1.5" />
|
|
254
|
-
Filtros{{ activeFilterCount > 0 ? ` (${activeFilterCount})` : '' }}
|
|
255
|
-
</button>
|
|
256
|
-
|
|
257
|
-
<!-- Filter panel — anchored below button -->
|
|
258
|
-
<Transition
|
|
259
|
-
enter-active-class="transition ease-out duration-150"
|
|
260
|
-
enter-from-class="opacity-0 translate-y-1 scale-95"
|
|
261
|
-
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
262
|
-
leave-active-class="transition ease-in duration-100"
|
|
263
|
-
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
264
|
-
leave-to-class="opacity-0 translate-y-1 scale-95"
|
|
265
|
-
>
|
|
266
|
-
<div
|
|
267
|
-
v-if="showFilterPanel"
|
|
268
|
-
ref="filterPanelRef"
|
|
269
|
-
class="absolute top-full left-0 z-50 mt-1.5 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 min-w-56 max-h-96 overflow-y-auto"
|
|
270
|
-
>
|
|
271
|
-
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-3 px-1">Filtros</p>
|
|
272
|
-
<TableFilter v-model="activeFilters" :columns="filtersConfig" />
|
|
273
|
-
</div>
|
|
274
|
-
</Transition>
|
|
275
|
-
</div>
|
|
276
|
-
|
|
277
|
-
<slot name="toolbar" />
|
|
403
|
+
<!-- Search -->
|
|
404
|
+
<div v-if="showSearch" class="flex-1 min-w-48 max-w-xs">
|
|
405
|
+
<Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
|
|
406
|
+
</div>
|
|
278
407
|
|
|
408
|
+
<!-- + Filtros button -->
|
|
409
|
+
<div v-if="showFilters && hasFilterableColumns" ref="filterAddBtnRef" class="relative">
|
|
279
410
|
<button
|
|
280
|
-
ref="columnButtonRef"
|
|
281
411
|
type="button"
|
|
282
|
-
@click="
|
|
412
|
+
@click="toggleFilterMenu"
|
|
283
413
|
:class="[
|
|
284
|
-
'
|
|
285
|
-
|
|
286
|
-
? 'border-
|
|
414
|
+
'inline-flex items-center gap-1.5 py-1.5 px-3 text-sm font-medium rounded-lg border transition-colors',
|
|
415
|
+
activeFilterList.length
|
|
416
|
+
? 'border-indigo-300 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:border-indigo-700 dark:text-indigo-300'
|
|
287
417
|
: 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
|
|
288
418
|
]"
|
|
289
419
|
>
|
|
290
|
-
<
|
|
291
|
-
|
|
420
|
+
<IconPlus class="size-3.5" />
|
|
421
|
+
Filtros{{ activeFilterList.length ? ` (${activeFilterList.length})` : '' }}
|
|
292
422
|
</button>
|
|
293
|
-
|
|
294
|
-
<TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
|
|
295
423
|
</div>
|
|
296
424
|
|
|
297
|
-
<!--
|
|
298
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
425
|
+
<!-- Slot for custom toolbar buttons -->
|
|
426
|
+
<slot name="toolbar" />
|
|
427
|
+
|
|
428
|
+
<!-- Columnas button -->
|
|
429
|
+
<button
|
|
430
|
+
ref="columnButtonRef"
|
|
431
|
+
type="button"
|
|
432
|
+
@click="showColumnPanel = !showColumnPanel"
|
|
433
|
+
:class="[
|
|
434
|
+
'py-1.5 px-3 inline-flex items-center gap-2 text-sm font-medium rounded-lg border transition-colors',
|
|
435
|
+
showColumnPanel
|
|
436
|
+
? 'border-indigo-300 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:border-indigo-700 dark:text-indigo-300'
|
|
437
|
+
: 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover'
|
|
438
|
+
]"
|
|
439
|
+
>
|
|
440
|
+
<IconLayoutColumns class="size-4" />
|
|
441
|
+
Columnas
|
|
442
|
+
</button>
|
|
443
|
+
|
|
444
|
+
<TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<!-- Filter chips row (shown when filters active) -->
|
|
448
|
+
<div v-if="activeFilterList.length" class="flex flex-wrap items-center gap-1.5 mb-2">
|
|
449
|
+
<div
|
|
450
|
+
v-for="chip in activeFilterList"
|
|
451
|
+
:key="chip.key"
|
|
452
|
+
class="inline-flex items-center text-xs rounded-lg border border-card-line bg-card overflow-hidden"
|
|
453
|
+
>
|
|
454
|
+
<span class="px-2.5 py-1 text-foreground font-medium border-r border-card-line bg-surface">{{ chip.label }}</span>
|
|
455
|
+
<span class="px-2 py-1 text-muted-foreground">{{ chip.displayOp }}</span>
|
|
456
|
+
<button
|
|
457
|
+
type="button"
|
|
458
|
+
@click.stop="openEditFilter(chip.col)"
|
|
459
|
+
class="inline-flex items-center gap-1 px-2 py-1 text-indigo-600 dark:text-indigo-400 font-medium hover:bg-indigo-50 dark:hover:bg-indigo-900/20 transition-colors border-x border-card-line"
|
|
318
460
|
>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
:enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
|
|
327
|
-
:enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
|
|
328
|
-
:enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
|
|
329
|
-
leave-active-class="transition ease-in duration-150"
|
|
330
|
-
leave-from-class="opacity-100 translate-x-0"
|
|
331
|
-
leave-to-class="opacity-0 translate-x-6"
|
|
461
|
+
{{ chip.displayVal }}
|
|
462
|
+
<IconChevronDown class="size-3 opacity-60" />
|
|
463
|
+
</button>
|
|
464
|
+
<button
|
|
465
|
+
type="button"
|
|
466
|
+
@click.stop="removeFilter(chip.key)"
|
|
467
|
+
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"
|
|
332
468
|
>
|
|
333
|
-
<
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
>
|
|
338
|
-
<!-- Resize handle -->
|
|
339
|
-
<div
|
|
340
|
-
class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-indigo-300 dark:hover:bg-indigo-600 transition-colors"
|
|
341
|
-
@mousedown="startResize"
|
|
342
|
-
/>
|
|
343
|
-
<!-- Preview -->
|
|
344
|
-
<div class="flex flex-col flex-1 overflow-hidden">
|
|
469
|
+
<IconX class="size-3" />
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
345
473
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
474
|
+
<!-- Table + preview overlay inside a minimal border box -->
|
|
475
|
+
<div class="relative overflow-hidden rounded-xl border border-card-line">
|
|
476
|
+
|
|
477
|
+
<!-- Tabla -->
|
|
478
|
+
<Table
|
|
479
|
+
ref="tableRef"
|
|
480
|
+
:endpoint="resolvedEndpoint"
|
|
481
|
+
:columns="columns"
|
|
482
|
+
:name="resolvedName"
|
|
483
|
+
:params="mergedParams"
|
|
484
|
+
:search="search"
|
|
485
|
+
:checkable="checkable"
|
|
486
|
+
:cached="cached"
|
|
487
|
+
:show-reload-button="showReloadButton"
|
|
488
|
+
:click-row-to-open="clickRowToOpen"
|
|
489
|
+
:preview-row-id="previewRow?.id ?? null"
|
|
490
|
+
:preview-mode="!!previewEnabled"
|
|
491
|
+
@row-click="handleRowClick"
|
|
492
|
+
@loaded="handleLoaded"
|
|
493
|
+
@page-change="closePreview"
|
|
494
|
+
@per-page-change="closePreview"
|
|
495
|
+
>
|
|
496
|
+
<template v-for="(_, name) in forwardedSlots" #[name]="slotProps">
|
|
497
|
+
<slot :name="name" v-bind="slotProps ?? {}" />
|
|
498
|
+
</template>
|
|
499
|
+
</Table>
|
|
350
500
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
501
|
+
<!-- Preview panel overlay — slides in from right, tapa la tabla -->
|
|
502
|
+
<Transition
|
|
503
|
+
:enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
|
|
504
|
+
:enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
|
|
505
|
+
:enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
|
|
506
|
+
leave-active-class="transition ease-in duration-150"
|
|
507
|
+
leave-from-class="opacity-100 translate-x-0"
|
|
508
|
+
leave-to-class="opacity-0 translate-x-6"
|
|
509
|
+
>
|
|
510
|
+
<div
|
|
511
|
+
v-if="previewRow && previewEnabled"
|
|
512
|
+
ref="previewPanelRef"
|
|
513
|
+
class="absolute top-0 right-0 z-30 flex bg-card border-l border-card-line shadow-[-4px_0_16px_rgba(0,0,0,0.06)]"
|
|
514
|
+
:style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
|
|
515
|
+
>
|
|
516
|
+
<!-- Resize handle -->
|
|
517
|
+
<div
|
|
518
|
+
class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-indigo-300 dark:hover:bg-indigo-600 transition-colors"
|
|
519
|
+
@mousedown="startResize"
|
|
520
|
+
/>
|
|
521
|
+
<!-- Preview -->
|
|
522
|
+
<div class="flex flex-col flex-1 overflow-hidden">
|
|
523
|
+
|
|
524
|
+
<!-- Barra de acciones del preview -->
|
|
525
|
+
<div class="shrink-0 flex items-center justify-between gap-2 px-3 py-2 border-b border-card-line">
|
|
526
|
+
<div class="flex-1 min-w-0">
|
|
527
|
+
<slot name="preview-header" :row="previewRow" :close="closePreview" />
|
|
358
528
|
</div>
|
|
359
|
-
|
|
360
|
-
<!-- Tabs — bottom -->
|
|
361
|
-
<div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
|
|
529
|
+
<div class="flex items-center gap-1 shrink-0">
|
|
362
530
|
<button
|
|
363
531
|
type="button"
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
previewTab === 'datos'
|
|
368
|
-
? 'border-t-card text-foreground'
|
|
369
|
-
: 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
|
|
370
|
-
]"
|
|
532
|
+
class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
533
|
+
title="Minimizar"
|
|
534
|
+
@click.stop="minimizePreview"
|
|
371
535
|
>
|
|
372
|
-
|
|
536
|
+
<IconMinus class="size-3.5" />
|
|
373
537
|
</button>
|
|
374
538
|
<button
|
|
375
539
|
type="button"
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
|
|
380
|
-
!resolvedHistoryEndpoint
|
|
381
|
-
? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
|
|
382
|
-
: previewTab === 'bitacora'
|
|
383
|
-
? 'border-t-card text-foreground'
|
|
384
|
-
: 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
|
|
385
|
-
]"
|
|
540
|
+
class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
541
|
+
title="Cerrar"
|
|
542
|
+
@click.stop="closePreview"
|
|
386
543
|
>
|
|
387
|
-
|
|
544
|
+
<IconX class="size-3.5" />
|
|
388
545
|
</button>
|
|
389
546
|
</div>
|
|
547
|
+
</div>
|
|
390
548
|
|
|
549
|
+
<!-- Scrollable content -->
|
|
550
|
+
<div class="flex-1 overflow-y-auto min-h-0">
|
|
551
|
+
<slot v-if="previewTab === 'datos'" name="preview" :row="previewRow" :close="closePreview" />
|
|
552
|
+
<Table.PreviewTimeline
|
|
553
|
+
v-else-if="previewTab === 'bitacora' && resolvedHistoryEndpoint"
|
|
554
|
+
:endpoint="resolvedHistoryEndpoint"
|
|
555
|
+
/>
|
|
391
556
|
</div>
|
|
557
|
+
|
|
558
|
+
<!-- Tabs — bottom -->
|
|
559
|
+
<div v-if="hasHistory" class="shrink-0 flex border-t border-card-line">
|
|
560
|
+
<button
|
|
561
|
+
type="button"
|
|
562
|
+
@click="previewTab = 'datos'"
|
|
563
|
+
:class="[
|
|
564
|
+
'flex-1 py-2.5 text-xs font-semibold transition-colors border-r border-card-line border-t-2 -mt-px',
|
|
565
|
+
previewTab === 'datos'
|
|
566
|
+
? 'border-t-card text-foreground'
|
|
567
|
+
: 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
|
|
568
|
+
]"
|
|
569
|
+
>
|
|
570
|
+
Datos
|
|
571
|
+
</button>
|
|
572
|
+
<button
|
|
573
|
+
type="button"
|
|
574
|
+
@click="resolvedHistoryEndpoint && (previewTab = 'bitacora')"
|
|
575
|
+
:disabled="!resolvedHistoryEndpoint"
|
|
576
|
+
:class="[
|
|
577
|
+
'flex-1 py-2.5 text-xs font-semibold transition-colors border-t-2 -mt-px',
|
|
578
|
+
!resolvedHistoryEndpoint
|
|
579
|
+
? 'border-t-transparent text-muted-foreground/40 cursor-not-allowed'
|
|
580
|
+
: previewTab === 'bitacora'
|
|
581
|
+
? 'border-t-card text-foreground'
|
|
582
|
+
: 'border-t-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover'
|
|
583
|
+
]"
|
|
584
|
+
>
|
|
585
|
+
Bitácora
|
|
586
|
+
</button>
|
|
587
|
+
</div>
|
|
588
|
+
|
|
392
589
|
</div>
|
|
393
|
-
</
|
|
590
|
+
</div>
|
|
591
|
+
</Transition>
|
|
394
592
|
|
|
395
|
-
</div>
|
|
396
593
|
</div>
|
|
397
594
|
|
|
595
|
+
<!-- ── Floating mini-preview (dock expand, estilo Gmail) ── -->
|
|
596
|
+
<Teleport to="body">
|
|
597
|
+
<Transition
|
|
598
|
+
enter-active-class="transition ease-out duration-200"
|
|
599
|
+
enter-from-class="opacity-0 translate-y-4"
|
|
600
|
+
enter-to-class="opacity-100 translate-y-0"
|
|
601
|
+
leave-active-class="transition ease-in duration-150"
|
|
602
|
+
leave-from-class="opacity-100 translate-y-0"
|
|
603
|
+
leave-to-class="opacity-0 translate-y-4"
|
|
604
|
+
>
|
|
605
|
+
<div
|
|
606
|
+
v-if="floatingItem"
|
|
607
|
+
class="fixed z-[60] w-96 flex flex-col bg-card border border-card-line rounded-t-xl shadow-2xl overflow-hidden"
|
|
608
|
+
:style="{ ...floatingPanelStyle, maxHeight: 'min(480px, calc(100vh - 60px))' }"
|
|
609
|
+
>
|
|
610
|
+
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-card-line shrink-0 bg-surface select-none">
|
|
611
|
+
<span class="size-6 rounded-full bg-primary flex items-center justify-center text-[10px] font-bold text-primary-foreground shrink-0">
|
|
612
|
+
{{ (floatingItem.label?.[0] ?? '?').toUpperCase() }}
|
|
613
|
+
</span>
|
|
614
|
+
<div class="flex-1 min-w-0">
|
|
615
|
+
<p class="text-sm font-semibold text-foreground truncate leading-tight">{{ floatingItem.label }}</p>
|
|
616
|
+
<p v-if="floatingItem.subtitle" class="text-xs text-muted-foreground truncate">{{ floatingItem.subtitle }}</p>
|
|
617
|
+
</div>
|
|
618
|
+
<button type="button" title="Expandir" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="expandToFull(floatingItem)">
|
|
619
|
+
<IconMaximize class="size-3.5" />
|
|
620
|
+
</button>
|
|
621
|
+
<button type="button" title="Minimizar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors" @click.stop="collapseDock()">
|
|
622
|
+
<IconMinus class="size-3.5" />
|
|
623
|
+
</button>
|
|
624
|
+
<button type="button" title="Cerrar" class="inline-flex items-center justify-center size-6 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" @click.stop="undockItem(floatingItem.id)">
|
|
625
|
+
<IconX class="size-3.5" />
|
|
626
|
+
</button>
|
|
627
|
+
</div>
|
|
628
|
+
<div class="flex-1 overflow-y-auto min-h-0">
|
|
629
|
+
<slot name="preview" :row="floatingItem.row" :close="() => undockItem(floatingItem.id)" />
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
</Transition>
|
|
633
|
+
</Teleport>
|
|
634
|
+
|
|
635
|
+
<!-- Filter menu — teleported to body -->
|
|
636
|
+
<Teleport to="body">
|
|
637
|
+
<Transition
|
|
638
|
+
enter-active-class="transition ease-out duration-150"
|
|
639
|
+
enter-from-class="opacity-0 translate-y-1 scale-95"
|
|
640
|
+
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
641
|
+
leave-active-class="transition ease-in duration-100"
|
|
642
|
+
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
643
|
+
leave-to-class="opacity-0 translate-y-1 scale-95"
|
|
644
|
+
>
|
|
645
|
+
<div
|
|
646
|
+
v-if="showFilterPanel"
|
|
647
|
+
ref="filterMenuRef"
|
|
648
|
+
class="fixed z-[60] bg-dropdown border border-dropdown-line rounded-xl shadow-2xl min-w-52 overflow-hidden"
|
|
649
|
+
:style="filterMenuStyle"
|
|
650
|
+
>
|
|
651
|
+
|
|
652
|
+
<!-- Step 1: column picker -->
|
|
653
|
+
<template v-if="filterMenuStep === 'columns'">
|
|
654
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest px-3 pt-2.5 pb-1">Filtrar por</p>
|
|
655
|
+
<div class="pb-1.5">
|
|
656
|
+
<button
|
|
657
|
+
v-for="col in filtersConfig"
|
|
658
|
+
:key="col.key"
|
|
659
|
+
type="button"
|
|
660
|
+
@click.stop="selectFilterColumn(col)"
|
|
661
|
+
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted-hover transition-colors"
|
|
662
|
+
>
|
|
663
|
+
<span class="flex-1 text-left text-foreground">{{ col.label }}</span>
|
|
664
|
+
<span v-if="activeFilters[col.key]" class="text-[10px] font-semibold text-indigo-500 uppercase">activo</span>
|
|
665
|
+
</button>
|
|
666
|
+
</div>
|
|
667
|
+
</template>
|
|
668
|
+
|
|
669
|
+
<!-- Step 2: value input -->
|
|
670
|
+
<template v-else-if="filterMenuStep === 'value' && pendingCol">
|
|
671
|
+
<div class="flex items-center gap-2 px-3 py-2 border-b border-card-line bg-surface">
|
|
672
|
+
<button
|
|
673
|
+
type="button"
|
|
674
|
+
@click.stop="filterMenuStep = 'columns'"
|
|
675
|
+
class="text-muted-foreground hover:text-foreground transition-colors"
|
|
676
|
+
>
|
|
677
|
+
<IconChevronLeft class="size-4" />
|
|
678
|
+
</button>
|
|
679
|
+
<span class="text-sm font-medium text-foreground">{{ pendingCol.label }}</span>
|
|
680
|
+
</div>
|
|
681
|
+
<div class="p-3 space-y-2.5">
|
|
682
|
+
|
|
683
|
+
<!-- text -->
|
|
684
|
+
<input
|
|
685
|
+
v-if="pendingCol.filterType === 'text'"
|
|
686
|
+
v-model="pendingValue"
|
|
687
|
+
type="text"
|
|
688
|
+
autofocus
|
|
689
|
+
@keydown.enter.stop="applyPendingFilter"
|
|
690
|
+
@keydown.escape.stop="closeFilterMenu"
|
|
691
|
+
placeholder="Buscar..."
|
|
692
|
+
class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"
|
|
693
|
+
/>
|
|
694
|
+
|
|
695
|
+
<!-- select -->
|
|
696
|
+
<div v-else-if="pendingCol.filterType === 'select'" class="space-y-0.5">
|
|
697
|
+
<button
|
|
698
|
+
v-for="opt in pendingCol.filterOptions"
|
|
699
|
+
:key="opt.value"
|
|
700
|
+
type="button"
|
|
701
|
+
@click.stop="pendingValue = opt.value; applyPendingFilter()"
|
|
702
|
+
:class="[
|
|
703
|
+
'w-full flex items-center gap-2 px-2.5 py-1.5 text-sm rounded-lg transition-colors text-left',
|
|
704
|
+
pendingValue === opt.value
|
|
705
|
+
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300'
|
|
706
|
+
: 'hover:bg-muted-hover text-foreground'
|
|
707
|
+
]"
|
|
708
|
+
>
|
|
709
|
+
<span class="flex-1">{{ opt.label }}</span>
|
|
710
|
+
<IconCheck v-if="pendingValue === opt.value" class="size-3.5 shrink-0 text-indigo-500" />
|
|
711
|
+
</button>
|
|
712
|
+
</div>
|
|
713
|
+
|
|
714
|
+
<!-- daterange -->
|
|
715
|
+
<div v-else-if="pendingCol.filterType === 'daterange'" class="space-y-2">
|
|
716
|
+
<div class="flex gap-1">
|
|
717
|
+
<button
|
|
718
|
+
v-for="op in dateOps"
|
|
719
|
+
:key="op.value"
|
|
720
|
+
type="button"
|
|
721
|
+
@click.stop="pendingDateOp = op.value"
|
|
722
|
+
:class="[
|
|
723
|
+
'flex-1 py-1 text-xs rounded-lg border transition-colors',
|
|
724
|
+
pendingDateOp === op.value
|
|
725
|
+
? 'border-indigo-400 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/20 dark:text-indigo-300'
|
|
726
|
+
: 'border-card-line text-muted-foreground hover:bg-muted-hover'
|
|
727
|
+
]"
|
|
728
|
+
>
|
|
729
|
+
{{ op.label }}
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
<template v-if="pendingDateOp === 'between'">
|
|
733
|
+
<input type="date" v-model="pendingValue.from" class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
|
734
|
+
<input type="date" v-model="pendingValue.to" class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
|
735
|
+
</template>
|
|
736
|
+
<input v-else type="date" v-model="pendingValue.singleDate" class="w-full rounded-lg border border-card-line bg-card text-foreground py-1.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" />
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
<button
|
|
740
|
+
v-if="pendingCol.filterType !== 'select'"
|
|
741
|
+
type="button"
|
|
742
|
+
@click.stop="applyPendingFilter"
|
|
743
|
+
class="w-full py-1.5 text-sm font-medium text-center rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
|
|
744
|
+
>
|
|
745
|
+
Aplicar
|
|
746
|
+
</button>
|
|
747
|
+
</div>
|
|
748
|
+
</template>
|
|
749
|
+
|
|
750
|
+
</div>
|
|
751
|
+
</Transition>
|
|
752
|
+
</Teleport>
|
|
753
|
+
|
|
398
754
|
<!-- Column panel — teleported to body to escape overflow-hidden -->
|
|
399
755
|
<Teleport to="body">
|
|
400
756
|
<Transition
|
package/nuxt.config.ts
CHANGED
|
@@ -2,6 +2,10 @@ import tailwindcss from '@tailwindcss/vite'
|
|
|
2
2
|
|
|
3
3
|
export default defineNuxtConfig({
|
|
4
4
|
extends: ['@innertia-solutions/nuxt-core'],
|
|
5
|
+
modules: [
|
|
6
|
+
'@pinia/nuxt',
|
|
7
|
+
'pinia-plugin-persistedstate/nuxt', // required for dockedPreviews store persistence
|
|
8
|
+
],
|
|
5
9
|
css: ['@innertia-solutions/nuxt-theme-spark/spark.css'],
|
|
6
10
|
components: [
|
|
7
11
|
{ path: './components', pathPrefix: true, prefix: '' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@innertia-solutions/nuxt-theme-spark",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.140",
|
|
4
4
|
"description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@innertia-solutions/nuxt-core": "^0.1.4",
|
|
32
|
+
"@pinia/nuxt": "^0.11.3",
|
|
33
|
+
"pinia-plugin-persistedstate": "^4.7.1",
|
|
32
34
|
"@tabler/icons-vue": "^3.44.0",
|
|
33
35
|
"@tailwindcss/aspect-ratio": "^0.4.2",
|
|
34
36
|
"@tailwindcss/forms": "^0.5.10",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sincroniza el store de previews minimizados entre pestañas del navegador.
|
|
3
|
+
* Escucha los eventos `storage` que dispara localStorage cuando otra pestaña escribe.
|
|
4
|
+
*/
|
|
5
|
+
export default defineNuxtPlugin(() => {
|
|
6
|
+
const store = useDockedPreviewsStore()
|
|
7
|
+
|
|
8
|
+
window.addEventListener('storage', (event) => {
|
|
9
|
+
if (event.key !== 'docked-previews' || !event.newValue) return
|
|
10
|
+
try {
|
|
11
|
+
const persisted = JSON.parse(event.newValue)
|
|
12
|
+
store.hydrate(persisted.items ?? [])
|
|
13
|
+
} catch {
|
|
14
|
+
// JSON inválido — ignorar
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable para manejar los previews minimizados.
|
|
3
|
+
*
|
|
4
|
+
* - `docked` / `dock` / `undock` / `isActive` → Pinia store (persiste en localStorage, sync entre tabs)
|
|
5
|
+
* - `activeDockId` / `activeDockRect` / expand / collapse → estado de UI efímero (no persistido)
|
|
6
|
+
*/
|
|
7
|
+
export function useDockedPreviews() {
|
|
8
|
+
const store = useDockedPreviewsStore()
|
|
9
|
+
|
|
10
|
+
// ─── UI state (no persiste) ───────────────────────────────────────────────────
|
|
11
|
+
const activeDockId = useState('docked-active-id', () => null)
|
|
12
|
+
const activeDockRect = useState('docked-active-rect', () => null)
|
|
13
|
+
|
|
14
|
+
// ─── Acceso reactivo a los items persistidos ──────────────────────────────────
|
|
15
|
+
const docked = computed(() => store.items)
|
|
16
|
+
|
|
17
|
+
// ─── Dock / undock ────────────────────────────────────────────────────────────
|
|
18
|
+
const dock = (payload) => store.add(payload)
|
|
19
|
+
|
|
20
|
+
const undock = (id) => {
|
|
21
|
+
store.remove(id)
|
|
22
|
+
if (activeDockId.value === id) {
|
|
23
|
+
activeDockId.value = null
|
|
24
|
+
activeDockRect.value = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isActive = (id) => !!store.items.find(d => d.id === id)
|
|
29
|
+
|
|
30
|
+
// ─── Panel flotante ───────────────────────────────────────────────────────────
|
|
31
|
+
const expandDock = (id, rect = null) => {
|
|
32
|
+
if (activeDockId.value === id) {
|
|
33
|
+
activeDockId.value = null
|
|
34
|
+
activeDockRect.value = null
|
|
35
|
+
} else {
|
|
36
|
+
activeDockId.value = id
|
|
37
|
+
activeDockRect.value = rect ? { left: rect.left, width: rect.width } : null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const collapseDock = () => {
|
|
42
|
+
activeDockId.value = null
|
|
43
|
+
activeDockRect.value = null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
docked,
|
|
48
|
+
activeDockId,
|
|
49
|
+
activeDockRect,
|
|
50
|
+
dock,
|
|
51
|
+
undock,
|
|
52
|
+
isActive,
|
|
53
|
+
expandDock,
|
|
54
|
+
collapseDock,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
|
|
3
|
+
// Storage SSR-safe: null en servidor, localStorage en cliente
|
|
4
|
+
const clientStorage = typeof window !== 'undefined' ? window.localStorage : null
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Store persistido en localStorage para los previews minimizados.
|
|
8
|
+
* Sobrevive recargas y se sincroniza entre pestañas (via plugin dockedPreviewsSync).
|
|
9
|
+
*/
|
|
10
|
+
export const useDockedPreviewsStore = defineStore('docked-previews', {
|
|
11
|
+
state: () => ({
|
|
12
|
+
items: [],
|
|
13
|
+
}),
|
|
14
|
+
|
|
15
|
+
actions: {
|
|
16
|
+
add({ id, label, subtitle, row, tableName, route }) {
|
|
17
|
+
if (this.items.find(d => d.id === id)) return
|
|
18
|
+
this.items.push({ id, label, subtitle: subtitle ?? null, row, tableName, route })
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
remove(id) {
|
|
22
|
+
this.items = this.items.filter(d => d.id !== id)
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
/** Sincroniza el estado desde otra pestaña (llamado por el plugin de storage). */
|
|
26
|
+
hydrate(items) {
|
|
27
|
+
this.items = items
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
persist: {
|
|
32
|
+
storage: clientStorage,
|
|
33
|
+
},
|
|
34
|
+
})
|