All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 45s
La sidebar ahora detecta el tamaño de pantalla al inicializar: - En móvil (< 1024px): cerrada por defecto - En desktop (>= 1024px): abierta por defecto Esto mejora la experiencia en dispositivos móviles evitando que la sidebar tape el contenido al cargar la página.
353 lines
12 KiB
Vue
353 lines
12 KiB
Vue
<template>
|
|
<div class="brand-shell min-h-screen text-[#fef9f0]">
|
|
<UDashboardGroup storage-key="analytics-dashboard" class="h-full">
|
|
<AppSidebar v-model:open="sidebarOpen" v-model:collapsed="sidebarCollapsed" />
|
|
|
|
<UDashboardPanel class="bg-transparent">
|
|
<template #header>
|
|
<div class="flex flex-col px-4 py-4 lg:px-6">
|
|
<UDashboardNavbar :title="pageTitle" icon="i-lucide-file-bar-chart" toggle-side="left">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse variant="subtle" />
|
|
</template>
|
|
|
|
<template #toggle>
|
|
<UDashboardSidebarToggle variant="subtle" />
|
|
</template>
|
|
|
|
<template #trailing>
|
|
<!-- Botones de acciones rápidas con transiciones -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-300 ease-out"
|
|
enter-from-class="opacity-0 -translate-x-4"
|
|
enter-to-class="opacity-100 translate-x-0"
|
|
leave-active-class="transition-all duration-200 ease-in"
|
|
leave-from-class="opacity-100 translate-x-0"
|
|
leave-to-class="opacity-0 translate-x-4"
|
|
>
|
|
<div v-show="showActions" class="flex items-center gap-2">
|
|
<!-- Resumen compacto de resultados y filtros activos -->
|
|
<div class="flex items-center gap-3">
|
|
<!-- Contadores -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-300 ease-out"
|
|
enter-from-class="opacity-0 -translate-x-4 scale-95"
|
|
enter-to-class="opacity-100 translate-x-0 scale-100"
|
|
leave-active-class="transition-all duration-200 ease-in"
|
|
leave-from-class="opacity-100 translate-x-0 scale-100"
|
|
leave-to-class="opacity-0 translate-x-4 scale-95"
|
|
>
|
|
<div v-if="Object.keys(filteredResults).length > 0" class="flex items-center gap-2">
|
|
<div class="h-4 w-px bg-[var(--brand-border)]"></div>
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<div v-for="(count, name) in filteredResults" :key="name" class="flex items-center gap-1">
|
|
<span class="text-[var(--brand-text-muted)]">{{ name }}:</span>
|
|
<span class="font-semibold text-[var(--brand-primary)]">{{ count }}</span>
|
|
<span class="text-[var(--brand-text-muted)]">/</span>
|
|
<span class="font-medium text-[var(--brand-text-muted)]">{{ datasourceCounts[name] || 0 }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Filtros activos -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-300 ease-out"
|
|
enter-from-class="opacity-0 -translate-x-4 scale-95"
|
|
enter-to-class="opacity-100 translate-x-0 scale-100"
|
|
leave-active-class="transition-all duration-200 ease-in"
|
|
leave-from-class="opacity-100 translate-x-0 scale-100"
|
|
leave-to-class="opacity-0 translate-x-4 scale-95"
|
|
>
|
|
<div v-if="activeFilters.length > 0" class="flex items-center gap-2">
|
|
<div class="h-4 w-px bg-[var(--brand-border)]"></div>
|
|
<div class="flex items-center gap-1.5 flex-wrap max-w-xl">
|
|
<UBadge
|
|
v-for="(filter, index) in activeFilters"
|
|
:key="index"
|
|
color="primary"
|
|
variant="soft"
|
|
size="xs"
|
|
class="cursor-pointer hover:bg-[var(--brand-primary)]/20 transition-colors"
|
|
@click="filter.onRemove"
|
|
>
|
|
<div class="flex items-center gap-1">
|
|
<span class="text-[10px] font-medium">{{ filter.label }}</span>
|
|
<UIcon name="i-lucide-x" class="w-2.5 h-2.5" />
|
|
</div>
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
</div>
|
|
</template>
|
|
|
|
<template #body>
|
|
<div
|
|
class="px-4 pb-10 lg:px-8"
|
|
@contextmenu="handleContextMenu"
|
|
@click="closeContextMenu"
|
|
>
|
|
<!-- Context Menu -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-200 ease-out"
|
|
enter-from-class="opacity-0 scale-95"
|
|
enter-to-class="opacity-100 scale-100"
|
|
leave-active-class="transition-all duration-150 ease-in"
|
|
leave-from-class="opacity-100 scale-100"
|
|
leave-to-class="opacity-0 scale-95"
|
|
>
|
|
<div
|
|
v-if="contextMenuVisible"
|
|
:style="{ top: `${contextMenuY}px`, left: `${contextMenuX}px` }"
|
|
class="fixed z-50 w-64 brand-card p-3 shadow-2xl border border-[var(--brand-border)]"
|
|
@click.stop
|
|
>
|
|
<div class="flex items-center justify-between mb-2 pb-2 border-b border-[var(--brand-border)]">
|
|
<span class="text-xs font-semibold text-[var(--brand-text)]">Visibilidad de Secciones</span>
|
|
<UButton
|
|
icon="i-lucide-x"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
@click="closeContextMenu"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2">
|
|
<UCheckbox
|
|
v-model="pageSections.totalesCafe"
|
|
label="Totales por Café"
|
|
size="xs"
|
|
/>
|
|
<UCheckbox
|
|
v-model="pageSections.totalesVerde"
|
|
label="Totales Netos de Verde"
|
|
size="xs"
|
|
/>
|
|
<UCheckbox
|
|
v-model="pageSections.tablaIngresos"
|
|
label="Tabla de Ingresos"
|
|
size="xs"
|
|
/>
|
|
<UCheckbox
|
|
v-model="pageSections.top10Clientes"
|
|
label="Top 10 Clientes"
|
|
size="xs"
|
|
/>
|
|
<UCheckbox
|
|
v-model="pageSections.graficas"
|
|
label="Todas las Gráficas"
|
|
size="xs"
|
|
/>
|
|
|
|
<div class="flex gap-2 pt-2 border-t border-[var(--brand-border)]">
|
|
<UButton size="xs" variant="soft" @click="toggleAllSections(true)" block>
|
|
Mostrar todo
|
|
</UButton>
|
|
<UButton size="xs" variant="soft" color="neutral" @click="toggleAllSections(false)" block>
|
|
Ocultar todo
|
|
</UButton>
|
|
</div>
|
|
|
|
<UButton size="xs" variant="soft" color="gray" @click="resetToDefaults" block>
|
|
<template #leading>
|
|
<UIcon name="i-lucide-rotate-ccw" class="w-3 h-3" />
|
|
</template>
|
|
Restaurar
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<slot />
|
|
</div>
|
|
</template>
|
|
</UDashboardPanel>
|
|
</UDashboardGroup>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
|
|
const route = useRoute()
|
|
|
|
// Detectar si es móvil en el primer montaje
|
|
const isMobile = () => {
|
|
if (import.meta.client) {
|
|
return window.innerWidth < 1024 // lg breakpoint
|
|
}
|
|
return false
|
|
}
|
|
|
|
const sidebarOpen = ref(!isMobile()) // Cerrada en móvil, abierta en desktop
|
|
const sidebarCollapsed = ref(false)
|
|
|
|
const showActions = ref(true)
|
|
|
|
interface FiltrosResumen {
|
|
count: number
|
|
summary: string
|
|
results: number
|
|
}
|
|
|
|
interface DatasourceCounts {
|
|
[key: string]: number
|
|
}
|
|
|
|
interface ActiveFilter {
|
|
type: string
|
|
label: string
|
|
value: any
|
|
onRemove: () => void
|
|
}
|
|
|
|
interface PageSections {
|
|
totalesCafe: boolean
|
|
totalesVerde: boolean
|
|
tablaIngresos: boolean
|
|
top10Clientes: boolean
|
|
graficas: boolean
|
|
}
|
|
|
|
// Estado compartido para filtros y metadatos
|
|
const filtrosResumen = ref<FiltrosResumen | null>(null)
|
|
const datasourceCounts = ref<DatasourceCounts>({})
|
|
const filteredResults = ref<DatasourceCounts>({})
|
|
const activeFilters = ref<ActiveFilter[]>([])
|
|
const metadatosNeedUpdate = ref(false)
|
|
|
|
// Estado colapsado de secciones (false = visible por defecto)
|
|
const filtrosCollapsed = ref(false)
|
|
const metadatosCollapsed = ref(false)
|
|
|
|
// Cookie para recordar preferencias de vista
|
|
const pageSectionsCookie = useCookie<PageSections>('informe-page-sections', {
|
|
default: () => ({
|
|
totalesCafe: true,
|
|
totalesVerde: true,
|
|
tablaIngresos: true,
|
|
top10Clientes: true,
|
|
graficas: true
|
|
}),
|
|
maxAge: 60 * 60 * 24 * 365, // 1 año
|
|
sameSite: 'lax'
|
|
})
|
|
|
|
// Estado de visibilidad de secciones de página (sincronizado con cookie)
|
|
const pageSections = ref<PageSections>(pageSectionsCookie.value)
|
|
|
|
// Watch para guardar en cookie cada vez que cambie
|
|
watch(pageSections, (newValue) => {
|
|
pageSectionsCookie.value = newValue
|
|
}, { deep: true })
|
|
|
|
// Estado del context menu
|
|
const contextMenuVisible = ref(false)
|
|
const contextMenuX = ref(0)
|
|
const contextMenuY = ref(0)
|
|
|
|
// Computed para el total de registros en datasources
|
|
const totalDatasourceRecords = computed(() => {
|
|
return Object.values(datasourceCounts.value).reduce((sum, count) => sum + count, 0)
|
|
})
|
|
|
|
// Provide para que las páginas puedan actualizar estos valores
|
|
provide('setFiltrosResumen', (resumen: FiltrosResumen | null) => {
|
|
filtrosResumen.value = resumen
|
|
})
|
|
|
|
provide('setDatasourceCounts', (counts: DatasourceCounts) => {
|
|
datasourceCounts.value = counts
|
|
})
|
|
|
|
provide('setFilteredResults', (results: DatasourceCounts) => {
|
|
filteredResults.value = results
|
|
})
|
|
|
|
provide('setActiveFilters', (filters: ActiveFilter[]) => {
|
|
activeFilters.value = filters
|
|
})
|
|
|
|
provide('setMetadatosNeedUpdate', (needsUpdate: boolean) => {
|
|
metadatosNeedUpdate.value = needsUpdate
|
|
})
|
|
|
|
// Provide estado colapsado para que las páginas lo lean
|
|
provide('filtrosCollapsed', filtrosCollapsed)
|
|
provide('metadatosCollapsed', metadatosCollapsed)
|
|
provide('pageSections', pageSections)
|
|
|
|
const pageTitle = computed(() => (route.meta.title as string) || 'Informe')
|
|
|
|
function toggleFiltros() {
|
|
filtrosCollapsed.value = !filtrosCollapsed.value
|
|
}
|
|
|
|
function toggleMetadatos() {
|
|
metadatosCollapsed.value = !metadatosCollapsed.value
|
|
}
|
|
|
|
function handleContextMenu(event: MouseEvent) {
|
|
event.preventDefault()
|
|
contextMenuX.value = event.clientX
|
|
contextMenuY.value = event.clientY
|
|
contextMenuVisible.value = true
|
|
}
|
|
|
|
function closeContextMenu() {
|
|
contextMenuVisible.value = false
|
|
}
|
|
|
|
function toggleAllSections(value: boolean) {
|
|
pageSections.value.totalesCafe = value
|
|
pageSections.value.totalesVerde = value
|
|
pageSections.value.tablaIngresos = value
|
|
pageSections.value.top10Clientes = value
|
|
pageSections.value.graficas = value
|
|
}
|
|
|
|
function resetToDefaults() {
|
|
pageSections.value = {
|
|
totalesCafe: true,
|
|
totalesVerde: true,
|
|
tablaIngresos: true,
|
|
top10Clientes: true,
|
|
graficas: true
|
|
}
|
|
}
|
|
|
|
// Cerrar context menu con Escape
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (contextMenuVisible.value) {
|
|
closeContextMenu()
|
|
} else if (showConfigPanel.value) {
|
|
showConfigPanel.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mostrar acciones después de montar
|
|
onMounted(() => {
|
|
setTimeout(() => {
|
|
showActions.value = true
|
|
}, 100)
|
|
|
|
// Agregar listener para escape
|
|
window.addEventListener('keydown', handleEscape)
|
|
})
|
|
|
|
// Cleanup
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', handleEscape)
|
|
})
|
|
</script>
|