Files
analiticaNucleo/nuxt4-app/app/layouts/informe.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 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()
const sidebarOpen = ref(true)
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>