@innertia-solutions/nuxt-theme-spark 0.1.134 → 0.1.135
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/Base.vue +1 -1
- package/components/Admin/Header.vue +1 -1
- package/components/Layout/SidebarWithAppColumn.vue +388 -0
- package/components/Layout/TopBar.vue +113 -0
- package/components/Modal/Base.vue +29 -0
- package/components/Table/Enterprise.vue +540 -0
- package/package.json +2 -2
|
@@ -11,7 +11,7 @@ const isOpen = ref(false)
|
|
|
11
11
|
const open = () => { isOpen.value = true }
|
|
12
12
|
const close = () => { isOpen.value = false }
|
|
13
13
|
|
|
14
|
-
provide('
|
|
14
|
+
provide('spark:sidebar', { isOpen, open, close })
|
|
15
15
|
|
|
16
16
|
const userInitial = computed(() =>
|
|
17
17
|
props.user?.name?.charAt(0).toUpperCase() ?? props.user?.email?.charAt(0).toUpperCase() ?? 'U'
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import * as TablerIcons from '@tabler/icons-vue'
|
|
3
|
+
|
|
4
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
interface NavItem {
|
|
7
|
+
label: string
|
|
8
|
+
icon: string
|
|
9
|
+
route: string
|
|
10
|
+
pattern?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AppItem {
|
|
14
|
+
label: string
|
|
15
|
+
icon: string
|
|
16
|
+
route: string
|
|
17
|
+
bg?: string
|
|
18
|
+
text?: string
|
|
19
|
+
nav?: NavItem[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MenuItem {
|
|
23
|
+
label: string
|
|
24
|
+
icon: string
|
|
25
|
+
route?: string
|
|
26
|
+
pattern?: string
|
|
27
|
+
children?: NavItem[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Props / Emits ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(defineProps<{
|
|
33
|
+
appItems?: AppItem[]
|
|
34
|
+
menuItems?: MenuItem[]
|
|
35
|
+
homeRoute?: string
|
|
36
|
+
user?: { name?: string; email?: string } | null
|
|
37
|
+
floating?: boolean
|
|
38
|
+
}>(), {
|
|
39
|
+
appItems: () => [],
|
|
40
|
+
menuItems: () => [],
|
|
41
|
+
homeRoute: '/',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{ logout: [] }>()
|
|
45
|
+
|
|
46
|
+
// ─── Mobile sidebar ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const isOpen = ref(false)
|
|
49
|
+
const open = () => { isOpen.value = true }
|
|
50
|
+
const close = () => { isOpen.value = false }
|
|
51
|
+
provide('spark:sidebar', { isOpen, open, close })
|
|
52
|
+
|
|
53
|
+
// ─── Route matching ───────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const route = useRoute()
|
|
56
|
+
|
|
57
|
+
const matchPattern = (pattern?: string) => {
|
|
58
|
+
if (!pattern) return false
|
|
59
|
+
return new RegExp('^' + pattern.replace(/\*/g, '.*') + '$').test(route.path)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isChildActive = (children?: NavItem[]) =>
|
|
63
|
+
children?.some(c => matchPattern(c.pattern ?? c.route + '/*')) ?? false
|
|
64
|
+
|
|
65
|
+
// ─── Active app detection ─────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const currentApp = computed(() =>
|
|
68
|
+
props.appItems.find(a => route.path.startsWith(a.route)) ?? null
|
|
69
|
+
)
|
|
70
|
+
const isInApp = computed(() => !!currentApp.value)
|
|
71
|
+
|
|
72
|
+
// ─── Hover preview ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const hoveredApp = ref<AppItem | 'home' | null>(null)
|
|
75
|
+
|
|
76
|
+
const previewApp = computed(() =>
|
|
77
|
+
hoveredApp.value && hoveredApp.value !== 'home'
|
|
78
|
+
? hoveredApp.value as AppItem
|
|
79
|
+
: currentApp.value
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const showMainMenu = computed(() =>
|
|
83
|
+
hoveredApp.value === 'home' || (!hoveredApp.value && !isInApp.value)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
watch(isInApp, (val) => { if (!val) hoveredApp.value = null })
|
|
87
|
+
|
|
88
|
+
// ─── Main menu accordion ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const openAccordions = ref<Record<number, boolean>>({})
|
|
91
|
+
const toggleAccordion = (i: number) => { openAccordions.value[i] = !openAccordions.value[i] }
|
|
92
|
+
|
|
93
|
+
watch(() => route.path, () => {
|
|
94
|
+
let active = -1
|
|
95
|
+
props.menuItems.forEach((item, i) => {
|
|
96
|
+
if (item.children && (matchPattern(item.pattern) || isChildActive(item.children))) active = i
|
|
97
|
+
})
|
|
98
|
+
openAccordions.value = active !== -1 ? { [active]: true } : {}
|
|
99
|
+
}, { immediate: true })
|
|
100
|
+
|
|
101
|
+
// ─── User / env ───────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
const userInitial = computed(() =>
|
|
104
|
+
props.user?.name?.charAt(0).toUpperCase() ??
|
|
105
|
+
props.user?.email?.charAt(0).toUpperCase() ?? 'U'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const config = useRuntimeConfig()
|
|
109
|
+
const appEnv = config.public.appEnv as string | undefined
|
|
110
|
+
const envLabel = computed(() => (!appEnv || appEnv === 'production') ? null : appEnv)
|
|
111
|
+
|
|
112
|
+
// ─── Icon resolver ────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const icon = (name: string) => (TablerIcons as Record<string, unknown>)[name]
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<div class="bg-background-1 min-h-screen">
|
|
119
|
+
|
|
120
|
+
<!-- Mobile backdrop -->
|
|
121
|
+
<Transition
|
|
122
|
+
enter-from-class="opacity-0" enter-active-class="transition-opacity duration-300"
|
|
123
|
+
leave-to-class="opacity-0" leave-active-class="transition-opacity duration-300"
|
|
124
|
+
>
|
|
125
|
+
<div
|
|
126
|
+
v-if="isOpen"
|
|
127
|
+
class="lg:hidden fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
|
128
|
+
@click="close"
|
|
129
|
+
/>
|
|
130
|
+
</Transition>
|
|
131
|
+
|
|
132
|
+
<!-- ═══ SIDEBAR ════════════════════════════════════════════════════════════ -->
|
|
133
|
+
<aside
|
|
134
|
+
:class="[
|
|
135
|
+
'fixed inset-y-0 start-0 z-60 w-65',
|
|
136
|
+
'transition-transform duration-300 lg:translate-x-0',
|
|
137
|
+
isOpen ? 'translate-x-0' : 'max-lg:-translate-x-full',
|
|
138
|
+
floating ? 'p-3' : '',
|
|
139
|
+
]"
|
|
140
|
+
>
|
|
141
|
+
<div
|
|
142
|
+
:class="[
|
|
143
|
+
'flex flex-col h-full bg-sidebar',
|
|
144
|
+
floating
|
|
145
|
+
? 'rounded-2xl border border-sidebar-line shadow-sm overflow-hidden'
|
|
146
|
+
: 'border-e border-sidebar-line',
|
|
147
|
+
]"
|
|
148
|
+
>
|
|
149
|
+
|
|
150
|
+
<!-- Logo ─────────────────────────────────────────────────────────────── -->
|
|
151
|
+
<header class="px-4 pt-4 pb-3 border-b border-sidebar-line shrink-0 flex items-center gap-x-2">
|
|
152
|
+
<div class="flex-1 min-w-0">
|
|
153
|
+
<slot name="logo" />
|
|
154
|
+
</div>
|
|
155
|
+
<!-- Mobile close -->
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
class="lg:hidden size-7 inline-flex justify-center items-center rounded-lg text-muted-foreground hover:bg-muted-hover transition-colors"
|
|
159
|
+
@click="close"
|
|
160
|
+
>
|
|
161
|
+
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
162
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
163
|
+
</svg>
|
|
164
|
+
</button>
|
|
165
|
+
</header>
|
|
166
|
+
|
|
167
|
+
<!-- Search (optional) ───────────────────────────────────────────────── -->
|
|
168
|
+
<div v-if="$slots.search" class="px-3 pb-2 border-b border-sidebar-line shrink-0">
|
|
169
|
+
<slot name="search" />
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- Body: icon strip + right panel ──────────────────────────────────── -->
|
|
173
|
+
<div class="flex flex-1 min-h-0" @mouseleave="hoveredApp = null">
|
|
174
|
+
|
|
175
|
+
<!-- ── Icon strip (50 px) ──────────────────────────────────────────── -->
|
|
176
|
+
<div class="w-[50px] shrink-0 flex flex-col relative">
|
|
177
|
+
|
|
178
|
+
<!-- Separator line (behind strip items via z-0) -->
|
|
179
|
+
<span class="pointer-events-none absolute right-0 inset-y-0 w-px bg-sidebar-line z-0" />
|
|
180
|
+
|
|
181
|
+
<div class="flex-1 flex flex-col py-2 gap-1.5 overflow-y-auto relative z-10">
|
|
182
|
+
|
|
183
|
+
<!-- Home button -->
|
|
184
|
+
<NuxtLink
|
|
185
|
+
:to="homeRoute"
|
|
186
|
+
class="group relative flex items-center justify-center h-12 border border-r-0"
|
|
187
|
+
:class="!isInApp
|
|
188
|
+
? 'ml-[2px] bg-card border-sidebar-line rounded-l-lg'
|
|
189
|
+
: 'ml-[1px] w-12 border-transparent rounded-lg'"
|
|
190
|
+
@mouseenter="hoveredApp = 'home'"
|
|
191
|
+
>
|
|
192
|
+
<span
|
|
193
|
+
class="size-10 flex items-center justify-center rounded-lg shrink-0 transition-opacity duration-150 bg-surface text-muted-foreground"
|
|
194
|
+
:class="!isInApp ? 'opacity-100' : 'opacity-50 group-hover:opacity-100'"
|
|
195
|
+
>
|
|
196
|
+
<component :is="icon('IconHome')" class="size-[22px]" />
|
|
197
|
+
</span>
|
|
198
|
+
</NuxtLink>
|
|
199
|
+
|
|
200
|
+
<!-- App items -->
|
|
201
|
+
<NuxtLink
|
|
202
|
+
v-for="app in appItems"
|
|
203
|
+
:key="app.route"
|
|
204
|
+
:to="app.route"
|
|
205
|
+
class="group relative flex items-center justify-center h-12 border border-r-0"
|
|
206
|
+
:class="route.path.startsWith(app.route)
|
|
207
|
+
? 'ml-[2px] bg-card border-sidebar-line rounded-l-lg'
|
|
208
|
+
: 'ml-[1px] w-12 border-transparent rounded-lg'"
|
|
209
|
+
@mouseenter="hoveredApp = app"
|
|
210
|
+
>
|
|
211
|
+
<span
|
|
212
|
+
class="size-10 flex items-center justify-center rounded-lg shrink-0 transition-opacity duration-150"
|
|
213
|
+
:class="[
|
|
214
|
+
app.bg ?? 'bg-surface',
|
|
215
|
+
app.text ?? 'text-muted-foreground',
|
|
216
|
+
route.path.startsWith(app.route) ? 'opacity-100' : 'opacity-50 group-hover:opacity-100',
|
|
217
|
+
]"
|
|
218
|
+
>
|
|
219
|
+
<component :is="icon(app.icon)" class="size-[22px]" />
|
|
220
|
+
</span>
|
|
221
|
+
</NuxtLink>
|
|
222
|
+
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<!-- /icon strip -->
|
|
226
|
+
|
|
227
|
+
<!-- ── Right panel ─────────────────────────────────────────────────── -->
|
|
228
|
+
<div class="flex-1 relative overflow-hidden bg-card">
|
|
229
|
+
|
|
230
|
+
<!-- MAIN MENU -->
|
|
231
|
+
<Transition name="panel-swap">
|
|
232
|
+
<div v-if="showMainMenu" key="main" class="absolute inset-0 overflow-y-auto">
|
|
233
|
+
<ul class="flex flex-col gap-y-1 px-3 pt-2 pb-3">
|
|
234
|
+
<li v-for="(item, index) in menuItems" :key="'menu-' + index">
|
|
235
|
+
|
|
236
|
+
<!-- Simple link -->
|
|
237
|
+
<NuxtLink
|
|
238
|
+
v-if="!item.children && item.route"
|
|
239
|
+
:to="item.route"
|
|
240
|
+
class="flex items-center gap-x-3 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-all border border-transparent"
|
|
241
|
+
:class="{ 'bg-surface text-foreground font-semibold border-card-line': matchPattern(item.pattern ?? item.route) }"
|
|
242
|
+
>
|
|
243
|
+
<component :is="icon(item.icon)" class="shrink-0 size-4" />
|
|
244
|
+
{{ item.label }}
|
|
245
|
+
</NuxtLink>
|
|
246
|
+
|
|
247
|
+
<!-- Accordion group -->
|
|
248
|
+
<div v-else-if="item.children" class="flex flex-col">
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
class="w-full text-start flex items-center gap-x-3 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-all"
|
|
252
|
+
:class="{ 'bg-muted text-foreground font-semibold': openAccordions[index] }"
|
|
253
|
+
@click="toggleAccordion(index)"
|
|
254
|
+
>
|
|
255
|
+
<component :is="icon(item.icon)" class="shrink-0 size-4" />
|
|
256
|
+
<span class="flex-1">{{ item.label }}</span>
|
|
257
|
+
<component
|
|
258
|
+
:is="icon('IconChevronDown')"
|
|
259
|
+
class="shrink-0 size-4 transition-transform duration-300"
|
|
260
|
+
:class="{ '-rotate-180': openAccordions[index] }"
|
|
261
|
+
/>
|
|
262
|
+
</button>
|
|
263
|
+
<div
|
|
264
|
+
class="overflow-hidden transition-all duration-300 ease-in-out"
|
|
265
|
+
:style="{
|
|
266
|
+
maxHeight: openAccordions[index] ? '300px' : '0px',
|
|
267
|
+
opacity: openAccordions[index] ? '1' : '0',
|
|
268
|
+
marginTop: openAccordions[index] ? '4px' : '0px',
|
|
269
|
+
}"
|
|
270
|
+
>
|
|
271
|
+
<ul class="ps-8 flex flex-col gap-y-1 relative before:absolute before:start-4.5 before:w-px before:h-full before:bg-sidebar-line">
|
|
272
|
+
<li v-for="child in item.children" :key="child.route">
|
|
273
|
+
<NuxtLink
|
|
274
|
+
:to="child.route"
|
|
275
|
+
class="flex items-center gap-x-4 py-2 px-3 text-sm text-muted-foreground rounded-lg hover:bg-muted-hover transition-colors border border-transparent"
|
|
276
|
+
:class="{ 'bg-surface text-foreground font-semibold': matchPattern(child.pattern ?? child.route) }"
|
|
277
|
+
>
|
|
278
|
+
<component :is="icon(child.icon)" class="shrink-0 size-4" />
|
|
279
|
+
{{ child.label }}
|
|
280
|
+
</NuxtLink>
|
|
281
|
+
</li>
|
|
282
|
+
</ul>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
</li>
|
|
287
|
+
</ul>
|
|
288
|
+
</div>
|
|
289
|
+
</Transition>
|
|
290
|
+
|
|
291
|
+
<!-- APP SUBNAV -->
|
|
292
|
+
<Transition name="panel-swap">
|
|
293
|
+
<div v-if="!showMainMenu" :key="previewApp?.route ?? 'app'" class="absolute inset-0 flex flex-col">
|
|
294
|
+
<div class="px-3 pt-3 pb-3 border-b border-sidebar-line shrink-0">
|
|
295
|
+
<span class="text-[13px] font-bold text-muted-foreground uppercase tracking-wider truncate">
|
|
296
|
+
{{ previewApp?.label }}
|
|
297
|
+
</span>
|
|
298
|
+
</div>
|
|
299
|
+
<nav class="flex-1 overflow-y-auto py-2 px-2">
|
|
300
|
+
<ul class="flex flex-col gap-0.5">
|
|
301
|
+
<li v-for="item in (previewApp?.nav ?? [])" :key="item.route">
|
|
302
|
+
<NuxtLink
|
|
303
|
+
:to="item.route"
|
|
304
|
+
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted-hover transition-colors border border-transparent"
|
|
305
|
+
:class="{ 'bg-surface text-foreground font-semibold border-card-line': matchPattern(item.pattern ?? item.route + '*') }"
|
|
306
|
+
>
|
|
307
|
+
<component :is="icon(item.icon)" class="size-4 shrink-0" />
|
|
308
|
+
{{ item.label }}
|
|
309
|
+
</NuxtLink>
|
|
310
|
+
</li>
|
|
311
|
+
</ul>
|
|
312
|
+
</nav>
|
|
313
|
+
</div>
|
|
314
|
+
</Transition>
|
|
315
|
+
|
|
316
|
+
</div>
|
|
317
|
+
<!-- /right panel -->
|
|
318
|
+
|
|
319
|
+
</div>
|
|
320
|
+
<!-- /body -->
|
|
321
|
+
|
|
322
|
+
<!-- Footer ───────────────────────────────────────────────────────────── -->
|
|
323
|
+
<div class="shrink-0 border-t border-sidebar-line">
|
|
324
|
+
|
|
325
|
+
<!-- Controls slot (dark mode, notifications, etc.) -->
|
|
326
|
+
<div v-if="$slots['user-controls']" class="flex items-center gap-x-1.5 px-3 pt-3">
|
|
327
|
+
<slot name="user-controls" />
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<!-- User info + logout -->
|
|
331
|
+
<div v-if="user" class="flex items-center gap-x-3 px-3 py-3">
|
|
332
|
+
<div class="size-9 rounded-lg bg-primary flex items-center justify-center text-primary-foreground text-sm font-bold shrink-0 select-none">
|
|
333
|
+
{{ userInitial }}
|
|
334
|
+
</div>
|
|
335
|
+
<div class="flex-1 min-w-0">
|
|
336
|
+
<p class="text-sm font-semibold text-foreground truncate">{{ user.name ?? user.email }}</p>
|
|
337
|
+
<p v-if="user.name && user.email" class="text-xs text-muted-foreground truncate">{{ user.email }}</p>
|
|
338
|
+
</div>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
class="size-7 inline-flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors shrink-0"
|
|
342
|
+
title="Cerrar sesión"
|
|
343
|
+
@click="emit('logout')"
|
|
344
|
+
>
|
|
345
|
+
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
346
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M18 9l3 3m0 0l-3 3m3-3H9" />
|
|
347
|
+
</svg>
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<!-- Extra footer slot -->
|
|
352
|
+
<slot name="user-footer" />
|
|
353
|
+
|
|
354
|
+
<!-- Env banner -->
|
|
355
|
+
<div
|
|
356
|
+
v-if="envLabel"
|
|
357
|
+
class="flex items-center justify-center gap-x-2 py-2 bg-amber-400/15 border-t border-amber-400/30"
|
|
358
|
+
>
|
|
359
|
+
<span class="size-1.5 rounded-full bg-amber-400 shrink-0" />
|
|
360
|
+
<span class="text-[11px] font-semibold text-amber-600 dark:text-amber-400 uppercase tracking-wide">
|
|
361
|
+
{{ envLabel }}
|
|
362
|
+
</span>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
</div>
|
|
366
|
+
<!-- /footer -->
|
|
367
|
+
|
|
368
|
+
</div>
|
|
369
|
+
</aside>
|
|
370
|
+
|
|
371
|
+
<!-- ═══ MAIN CONTENT ══════════════════════════════════════════════════════ -->
|
|
372
|
+
<div class="lg:ps-65 flex flex-col min-h-screen">
|
|
373
|
+
<slot name="topbar" />
|
|
374
|
+
<div class="flex-1 min-h-0">
|
|
375
|
+
<slot />
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
</div>
|
|
380
|
+
</template>
|
|
381
|
+
|
|
382
|
+
<style scoped>
|
|
383
|
+
/* Panel swap animation */
|
|
384
|
+
.panel-swap-enter-active,
|
|
385
|
+
.panel-swap-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
|
386
|
+
.panel-swap-enter-from { opacity: 0; transform: translateX(10px); }
|
|
387
|
+
.panel-swap-leave-to { opacity: 0; transform: translateX(-10px); }
|
|
388
|
+
</style>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// ─── Props / Emits ────────────────────────────────────────────────────────────
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(defineProps<{
|
|
5
|
+
user?: { name?: string; email?: string; role?: string } | null
|
|
6
|
+
notificationsCount?: number
|
|
7
|
+
searchPlaceholder?: string
|
|
8
|
+
showSearch?: boolean
|
|
9
|
+
showNotifications?: boolean
|
|
10
|
+
showUser?: boolean
|
|
11
|
+
}>(), {
|
|
12
|
+
user: null,
|
|
13
|
+
notificationsCount: 0,
|
|
14
|
+
searchPlaceholder: 'Buscar…',
|
|
15
|
+
showSearch: true,
|
|
16
|
+
showNotifications: true,
|
|
17
|
+
showUser: true,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
search: [query: string]
|
|
22
|
+
'notification-click': []
|
|
23
|
+
logout: []
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
// ─── Search ───────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const searchQuery = ref('')
|
|
29
|
+
|
|
30
|
+
const handleSearch = () => emit('search', searchQuery.value)
|
|
31
|
+
|
|
32
|
+
// ⌘K / Ctrl+K → focus search
|
|
33
|
+
const searchRef = ref<HTMLInputElement | null>(null)
|
|
34
|
+
onMounted(() => {
|
|
35
|
+
window.addEventListener('keydown', (e) => {
|
|
36
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
searchRef.value?.focus()
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ─── User initials ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const userInitials = computed(() => {
|
|
46
|
+
const name = props.user?.name ?? props.user?.email ?? ''
|
|
47
|
+
return name.split(' ').slice(0, 2).map(p => p[0]).join('').toUpperCase() || 'U'
|
|
48
|
+
})
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<header class="sticky top-0 z-40 h-14 bg-card/95 backdrop-blur-sm border-b border-card-line flex items-center gap-4 px-6">
|
|
53
|
+
|
|
54
|
+
<!-- Left slot (breadcrumb, page title, etc.) -->
|
|
55
|
+
<div v-if="$slots.left" class="flex items-center gap-3 min-w-0 mr-auto">
|
|
56
|
+
<slot name="left" />
|
|
57
|
+
</div>
|
|
58
|
+
<div v-else class="mr-auto" />
|
|
59
|
+
|
|
60
|
+
<!-- Global search -->
|
|
61
|
+
<div v-if="showSearch" class="relative hidden sm:block w-72">
|
|
62
|
+
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
63
|
+
<svg class="size-4 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
64
|
+
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
65
|
+
</svg>
|
|
66
|
+
</div>
|
|
67
|
+
<input
|
|
68
|
+
ref="searchRef"
|
|
69
|
+
v-model="searchQuery"
|
|
70
|
+
type="search"
|
|
71
|
+
:placeholder="searchPlaceholder"
|
|
72
|
+
class="w-full h-9 pl-9 pr-12 text-sm bg-surface border border-card-line rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition"
|
|
73
|
+
@keydown.enter="handleSearch"
|
|
74
|
+
/>
|
|
75
|
+
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
|
76
|
+
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-muted-foreground bg-muted border border-card-line rounded">
|
|
77
|
+
<span class="text-[9px]">⌘</span>K
|
|
78
|
+
</kbd>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Right slot -->
|
|
83
|
+
<slot name="right" />
|
|
84
|
+
|
|
85
|
+
<!-- Notifications -->
|
|
86
|
+
<button
|
|
87
|
+
v-if="showNotifications"
|
|
88
|
+
type="button"
|
|
89
|
+
class="relative size-9 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
90
|
+
@click="emit('notification-click')"
|
|
91
|
+
>
|
|
92
|
+
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
93
|
+
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
94
|
+
</svg>
|
|
95
|
+
<span
|
|
96
|
+
v-if="notificationsCount > 0"
|
|
97
|
+
class="absolute top-1.5 right-1.5 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none"
|
|
98
|
+
>{{ notificationsCount > 99 ? '99+' : notificationsCount }}</span>
|
|
99
|
+
</button>
|
|
100
|
+
|
|
101
|
+
<!-- User -->
|
|
102
|
+
<div v-if="showUser && user" class="flex items-center gap-2.5 pl-2 border-l border-card-line">
|
|
103
|
+
<div class="size-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xs font-bold shrink-0 select-none">
|
|
104
|
+
{{ userInitials }}
|
|
105
|
+
</div>
|
|
106
|
+
<div class="hidden md:block min-w-0">
|
|
107
|
+
<p class="text-sm font-semibold text-foreground truncate leading-tight">{{ user.name ?? user.email }}</p>
|
|
108
|
+
<p v-if="user.role" class="text-xs text-muted-foreground truncate leading-tight">{{ user.role }}</p>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
</header>
|
|
113
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
modelValue: { type: Boolean, default: false },
|
|
4
|
+
title: { type: String, default: '' },
|
|
5
|
+
size: { type: String, default: 'md' },
|
|
6
|
+
loading: { type: Boolean, default: false },
|
|
7
|
+
closable: { type: Boolean, default: true },
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits(['update:modelValue', 'close'])
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<Modal
|
|
15
|
+
:model-value="modelValue"
|
|
16
|
+
:title="title"
|
|
17
|
+
:size="size"
|
|
18
|
+
:closable="closable && !loading"
|
|
19
|
+
:backdrop-dismiss="closable && !loading"
|
|
20
|
+
:show-footer="!!$slots.footer"
|
|
21
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
22
|
+
@close="emit('close')"
|
|
23
|
+
>
|
|
24
|
+
<slot />
|
|
25
|
+
<template v-if="$slots.footer" #footer>
|
|
26
|
+
<slot name="footer" />
|
|
27
|
+
</template>
|
|
28
|
+
</Modal>
|
|
29
|
+
</template>
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { IconSearch, IconAdjustmentsHorizontal, IconLayoutColumns, IconGripVertical, IconDownload, IconBookmark, IconChevronDown, IconX } from '@tabler/icons-vue'
|
|
3
|
+
|
|
4
|
+
// ─── Props ────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
// DataTable wiring
|
|
8
|
+
table: { type: Object, default: null },
|
|
9
|
+
endpoint: { type: String, default: '' },
|
|
10
|
+
endpointParams: { type: Object, default: () => ({}) },
|
|
11
|
+
columns: { type: Array, required: true },
|
|
12
|
+
name: { type: String, default: '' },
|
|
13
|
+
params: { type: Object, default: () => ({}) },
|
|
14
|
+
cached: { type: Boolean, default: true },
|
|
15
|
+
showReloadButton: { type: Boolean, default: true },
|
|
16
|
+
clickRowToOpen: { type: Boolean, default: false },
|
|
17
|
+
// Toolbar
|
|
18
|
+
searchPlaceholder: { type: String, default: 'Buscar...' },
|
|
19
|
+
showSearch: { type: Boolean, default: true },
|
|
20
|
+
showFilters: { type: Boolean, default: true },
|
|
21
|
+
showColumns: { type: Boolean, default: true },
|
|
22
|
+
showExport: { type: Boolean, default: true },
|
|
23
|
+
showSaveView: { type: Boolean, default: false },
|
|
24
|
+
// Checkboxes
|
|
25
|
+
checkable: { type: Boolean, default: true },
|
|
26
|
+
// Preview panel tabs: [{ key: 'resumen', label: 'Resumen' }, ...]
|
|
27
|
+
previewTabs: { type: Array, default: () => [] },
|
|
28
|
+
// Split ratio
|
|
29
|
+
splitRatio: { type: Number, default: 55 },
|
|
30
|
+
// Filters as chips in the filter bar: [{ key, label, options: [{value,label}] }]
|
|
31
|
+
filterChips: { type: Array, default: () => [] },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const resolvedEndpoint = computed(() => props.table?.endpoint ?? props.endpoint)
|
|
35
|
+
const resolvedName = computed(() => props.table?.name ?? props.name)
|
|
36
|
+
|
|
37
|
+
// ─── Emits / Slots ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits(['row-click', 'loaded', 'save-view'])
|
|
40
|
+
const slots = useSlots()
|
|
41
|
+
|
|
42
|
+
const excludedSlots = new Set(['toolbar', 'action', 'filter-bar', 'preview', 'preview-header'])
|
|
43
|
+
const forwardedSlots = computed(() =>
|
|
44
|
+
Object.fromEntries(Object.entries(slots).filter(([k]) => !excludedSlots.has(k)))
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// ─── Search & filters ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const search = ref('')
|
|
50
|
+
const activeFilters = ref({})
|
|
51
|
+
|
|
52
|
+
// Filter chips state: { [key]: selectedValue }
|
|
53
|
+
const chipValues = reactive(
|
|
54
|
+
Object.fromEntries((props.filterChips ?? []).map(f => [f.key, '']))
|
|
55
|
+
)
|
|
56
|
+
const openChip = ref(null)
|
|
57
|
+
|
|
58
|
+
const clearChips = () => {
|
|
59
|
+
for (const key of Object.keys(chipValues)) chipValues[key] = ''
|
|
60
|
+
}
|
|
61
|
+
const hasActiveChips = computed(() =>
|
|
62
|
+
Object.values(chipValues).some(v => v !== '' && v !== null && v !== undefined)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Advanced filter panel
|
|
66
|
+
const showFilterPanel = ref(false)
|
|
67
|
+
const filterPanelRef = ref(null)
|
|
68
|
+
|
|
69
|
+
const filtersConfig = computed(() =>
|
|
70
|
+
props.columns.filter(c => c.filterType)
|
|
71
|
+
)
|
|
72
|
+
const activeFilterCount = computed(() =>
|
|
73
|
+
Object.values(activeFilters.value).filter(v => v !== null && v !== undefined && v !== '').length
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const mergedParams = computed(() => ({
|
|
77
|
+
...props.params,
|
|
78
|
+
...props.endpointParams,
|
|
79
|
+
...activeFilters.value,
|
|
80
|
+
...Object.fromEntries(Object.entries(chipValues).filter(([, v]) => v !== '')),
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
// ─── Preview panel ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const previewRow = ref(null)
|
|
86
|
+
const currentRatio = ref(props.splitRatio)
|
|
87
|
+
const containerRef = ref(null)
|
|
88
|
+
const previewEnabled = ref(false)
|
|
89
|
+
const paginationHeight = ref(0)
|
|
90
|
+
const previewFromCache = ref(false)
|
|
91
|
+
|
|
92
|
+
const previewCacheKey = computed(() => `table-enterprise-preview-${resolvedName.value}`)
|
|
93
|
+
|
|
94
|
+
// Active preview tab
|
|
95
|
+
const firstTabKey = computed(() =>
|
|
96
|
+
props.previewTabs?.length ? props.previewTabs[0].key : 'datos'
|
|
97
|
+
)
|
|
98
|
+
const previewTab = ref(firstTabKey.value)
|
|
99
|
+
watch(previewRow, () => { previewTab.value = firstTabKey.value })
|
|
100
|
+
|
|
101
|
+
const closePreview = () => { previewRow.value = null }
|
|
102
|
+
|
|
103
|
+
const tableRef = ref(null)
|
|
104
|
+
const tableMeta = ref(null)
|
|
105
|
+
|
|
106
|
+
const handleRowClick = (row) => {
|
|
107
|
+
if (previewEnabled.value) {
|
|
108
|
+
previewRow.value = previewRow.value?.id === row.id ? null : row
|
|
109
|
+
} else {
|
|
110
|
+
emit('row-click', row)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const handleLoaded = (res) => {
|
|
115
|
+
emit('loaded', res)
|
|
116
|
+
if (res?.meta) tableMeta.value = res.meta
|
|
117
|
+
if (previewRow.value && Array.isArray(res?.data)) {
|
|
118
|
+
const fresh = res.data.find(r => r.id === previewRow.value.id)
|
|
119
|
+
if (fresh) previewRow.value = fresh
|
|
120
|
+
else closePreview()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Persist preview in session cache
|
|
125
|
+
watch(previewRow, (row) => {
|
|
126
|
+
if (!props.cached) return
|
|
127
|
+
if (row) sessionStorage.setItem(previewCacheKey.value, JSON.stringify(row))
|
|
128
|
+
else sessionStorage.removeItem(previewCacheKey.value)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Track pagination height
|
|
132
|
+
let paginationObserver = null
|
|
133
|
+
watch(() => tableRef.value?.paginationBarRef, (el) => {
|
|
134
|
+
paginationObserver?.disconnect()
|
|
135
|
+
paginationObserver = null
|
|
136
|
+
if (!el) return
|
|
137
|
+
paginationHeight.value = el.offsetHeight
|
|
138
|
+
paginationObserver = new ResizeObserver(() => { paginationHeight.value = el.offsetHeight })
|
|
139
|
+
paginationObserver.observe(el)
|
|
140
|
+
}, { flush: 'post' })
|
|
141
|
+
|
|
142
|
+
// Resize handle
|
|
143
|
+
const startResize = (e) => {
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
const onMove = (ev) => {
|
|
146
|
+
if (!containerRef.value) return
|
|
147
|
+
const rect = containerRef.value.getBoundingClientRect()
|
|
148
|
+
const ratio = ((ev.clientX - rect.left) / rect.width) * 100
|
|
149
|
+
currentRatio.value = Math.min(80, Math.max(25, ratio))
|
|
150
|
+
}
|
|
151
|
+
const onUp = () => {
|
|
152
|
+
window.removeEventListener('mousemove', onMove)
|
|
153
|
+
window.removeEventListener('mouseup', onUp)
|
|
154
|
+
}
|
|
155
|
+
window.addEventListener('mousemove', onMove)
|
|
156
|
+
window.addEventListener('mouseup', onUp)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Column panel ─────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const showColumnPanel = ref(false)
|
|
162
|
+
const columnPanelRef = ref(null)
|
|
163
|
+
const columnButtonRef = ref(null)
|
|
164
|
+
const columnPanelStyle = ref({})
|
|
165
|
+
|
|
166
|
+
const orderedColumns = computed(() => {
|
|
167
|
+
if (!tableRef.value) return props.columns.filter(c => c.label)
|
|
168
|
+
const ids = tableRef.value.table.getAllLeafColumns().map(c => c.id).filter(id => id !== 'select')
|
|
169
|
+
return ids.map(id => props.columns.find(c => c.key === id)).filter(c => c?.label)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
let draggedKey = null
|
|
173
|
+
const dragOverKey = ref(null)
|
|
174
|
+
const onDragStart = (key) => { draggedKey = key }
|
|
175
|
+
const onDragOver = (e, key) => { e.preventDefault(); dragOverKey.value = key }
|
|
176
|
+
const onDragLeave = () => { dragOverKey.value = null }
|
|
177
|
+
const onDrop = (key) => {
|
|
178
|
+
if (!draggedKey || draggedKey === key) return
|
|
179
|
+
const ids = tableRef.value?.table.getAllLeafColumns().map(c => c.id) ?? []
|
|
180
|
+
const from = ids.indexOf(draggedKey)
|
|
181
|
+
const to = ids.indexOf(key)
|
|
182
|
+
if (from < 0 || to < 0) return
|
|
183
|
+
ids.splice(from, 1); ids.splice(to, 0, draggedKey)
|
|
184
|
+
const selIdx = ids.indexOf('select')
|
|
185
|
+
if (selIdx > 0) { ids.splice(selIdx, 1); ids.unshift('select') }
|
|
186
|
+
tableRef.value?.setColumnOrder(ids)
|
|
187
|
+
draggedKey = null; dragOverKey.value = null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Outside-click helpers ────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
const onColumnOutsideClick = (e) => {
|
|
193
|
+
if (
|
|
194
|
+
columnPanelRef.value && !columnPanelRef.value.contains(e.target) &&
|
|
195
|
+
columnButtonRef.value && !columnButtonRef.value.contains(e.target)
|
|
196
|
+
) showColumnPanel.value = false
|
|
197
|
+
}
|
|
198
|
+
const onFilterOutsideClick = (e) => {
|
|
199
|
+
if (filterPanelRef.value && !filterPanelRef.value.contains(e.target)) showFilterPanel.value = false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
watch(showColumnPanel, async (v) => {
|
|
203
|
+
if (v) {
|
|
204
|
+
await nextTick()
|
|
205
|
+
const rect = columnButtonRef.value?.getBoundingClientRect()
|
|
206
|
+
if (rect) columnPanelStyle.value = { top: rect.bottom + 6 + 'px', right: window.innerWidth - rect.right + 'px' }
|
|
207
|
+
document.addEventListener('mousedown', onColumnOutsideClick)
|
|
208
|
+
} else {
|
|
209
|
+
document.removeEventListener('mousedown', onColumnOutsideClick)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
watch(showFilterPanel, (v) => {
|
|
213
|
+
if (v) document.addEventListener('mousedown', onFilterOutsideClick)
|
|
214
|
+
else document.removeEventListener('mousedown', onFilterOutsideClick)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const onEsc = (e) => { if (e.key === 'Escape' && previewRow.value) closePreview() }
|
|
220
|
+
onMounted(async () => {
|
|
221
|
+
previewEnabled.value = !!(slots.preview)
|
|
222
|
+
window.addEventListener('keydown', onEsc)
|
|
223
|
+
if (props.cached && previewEnabled.value) {
|
|
224
|
+
try {
|
|
225
|
+
const raw = sessionStorage.getItem(previewCacheKey.value)
|
|
226
|
+
if (raw) {
|
|
227
|
+
previewFromCache.value = true
|
|
228
|
+
previewRow.value = JSON.parse(raw)
|
|
229
|
+
await nextTick()
|
|
230
|
+
previewFromCache.value = false
|
|
231
|
+
}
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
onBeforeUnmount(() => {
|
|
236
|
+
window.removeEventListener('keydown', onEsc)
|
|
237
|
+
paginationObserver?.disconnect()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ─── Expose ───────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
const getSelectedRows = () => tableRef.value?.getSelectedRows()
|
|
243
|
+
const reload = () => tableRef.value?.reload()
|
|
244
|
+
const clearCache = () => tableRef.value?.clearCache()
|
|
245
|
+
const exportTable = (format, allPages, filteredRows) => tableRef.value?.exportTable(format, allPages, filteredRows)
|
|
246
|
+
defineExpose({ getSelectedRows, reload, clearCache, exportTable, tableRef, closePreview })
|
|
247
|
+
</script>
|
|
248
|
+
|
|
249
|
+
<template>
|
|
250
|
+
<div class="relative" ref="containerRef">
|
|
251
|
+
<div class="bg-card border border-card-line rounded-2xl shadow-sm overflow-hidden">
|
|
252
|
+
|
|
253
|
+
<!-- ── Toolbar ──────────────────────────────────────────────────────── -->
|
|
254
|
+
<div class="flex flex-wrap items-center gap-2 px-4 py-3 border-b border-card-line">
|
|
255
|
+
|
|
256
|
+
<!-- Search -->
|
|
257
|
+
<div v-if="showSearch" class="flex-1 min-w-48">
|
|
258
|
+
<Forms.Input v-model="search" type="search" :placeholder="searchPlaceholder" :icon-left="IconSearch" size="sm" />
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<!-- Advanced filters button -->
|
|
262
|
+
<button
|
|
263
|
+
v-if="showFilters && filtersConfig.length > 0"
|
|
264
|
+
type="button"
|
|
265
|
+
@click="showFilterPanel = !showFilterPanel"
|
|
266
|
+
:class="[
|
|
267
|
+
'py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border transition-colors',
|
|
268
|
+
showFilterPanel || activeFilterCount > 0
|
|
269
|
+
? 'border-primary/50 bg-primary/5 text-primary dark:bg-primary/10'
|
|
270
|
+
: 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
|
|
271
|
+
]"
|
|
272
|
+
>
|
|
273
|
+
<IconAdjustmentsHorizontal class="size-4" stroke="1.5" />
|
|
274
|
+
Filtros
|
|
275
|
+
<span v-if="activeFilterCount > 0" class="text-xs font-bold">({{ activeFilterCount }})</span>
|
|
276
|
+
<IconChevronDown class="size-3.5" stroke="2" />
|
|
277
|
+
</button>
|
|
278
|
+
|
|
279
|
+
<!-- Toolbar slot (custom filter dropdowns, etc.) -->
|
|
280
|
+
<slot name="toolbar" />
|
|
281
|
+
|
|
282
|
+
<!-- Spacer -->
|
|
283
|
+
<div class="flex-1" />
|
|
284
|
+
|
|
285
|
+
<!-- Save view -->
|
|
286
|
+
<button
|
|
287
|
+
v-if="showSaveView"
|
|
288
|
+
type="button"
|
|
289
|
+
class="py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover transition-colors"
|
|
290
|
+
@click="emit('save-view')"
|
|
291
|
+
>
|
|
292
|
+
<IconBookmark class="size-4" stroke="1.5" />
|
|
293
|
+
Guardar vista
|
|
294
|
+
</button>
|
|
295
|
+
|
|
296
|
+
<!-- Columns -->
|
|
297
|
+
<button
|
|
298
|
+
v-if="showColumns"
|
|
299
|
+
ref="columnButtonRef"
|
|
300
|
+
type="button"
|
|
301
|
+
@click="showColumnPanel = !showColumnPanel"
|
|
302
|
+
:class="[
|
|
303
|
+
'py-1.5 px-3 inline-flex items-center gap-1.5 text-sm font-medium rounded-lg border transition-colors',
|
|
304
|
+
showColumnPanel
|
|
305
|
+
? 'border-primary/50 bg-primary/5 text-primary dark:bg-primary/10'
|
|
306
|
+
: 'border-card-line bg-card text-muted-foreground-1 hover:bg-muted-hover',
|
|
307
|
+
]"
|
|
308
|
+
>
|
|
309
|
+
<IconLayoutColumns class="size-4" stroke="1.5" />
|
|
310
|
+
Columnas
|
|
311
|
+
</button>
|
|
312
|
+
|
|
313
|
+
<!-- Export -->
|
|
314
|
+
<TableExportable v-if="showExport" :table-ref="tableRef" :name="resolvedName" :columns="columns" />
|
|
315
|
+
|
|
316
|
+
<!-- Primary action slot -->
|
|
317
|
+
<slot name="action" />
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<!-- ── Filter bar (chips) ───────────────────────────────────────────── -->
|
|
321
|
+
<div
|
|
322
|
+
v-if="filterChips.length > 0 || $slots['filter-bar']"
|
|
323
|
+
class="flex flex-wrap items-center gap-2 px-4 py-2.5 border-b border-card-line bg-surface/40"
|
|
324
|
+
>
|
|
325
|
+
<!-- Built-in chips from filterChips prop -->
|
|
326
|
+
<template v-if="filterChips.length > 0">
|
|
327
|
+
<div
|
|
328
|
+
v-for="chip in filterChips"
|
|
329
|
+
:key="chip.key"
|
|
330
|
+
class="relative"
|
|
331
|
+
>
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
@click="openChip = openChip === chip.key ? null : chip.key"
|
|
335
|
+
:class="[
|
|
336
|
+
'inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-sm border transition-colors',
|
|
337
|
+
chipValues[chip.key]
|
|
338
|
+
? 'border-primary/50 bg-primary/5 text-primary font-medium dark:bg-primary/10'
|
|
339
|
+
: 'border-card-line bg-card text-muted-foreground hover:bg-muted-hover',
|
|
340
|
+
]"
|
|
341
|
+
>
|
|
342
|
+
<span class="text-muted-foreground">{{ chip.label }}:</span>
|
|
343
|
+
<span class="font-medium">
|
|
344
|
+
{{ chip.options?.find(o => o.value === chipValues[chip.key])?.label ?? 'Todos' }}
|
|
345
|
+
</span>
|
|
346
|
+
<IconChevronDown class="size-3" stroke="2" />
|
|
347
|
+
</button>
|
|
348
|
+
|
|
349
|
+
<!-- Chip dropdown -->
|
|
350
|
+
<Transition
|
|
351
|
+
enter-active-class="transition ease-out duration-100"
|
|
352
|
+
enter-from-class="opacity-0 scale-95"
|
|
353
|
+
enter-to-class="opacity-100 scale-100"
|
|
354
|
+
leave-active-class="transition ease-in duration-75"
|
|
355
|
+
leave-from-class="opacity-100 scale-100"
|
|
356
|
+
leave-to-class="opacity-0 scale-95"
|
|
357
|
+
>
|
|
358
|
+
<div
|
|
359
|
+
v-if="openChip === chip.key"
|
|
360
|
+
v-click-outside="() => openChip = null"
|
|
361
|
+
class="absolute top-full left-0 z-50 mt-1 bg-dropdown border border-dropdown-line rounded-xl shadow-xl py-1 min-w-40"
|
|
362
|
+
>
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted-hover transition-colors"
|
|
366
|
+
:class="chipValues[chip.key] === '' ? 'font-semibold text-foreground' : 'text-muted-foreground'"
|
|
367
|
+
@click="chipValues[chip.key] = ''; openChip = null"
|
|
368
|
+
>Todos</button>
|
|
369
|
+
<button
|
|
370
|
+
v-for="opt in chip.options"
|
|
371
|
+
:key="opt.value"
|
|
372
|
+
type="button"
|
|
373
|
+
class="w-full text-left px-3 py-1.5 text-sm hover:bg-muted-hover transition-colors"
|
|
374
|
+
:class="chipValues[chip.key] === opt.value ? 'font-semibold text-foreground' : 'text-muted-foreground'"
|
|
375
|
+
@click="chipValues[chip.key] = opt.value; openChip = null"
|
|
376
|
+
>{{ opt.label }}</button>
|
|
377
|
+
</div>
|
|
378
|
+
</Transition>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<button
|
|
382
|
+
v-if="hasActiveChips"
|
|
383
|
+
type="button"
|
|
384
|
+
class="inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
385
|
+
@click="clearChips"
|
|
386
|
+
>
|
|
387
|
+
<IconX class="size-3" stroke="2" />
|
|
388
|
+
Limpiar filtros
|
|
389
|
+
</button>
|
|
390
|
+
</template>
|
|
391
|
+
|
|
392
|
+
<!-- Custom filter bar slot -->
|
|
393
|
+
<slot name="filter-bar" />
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<!-- Advanced filter panel -->
|
|
397
|
+
<Transition
|
|
398
|
+
enter-active-class="transition ease-out duration-150"
|
|
399
|
+
enter-from-class="opacity-0 translate-y-1 scale-95"
|
|
400
|
+
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
401
|
+
leave-active-class="transition ease-in duration-100"
|
|
402
|
+
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
403
|
+
leave-to-class="opacity-0 translate-y-1 scale-95"
|
|
404
|
+
>
|
|
405
|
+
<div
|
|
406
|
+
v-if="showFilterPanel"
|
|
407
|
+
ref="filterPanelRef"
|
|
408
|
+
class="absolute top-14 left-4 z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 min-w-64 max-h-96 overflow-y-auto"
|
|
409
|
+
>
|
|
410
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-3 px-1">Filtros avanzados</p>
|
|
411
|
+
<TableFilter v-model="activeFilters" :columns="filtersConfig" />
|
|
412
|
+
</div>
|
|
413
|
+
</Transition>
|
|
414
|
+
|
|
415
|
+
<!-- ── Table + Preview ──────────────────────────────────────────────── -->
|
|
416
|
+
<div class="relative overflow-hidden">
|
|
417
|
+
|
|
418
|
+
<Table
|
|
419
|
+
ref="tableRef"
|
|
420
|
+
:endpoint="resolvedEndpoint"
|
|
421
|
+
:columns="columns"
|
|
422
|
+
:name="resolvedName"
|
|
423
|
+
:params="mergedParams"
|
|
424
|
+
:search="search"
|
|
425
|
+
:checkable="checkable"
|
|
426
|
+
:cached="cached"
|
|
427
|
+
:show-reload-button="showReloadButton"
|
|
428
|
+
:click-row-to-open="clickRowToOpen"
|
|
429
|
+
:preview-row-id="previewRow?.id ?? null"
|
|
430
|
+
:preview-mode="!!previewEnabled"
|
|
431
|
+
@row-click="handleRowClick"
|
|
432
|
+
@loaded="handleLoaded"
|
|
433
|
+
@page-change="closePreview"
|
|
434
|
+
@per-page-change="closePreview"
|
|
435
|
+
>
|
|
436
|
+
<template v-for="(_, sname) in forwardedSlots" #[sname]="slotProps">
|
|
437
|
+
<slot :name="sname" v-bind="slotProps ?? {}" />
|
|
438
|
+
</template>
|
|
439
|
+
</Table>
|
|
440
|
+
|
|
441
|
+
<!-- Preview overlay -->
|
|
442
|
+
<Transition
|
|
443
|
+
:enter-active-class="previewFromCache ? '' : 'transition ease-out duration-200'"
|
|
444
|
+
:enter-from-class="previewFromCache ? '' : 'opacity-0 translate-x-6'"
|
|
445
|
+
:enter-to-class="previewFromCache ? '' : 'opacity-100 translate-x-0'"
|
|
446
|
+
leave-active-class="transition ease-in duration-150"
|
|
447
|
+
leave-from-class="opacity-100 translate-x-0"
|
|
448
|
+
leave-to-class="opacity-0 translate-x-6"
|
|
449
|
+
>
|
|
450
|
+
<div
|
|
451
|
+
v-if="previewRow && previewEnabled"
|
|
452
|
+
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)]"
|
|
453
|
+
:style="{ width: (100 - currentRatio) + '%', bottom: paginationHeight + 'px' }"
|
|
454
|
+
>
|
|
455
|
+
<!-- Resize handle -->
|
|
456
|
+
<div
|
|
457
|
+
class="w-1 shrink-0 cursor-col-resize bg-surface hover:bg-primary/40 transition-colors"
|
|
458
|
+
@mousedown="startResize"
|
|
459
|
+
/>
|
|
460
|
+
|
|
461
|
+
<div class="flex flex-col flex-1 overflow-hidden">
|
|
462
|
+
|
|
463
|
+
<!-- Fixed header -->
|
|
464
|
+
<div v-if="$slots['preview-header']" class="shrink-0 border-b border-card-line">
|
|
465
|
+
<slot name="preview-header" :row="previewRow" :close="closePreview" />
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<!-- Tabs (top) — only if previewTabs provided -->
|
|
469
|
+
<div
|
|
470
|
+
v-if="previewTabs && previewTabs.length > 1"
|
|
471
|
+
class="shrink-0 flex border-b border-card-line overflow-x-auto"
|
|
472
|
+
>
|
|
473
|
+
<button
|
|
474
|
+
v-for="tab in previewTabs"
|
|
475
|
+
:key="tab.key"
|
|
476
|
+
type="button"
|
|
477
|
+
@click="previewTab = tab.key"
|
|
478
|
+
:class="[
|
|
479
|
+
'px-4 py-2.5 text-xs font-semibold whitespace-nowrap transition-colors border-b-2 -mb-px',
|
|
480
|
+
previewTab === tab.key
|
|
481
|
+
? 'border-primary text-primary'
|
|
482
|
+
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted-hover',
|
|
483
|
+
]"
|
|
484
|
+
>{{ tab.label }}</button>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<!-- Scrollable preview content -->
|
|
488
|
+
<div class="flex-1 overflow-y-auto min-h-0">
|
|
489
|
+
<slot name="preview" :row="previewRow" :tab="previewTab" :close="closePreview" />
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</Transition>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<!-- Column panel (teleported) -->
|
|
499
|
+
<Teleport to="body">
|
|
500
|
+
<Transition
|
|
501
|
+
enter-active-class="transition ease-out duration-150"
|
|
502
|
+
enter-from-class="opacity-0 translate-y-1 scale-95"
|
|
503
|
+
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
504
|
+
leave-active-class="transition ease-in duration-100"
|
|
505
|
+
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
506
|
+
leave-to-class="opacity-0 translate-y-1 scale-95"
|
|
507
|
+
>
|
|
508
|
+
<div
|
|
509
|
+
v-if="showColumnPanel"
|
|
510
|
+
ref="columnPanelRef"
|
|
511
|
+
class="fixed z-50 bg-dropdown border border-dropdown-line rounded-xl shadow-2xl p-3 min-w-56 max-h-80 overflow-y-auto"
|
|
512
|
+
:style="columnPanelStyle"
|
|
513
|
+
>
|
|
514
|
+
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2 px-1">Columnas visibles</p>
|
|
515
|
+
<div
|
|
516
|
+
v-for="col in orderedColumns"
|
|
517
|
+
:key="col.key"
|
|
518
|
+
draggable="true"
|
|
519
|
+
@dragstart="onDragStart(col.key)"
|
|
520
|
+
@dragover="(e) => onDragOver(e, col.key)"
|
|
521
|
+
@dragleave="onDragLeave"
|
|
522
|
+
@drop="onDrop(col.key)"
|
|
523
|
+
class="flex items-center gap-2 py-1.5 px-2 rounded-lg select-none transition-colors"
|
|
524
|
+
:class="dragOverKey === col.key ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted-hover cursor-grab'"
|
|
525
|
+
>
|
|
526
|
+
<IconGripVertical class="size-4 text-muted-foreground-2 shrink-0" />
|
|
527
|
+
<input
|
|
528
|
+
type="checkbox"
|
|
529
|
+
:checked="tableRef?.table.getColumn(col.key)?.getIsVisible() ?? true"
|
|
530
|
+
@change="tableRef?.table.getColumn(col.key)?.toggleVisibility()"
|
|
531
|
+
@click.stop
|
|
532
|
+
class="rounded border-card-line bg-surface shrink-0 cursor-pointer"
|
|
533
|
+
/>
|
|
534
|
+
<span class="text-sm text-foreground truncate">{{ col.label }}</span>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
</Transition>
|
|
538
|
+
</Teleport>
|
|
539
|
+
</div>
|
|
540
|
+
</template>
|
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.135",
|
|
4
4
|
"description": "Innertia Solutions — Spark theme: backoffice, landing and mobile components and layouts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nuxt",
|
|
@@ -43,4 +43,4 @@
|
|
|
43
43
|
"nuxt": "^4.4.2",
|
|
44
44
|
"vue": "^3.5.0"
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
}
|