All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 51s
Problema identificado: - Páginas con layout 'informe' mostraban icono inconsistente - Páginas con layout 'dashboard' funcionaban correctamente - Layout 'informe' seguía usando refs locales obsoletas Causa raíz: El layout 'informe' no fue actualizado en el refactor inicial: - Línea 4: <AppSidebar v-model:open="sidebarOpen" v-model:collapsed="sidebarCollapsed" /> - Líneas 190-191: Refs locales que sobrescriben el composable - Las props v-model forzaban estado local en lugar de usar singleton Análisis por layout: ✅ Funcionaban (layout: dashboard): - index, explorer, rawExplorer, metadatos, notifications, settings ❌ No funcionaban (layout: informe): - panorama, informe-ingresos, comparativa-cosechas, metabase-debug Solución aplicada: 1. Eliminar v-model:open y v-model:collapsed de <AppSidebar /> 2. Remover refs locales sidebarOpen y sidebarCollapsed 3. Remover función isMobile() duplicada 4. Usar useSidebarState() como única fuente de verdad Cambios: - app/layouts/informe.vue:4 - Remover v-models de AppSidebar - app/layouts/informe.vue:183 - Usar useSidebarState() composable - app/layouts/informe.vue:190-191 - Eliminar refs locales Resultado: ✓ Icono consistente en TODAS las páginas ✓ Ambos layouts usan la misma arquitectura ✓ Estado completamente unificado ✓ Sin refs locales que sobrescriban el singleton Referencias: - app/layouts/informe.vue:4 - app/layouts/informe.vue:183
345 lines
12 KiB
Vue
345 lines
12 KiB
Vue
<template>
|
|
<div class="brand-shell min-h-screen text-[#fef9f0]">
|
|
<UDashboardGroup storage-key="analytics-dashboard" class="h-full">
|
|
<AppSidebar />
|
|
|
|
<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()
|
|
|
|
// Usar el composable unificado para el estado de la sidebar
|
|
const sidebarState = useSidebarState()
|
|
|
|
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>
|