ui?ux mejorada, comparativa cosecha-cosecha

This commit is contained in:
2025-10-01 03:51:18 -06:00
parent bf370de372
commit 9bd96e6d69
8 changed files with 1353 additions and 60 deletions

View File

@@ -101,6 +101,12 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
to: '/informe-ingresos',
active: route.path === '/informe-ingresos'
},
{
label: 'Comparativa Cosechas',
icon: 'i-lucide-calendar-range',
to: '/comparativa-cosechas',
active: route.path === '/comparativa-cosechas'
},
{
label: 'Explorador de datos',
icon: 'i-lucide-table',

View File

@@ -0,0 +1,319 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-trending-up" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Evolución Temporal Comparada</h3>
</div>
<div class="flex items-center gap-3">
<label class="text-xs text-[var(--brand-text-muted)]">Métrica:</label>
<div class="flex gap-2">
<button
@click="metrica = 'acumulado'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'acumulado'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Acumulado
</button>
<button
@click="metrica = 'diario'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'diario'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Diario
</button>
</div>
<label class="text-xs text-[var(--brand-text-muted)] ml-4">Tipo:</label>
<div class="flex gap-2">
<button
@click="tipoSeleccionado = 'todos'"
class="px-3 py-1 rounded text-xs transition-all"
:class="tipoSeleccionado === 'todos'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Todos
</button>
<button
v-for="tipo in ['uva', 'verde', 'oreado', 'mojado']"
:key="tipo"
@click="tipoSeleccionado = tipo"
class="px-3 py-1 rounded text-xs transition-all capitalize"
:class="tipoSeleccionado === tipo
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
{{ tipo }}
</button>
</div>
</div>
</div>
</template>
<div class="w-full">
<svg :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="w-full h-96">
<!-- Grid horizontal -->
<g v-for="i in 5" :key="`grid-${i}`">
<line
:x1="padding.left"
:y1="padding.top + (chartHeight / 5) * i"
:x2="svgWidth - padding.right"
:y2="padding.top + (chartHeight / 5) * i"
stroke="var(--brand-border)"
stroke-width="0.5"
opacity="0.3"
/>
<text
:x="padding.left - 10"
:y="padding.top + (chartHeight / 5) * i + 5"
text-anchor="end"
class="text-xs fill-[var(--brand-text-muted)]"
>
{{ formatValue(maxValue * (1 - i / 5)) }}
</text>
</g>
<!-- Eje X -->
<line
:x1="padding.left"
:y1="svgHeight - padding.bottom"
:x2="svgWidth - padding.right"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Eje Y -->
<line
:x1="padding.left"
:y1="padding.top"
:x2="padding.left"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Líneas de evolución -->
<g v-for="(cosecha, index) in evolucionCosechas" :key="cosecha.id">
<path
:d="getLinePath(cosecha.puntos)"
:stroke="getCosechaColor(index)"
stroke-width="2"
fill="none"
class="transition-all duration-300"
/>
<!-- Puntos -->
<circle
v-for="(punto, pIndex) in cosecha.puntos.filter((_, i) => i % Math.ceil(cosecha.puntos.length / 30) === 0)"
:key="`${cosecha.id}-${pIndex}`"
:cx="getX(punto.diaRelativo)"
:cy="getY(punto.valor)"
r="3"
:fill="getCosechaColor(index)"
class="transition-all duration-300 hover:r-5"
>
<title>{{ cosecha.label }} - Día {{ punto.diaRelativo }}: {{ punto.valor.toFixed(1) }} qq</title>
</circle>
</g>
<!-- Etiquetas eje X -->
<g v-for="i in 6" :key="`x-label-${i}`">
<text
:x="padding.left + (chartWidth / 5) * (i - 1)"
:y="svgHeight - padding.bottom + 20"
text-anchor="middle"
class="text-xs fill-[var(--brand-text-muted)]"
>
Día {{ Math.floor(maxDias * (i - 1) / 5) }}
</text>
</g>
<!-- Leyenda -->
<g v-for="(cosecha, index) in evolucionCosechas" :key="`legend-${cosecha.id}`">
<line
:x1="svgWidth - padding.right - 150"
:y1="20 + index * 20"
:x2="svgWidth - padding.right - 130"
:y2="20 + index * 20"
:stroke="getCosechaColor(index)"
stroke-width="2"
/>
<text
:x="svgWidth - padding.right - 125"
:y="24 + index * 20"
class="text-xs fill-[var(--brand-text)]"
>
{{ cosecha.label }}
</text>
</g>
<!-- Etiqueta eje Y -->
<text
:x="20"
:y="padding.top - 10"
class="text-xs fill-[var(--brand-text-muted)]"
>
Peso (qq)
</text>
</svg>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
interface Props {
ingresos: IngresoRecord[]
cosechasSeleccionadas: string[]
}
const props = defineProps<Props>()
// Obtener definiciones de cosechas
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
// Dimensiones del SVG
const svgWidth = 900
const svgHeight = 450
const padding = { top: 40, right: 180, bottom: 50, left: 80 }
const chartWidth = svgWidth - padding.left - padding.right
const chartHeight = svgHeight - padding.top - padding.bottom
// Selección de métrica y tipo
const metrica = ref<'acumulado' | 'diario'>('acumulado')
const tipoSeleccionado = ref<string>('todos')
// Calcular evolución por cosecha
const evolucionCosechas = computed(() => {
return props.cosechasSeleccionadas.map(cosechaId => {
const cosechaDef = cosechasDisponibles.find(c => c.id === cosechaId)
if (!cosechaDef) return null
const ingresosEnCosecha = props.ingresos.filter(ingreso => {
if (!ingreso.created_at) return false
const fecha = new Date(ingreso.created_at)
const inicio = new Date(cosechaDef.fechaInicio)
const fin = new Date(cosechaDef.fechaFin)
// Filtrar por tipo si no es "todos"
const cumpleTipo = tipoSeleccionado.value === 'todos' || ingreso.tipo === tipoSeleccionado.value
return fecha >= inicio && fecha <= fin && cumpleTipo
})
// Ordenar por fecha
const ingresosOrdenados = ingresosEnCosecha.sort((a, b) => {
return new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime()
})
// Calcular día relativo para cada ingreso
const fechaInicio = new Date(cosechaDef.fechaInicio)
const puntosPorDia: Record<number, number> = {}
ingresosOrdenados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!)
const diaRelativo = Math.floor((fecha.getTime() - fechaInicio.getTime()) / (1000 * 60 * 60 * 24))
if (!puntosPorDia[diaRelativo]) {
puntosPorDia[diaRelativo] = 0
}
puntosPorDia[diaRelativo] += ingreso.peso_neto
})
// Convertir a array de puntos
const puntos: Array<{ diaRelativo: number, valor: number }> = []
let acumulado = 0
const maxDia = Math.max(...Object.keys(puntosPorDia).map(Number))
for (let dia = 0; dia <= maxDia; dia++) {
const valorDia = puntosPorDia[dia] || 0
if (metrica.value === 'acumulado') {
acumulado += valorDia
puntos.push({ diaRelativo: dia, valor: acumulado })
} else {
puntos.push({ diaRelativo: dia, valor: valorDia })
}
}
return {
id: cosechaId,
label: cosechaDef.label.replace('Cosecha ', ''),
puntos
}
}).filter(Boolean) as Array<{
id: string
label: string
puntos: Array<{ diaRelativo: number, valor: number }>
}>
})
// Máximos para escala
const maxDias = computed(() => {
let max = 0
evolucionCosechas.value.forEach(cosecha => {
cosecha.puntos.forEach(punto => {
if (punto.diaRelativo > max) {
max = punto.diaRelativo
}
})
})
return max || 365
})
const maxValue = computed(() => {
let max = 0
evolucionCosechas.value.forEach(cosecha => {
cosecha.puntos.forEach(punto => {
if (punto.valor > max) {
max = punto.valor
}
})
})
return max || 1
})
// Funciones de mapeo
function getX(diaRelativo: number): number {
return padding.left + (diaRelativo / maxDias.value) * chartWidth
}
function getY(valor: number): number {
const normalized = valor / maxValue.value
return padding.top + chartHeight * (1 - normalized)
}
function getLinePath(puntos: Array<{ diaRelativo: number, valor: number }>): string {
if (puntos.length === 0) return ''
const path = puntos.map((punto, index) => {
const x = getX(punto.diaRelativo)
const y = getY(punto.valor)
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`
}).join(' ')
return path
}
// Colores para las cosechas
const cosechaColors = ['#c08040', '#d99a56', '#f0c07c', '#8b6f47', '#a0826e', '#b89968']
function getCosechaColor(index: number): string {
return cosechaColors[index % cosechaColors.length]
}
function formatValue(value: number): string {
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`
}
return value.toFixed(0)
}
</script>

View File

@@ -0,0 +1,296 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-layers" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Comparativa por Tipo de Café</h3>
</div>
</template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Peso por Tipo -->
<div class="flex flex-col gap-2">
<h4 class="text-sm font-medium text-[var(--brand-text-muted)]">Peso Total por Tipo (qq)</h4>
<svg :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="w-full h-80">
<!-- Eje Y -->
<line
:x1="padding.left"
:y1="padding.top"
:x2="padding.left"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Eje X -->
<line
:x1="padding.left"
:y1="svgHeight - padding.bottom"
:x2="svgWidth - padding.right"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Barras agrupadas -->
<g v-for="(tipo, tipoIndex) in tipos" :key="tipo">
<g v-for="(cosecha, cosechaIndex) in datosCosechas" :key="cosecha.id">
<rect
:x="getGroupedBarX(tipoIndex, cosechaIndex)"
:y="getBarY(cosecha.pesosPorTipo[tipo] || 0, maxPesoPorTipo)"
:width="groupedBarWidth"
:height="getBarHeight(cosecha.pesosPorTipo[tipo] || 0, maxPesoPorTipo)"
:fill="getCosechaColor(cosechaIndex)"
:opacity="0.8"
class="transition-all duration-300 hover:opacity-100"
>
<title>{{ cosecha.label }}: {{ (cosecha.pesosPorTipo[tipo] || 0).toFixed(1) }} qq</title>
</rect>
<!-- Valor sobre la barra (solo si es significativo) -->
<text
v-if="(cosecha.pesosPorTipo[tipo] || 0) > maxPesoPorTipo * 0.05"
:x="getGroupedBarX(tipoIndex, cosechaIndex) + groupedBarWidth / 2"
:y="getBarY(cosecha.pesosPorTipo[tipo] || 0, maxPesoPorTipo) - 5"
text-anchor="middle"
class="text-xs fill-[var(--brand-text)]"
>
{{ (cosecha.pesosPorTipo[tipo] || 0).toFixed(0) }}
</text>
</g>
<!-- Etiqueta del tipo -->
<text
:x="getGroupX(tipoIndex)"
:y="svgHeight - padding.bottom + 20"
text-anchor="middle"
class="text-sm font-medium fill-[var(--brand-text)]"
>
{{ tipo.charAt(0).toUpperCase() + tipo.slice(1) }}
</text>
</g>
<!-- Leyenda -->
<g v-for="(cosecha, index) in datosCosechas" :key="cosecha.id">
<rect
:x="padding.left + index * 100"
:y="10"
:width="12"
:height="12"
:fill="getCosechaColor(index)"
/>
<text
:x="padding.left + index * 100 + 18"
:y="20"
class="text-xs fill-[var(--brand-text-muted)]"
>
{{ cosecha.label }}
</text>
</g>
</svg>
</div>
<!-- Cantidad por Tipo -->
<div class="flex flex-col gap-2">
<h4 class="text-sm font-medium text-[var(--brand-text-muted)]">Cantidad de Ingresos por Tipo</h4>
<svg :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="w-full h-80">
<!-- Eje Y -->
<line
:x1="padding.left"
:y1="padding.top"
:x2="padding.left"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Eje X -->
<line
:x1="padding.left"
:y1="svgHeight - padding.bottom"
:x2="svgWidth - padding.right"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Barras agrupadas -->
<g v-for="(tipo, tipoIndex) in tipos" :key="tipo">
<g v-for="(cosecha, cosechaIndex) in datosCosechas" :key="cosecha.id">
<rect
:x="getGroupedBarX(tipoIndex, cosechaIndex)"
:y="getBarY(cosecha.cantidadPorTipo[tipo] || 0, maxCantidadPorTipo)"
:width="groupedBarWidth"
:height="getBarHeight(cosecha.cantidadPorTipo[tipo] || 0, maxCantidadPorTipo)"
:fill="getCosechaColor(cosechaIndex)"
:opacity="0.8"
class="transition-all duration-300 hover:opacity-100"
>
<title>{{ cosecha.label }}: {{ cosecha.cantidadPorTipo[tipo] || 0 }} ingresos</title>
</rect>
<!-- Valor sobre la barra -->
<text
v-if="(cosecha.cantidadPorTipo[tipo] || 0) > 0"
:x="getGroupedBarX(tipoIndex, cosechaIndex) + groupedBarWidth / 2"
:y="getBarY(cosecha.cantidadPorTipo[tipo] || 0, maxCantidadPorTipo) - 5"
text-anchor="middle"
class="text-xs fill-[var(--brand-text)]"
>
{{ cosecha.cantidadPorTipo[tipo] || 0 }}
</text>
</g>
<!-- Etiqueta del tipo -->
<text
:x="getGroupX(tipoIndex)"
:y="svgHeight - padding.bottom + 20"
text-anchor="middle"
class="text-sm font-medium fill-[var(--brand-text)]"
>
{{ tipo.charAt(0).toUpperCase() + tipo.slice(1) }}
</text>
</g>
<!-- Leyenda -->
<g v-for="(cosecha, index) in datosCosechas" :key="cosecha.id">
<rect
:x="padding.left + index * 100"
:y="10"
:width="12"
:height="12"
:fill="getCosechaColor(index)"
/>
<text
:x="padding.left + index * 100 + 18"
:y="20"
class="text-xs fill-[var(--brand-text-muted)]"
>
{{ cosecha.label }}
</text>
</g>
</svg>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
interface Props {
ingresos: IngresoRecord[]
cosechasSeleccionadas: string[]
}
const props = defineProps<Props>()
// Obtener definiciones de cosechas
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
// Dimensiones del SVG
const svgWidth = 500
const svgHeight = 350
const padding = { top: 40, right: 20, bottom: 40, left: 60 }
// Tipos de café
const tipos = ['uva', 'verde', 'oreado', 'mojado']
// Calcular datos por cosecha y tipo
const datosCosechas = computed(() => {
return props.cosechasSeleccionadas.map(cosechaId => {
const cosechaDef = cosechasDisponibles.find(c => c.id === cosechaId)
if (!cosechaDef) return null
const ingresosEnCosecha = props.ingresos.filter(ingreso => {
if (!ingreso.created_at) return false
const fecha = new Date(ingreso.created_at)
const inicio = new Date(cosechaDef.fechaInicio)
const fin = new Date(cosechaDef.fechaFin)
return fecha >= inicio && fecha <= fin
})
const pesosPorTipo: Record<string, number> = {}
const cantidadPorTipo: Record<string, number> = {}
tipos.forEach(tipo => {
const ingresosTipo = ingresosEnCosecha.filter(ing => ing.tipo === tipo)
pesosPorTipo[tipo] = ingresosTipo.reduce((sum, ing) => sum + ing.peso_neto, 0)
cantidadPorTipo[tipo] = ingresosTipo.length
})
return {
id: cosechaId,
label: cosechaDef.label.replace('Cosecha ', ''),
pesosPorTipo,
cantidadPorTipo
}
}).filter(Boolean) as Array<{
id: string
label: string
pesosPorTipo: Record<string, number>
cantidadPorTipo: Record<string, number>
}>
})
// Máximos para escala
const maxPesoPorTipo = computed(() => {
let max = 0
datosCosechas.value.forEach(cosecha => {
tipos.forEach(tipo => {
if (cosecha.pesosPorTipo[tipo] > max) {
max = cosecha.pesosPorTipo[tipo]
}
})
})
return max || 1
})
const maxCantidadPorTipo = computed(() => {
let max = 0
datosCosechas.value.forEach(cosecha => {
tipos.forEach(tipo => {
if (cosecha.cantidadPorTipo[tipo] > max) {
max = cosecha.cantidadPorTipo[tipo]
}
})
})
return max || 1
})
// Dimensiones de barras agrupadas
const chartWidth = computed(() => svgWidth - padding.left - padding.right)
const chartHeight = svgHeight - padding.top - padding.bottom
const groupWidth = computed(() => chartWidth.value / tipos.length)
const groupedBarWidth = computed(() => {
const count = datosCosechas.value.length
return count > 0 ? (groupWidth.value * 0.8) / count : 0
})
function getGroupX(tipoIndex: number): number {
return padding.left + tipoIndex * groupWidth.value + groupWidth.value / 2
}
function getGroupedBarX(tipoIndex: number, cosechaIndex: number): number {
const groupStart = padding.left + tipoIndex * groupWidth.value
const groupPadding = groupWidth.value * 0.1
return groupStart + groupPadding + cosechaIndex * groupedBarWidth.value
}
function getBarY(value: number, max: number): number {
const normalized = value / max
return padding.top + chartHeight * (1 - normalized)
}
function getBarHeight(value: number, max: number): number {
return (value / max) * chartHeight
}
// Colores para las cosechas
const cosechaColors = ['#c08040', '#d99a56', '#f0c07c', '#8b6f47', '#a0826e', '#b89968']
function getCosechaColor(index: number): string {
return cosechaColors[index % cosechaColors.length]
}
</script>

View File

@@ -0,0 +1,263 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-bar-chart-3" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Totales por Cosecha</h3>
</div>
</template>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Gráfico de Peso Total -->
<div class="flex flex-col gap-2">
<h4 class="text-sm font-medium text-[var(--brand-text-muted)]">Peso Total (qq)</h4>
<svg :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="w-full h-64">
<!-- Eje Y -->
<line
:x1="padding.left"
:y1="padding.top"
:x2="padding.left"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Barras de peso -->
<g v-for="(cosecha, index) in datosCosechas" :key="cosecha.id">
<rect
:x="padding.left + index * barWidth + index * barGap"
:y="getBarY(cosecha.pesoTotal, maxPeso)"
:width="barWidth"
:height="getBarHeight(cosecha.pesoTotal, maxPeso)"
:fill="getColor(index)"
:opacity="0.8"
class="transition-all duration-300 hover:opacity-100"
/>
<!-- Valor sobre la barra -->
<text
:x="padding.left + index * barWidth + index * barGap + barWidth / 2"
:y="getBarY(cosecha.pesoTotal, maxPeso) - 5"
text-anchor="middle"
class="text-xs fill-[var(--brand-text)]"
>
{{ cosecha.pesoTotal.toLocaleString('es-HN', { maximumFractionDigits: 0 }) }}
</text>
<!-- Etiqueta -->
<text
:x="padding.left + index * barWidth + index * barGap + barWidth / 2"
:y="svgHeight - padding.bottom + 20"
text-anchor="middle"
class="text-xs fill-[var(--brand-text-muted)]"
>
{{ cosecha.label }}
</text>
</g>
</svg>
</div>
<!-- Gráfico de Inversión Total -->
<div class="flex flex-col gap-2">
<h4 class="text-sm font-medium text-[var(--brand-text-muted)]">Inversión Total (L)</h4>
<svg :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="w-full h-64">
<!-- Eje Y -->
<line
:x1="padding.left"
:y1="padding.top"
:x2="padding.left"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Barras de inversión -->
<g v-for="(cosecha, index) in datosCosechas" :key="cosecha.id">
<rect
:x="padding.left + index * barWidth + index * barGap"
:y="getBarY(cosecha.inversionTotal, maxInversion)"
:width="barWidth"
:height="getBarHeight(cosecha.inversionTotal, maxInversion)"
:fill="getColor(index)"
:opacity="0.8"
class="transition-all duration-300 hover:opacity-100"
/>
<!-- Valor sobre la barra -->
<text
:x="padding.left + index * barWidth + index * barGap + barWidth / 2"
:y="getBarY(cosecha.inversionTotal, maxInversion) - 5"
text-anchor="middle"
class="text-xs fill-[var(--brand-text)]"
>
{{ formatMoney(cosecha.inversionTotal) }}
</text>
<!-- Etiqueta -->
<text
:x="padding.left + index * barWidth + index * barGap + barWidth / 2"
:y="svgHeight - padding.bottom + 20"
text-anchor="middle"
class="text-xs fill-[var(--brand-text-muted)]"
>
{{ cosecha.label }}
</text>
</g>
</svg>
</div>
<!-- Gráfico de Cantidad de Ingresos -->
<div class="flex flex-col gap-2">
<h4 class="text-sm font-medium text-[var(--brand-text-muted)]">Cantidad de Ingresos</h4>
<svg :viewBox="`0 0 ${svgWidth} ${svgHeight}`" class="w-full h-64">
<!-- Eje Y -->
<line
:x1="padding.left"
:y1="padding.top"
:x2="padding.left"
:y2="svgHeight - padding.bottom"
stroke="var(--brand-border)"
stroke-width="1"
/>
<!-- Barras de cantidad -->
<g v-for="(cosecha, index) in datosCosechas" :key="cosecha.id">
<rect
:x="padding.left + index * barWidth + index * barGap"
:y="getBarY(cosecha.cantidad, maxCantidad)"
:width="barWidth"
:height="getBarHeight(cosecha.cantidad, maxCantidad)"
:fill="getColor(index)"
:opacity="0.8"
class="transition-all duration-300 hover:opacity-100"
/>
<!-- Valor sobre la barra -->
<text
:x="padding.left + index * barWidth + index * barGap + barWidth / 2"
:y="getBarY(cosecha.cantidad, maxCantidad) - 5"
text-anchor="middle"
class="text-xs fill-[var(--brand-text)]"
>
{{ cosecha.cantidad }}
</text>
<!-- Etiqueta -->
<text
:x="padding.left + index * barWidth + index * barGap + barWidth / 2"
:y="svgHeight - padding.bottom + 20"
text-anchor="middle"
class="text-xs fill-[var(--brand-text-muted)]"
>
{{ cosecha.label }}
</text>
</g>
</svg>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
interface Props {
ingresos: IngresoRecord[]
cosechasSeleccionadas: string[]
}
const props = defineProps<Props>()
// Obtener definiciones de cosechas
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
// Dimensiones del SVG
const svgWidth = 400
const svgHeight = 300
const padding = { top: 20, right: 20, bottom: 40, left: 40 }
// Calcular datos por cosecha
const datosCosechas = computed(() => {
return props.cosechasSeleccionadas.map(cosechaId => {
const cosechaDef = cosechasDisponibles.find(c => c.id === cosechaId)
if (!cosechaDef) return null
const ingresosEnCosecha = props.ingresos.filter(ingreso => {
if (!ingreso.created_at) return false
const fecha = new Date(ingreso.created_at)
const inicio = new Date(cosechaDef.fechaInicio)
const fin = new Date(cosechaDef.fechaFin)
return fecha >= inicio && fecha <= fin
})
const pesoTotal = ingresosEnCosecha.reduce((sum, ing) => sum + ing.peso_neto, 0)
const inversionTotal = ingresosEnCosecha.reduce((sum, ing) => {
let inversion = 0
if (ing.tipo === 'uva' || ing.tipo === 'verde') {
inversion = ing.precio * ing.peso_neto
} else if (ing.tipo === 'oreado' || ing.tipo === 'mojado') {
inversion = (ing.precio / 2) * (ing.peso_seco || 0)
}
return sum + inversion
}, 0)
return {
id: cosechaId,
label: cosechaDef.label.replace('Cosecha ', ''),
pesoTotal,
inversionTotal,
cantidad: ingresosEnCosecha.length
}
}).filter(Boolean) as Array<{
id: string
label: string
pesoTotal: number
inversionTotal: number
cantidad: number
}>
})
// Máximos para escala
const maxPeso = computed(() => Math.max(...datosCosechas.value.map(c => c.pesoTotal), 1))
const maxInversion = computed(() => Math.max(...datosCosechas.value.map(c => c.inversionTotal), 1))
const maxCantidad = computed(() => Math.max(...datosCosechas.value.map(c => c.cantidad), 1))
// Dimensiones de barras
const chartWidth = computed(() => svgWidth - padding.left - padding.right)
const barWidth = computed(() => {
const count = datosCosechas.value.length
return count > 0 ? (chartWidth.value - (count - 1) * 10) / count : 0
})
const barGap = 10
// Función para obtener Y de la barra
function getBarY(value: number, max: number): number {
const chartHeight = svgHeight - padding.top - padding.bottom
const normalized = value / max
return padding.top + chartHeight * (1 - normalized)
}
// Función para obtener altura de la barra
function getBarHeight(value: number, max: number): number {
const chartHeight = svgHeight - padding.top - padding.bottom
return (value / max) * chartHeight
}
// Colores para las barras
const colors = ['#c08040', '#d99a56', '#f0c07c', '#8b6f47', '#a0826e', '#b89968']
function getColor(index: number): string {
return colors[index % colors.length]
}
function formatMoney(value: number): string {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`
}
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}K`
}
return value.toFixed(0)
}
</script>

View File

@@ -17,13 +17,23 @@ export interface ActiveFilter {
onRemove: () => void
}
export interface PageSections {
totalesCafe: boolean
totalesVerde: boolean
tablaIngresos: boolean
top10Clientes: boolean
graficas: boolean
}
export function useInformeLayout() {
const setFiltrosResumenFn = inject<(resumen: FiltrosResumen | null) => void>('setFiltrosResumen')
const setDatasourceCountsFn = inject<(counts: DatasourceCounts) => void>('setDatasourceCounts')
const setFilteredResultsFn = inject<(results: DatasourceCounts) => void>('setFilteredResults')
const setActiveFiltersFn = inject<(filters: ActiveFilter[]) => void>('setActiveFilters')
const setMetadatosNeedUpdateFn = inject<(needsUpdate: boolean) => void>('setMetadatosNeedUpdate')
const filtrosCollapsedRef = inject<Ref<boolean>>('filtrosCollapsed')
const metadatosCollapsedRef = inject<Ref<boolean>>('metadatosCollapsed')
const pageSectionsRef = inject<Ref<PageSections>>('pageSections')
function setFiltrosResumen(count: number, summary: string, results: number) {
if (setFiltrosResumenFn) {
@@ -55,13 +65,27 @@ export function useInformeLayout() {
}
}
function setMetadatosNeedUpdate(needsUpdate: boolean) {
if (setMetadatosNeedUpdateFn) {
setMetadatosNeedUpdateFn(needsUpdate)
}
}
return {
setFiltrosResumen,
clearFiltrosResumen,
setDatasourceCounts,
setFilteredResults,
setActiveFilters,
setMetadatosNeedUpdate,
filtrosCollapsed: filtrosCollapsedRef || ref(false),
metadatosCollapsed: metadatosCollapsedRef || ref(false)
metadatosCollapsed: metadatosCollapsedRef || ref(false),
pageSections: pageSectionsRef || ref({
totalesCafe: true,
totalesVerde: true,
tablaIngresos: true,
top10Clientes: true,
graficas: true
})
}
}

View File

@@ -5,7 +5,7 @@
<UDashboardPanel class="bg-transparent">
<template #header>
<div class="flex flex-col gap-4 px-4 py-4 lg:px-6">
<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" />
@@ -26,50 +26,6 @@
leave-to-class="opacity-0 translate-x-4"
>
<div v-show="showActions" class="flex items-center gap-2">
<!-- Botón de Filtros -->
<UButton
color="neutral"
variant="subtle"
:icon="filtrosCollapsed ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
:label="filtrosResumen ? undefined : 'Filtros'"
@click="toggleFiltros"
class="relative"
>
<template v-if="filtrosResumen" #leading>
<UIcon name="i-lucide-filter" />
</template>
<template v-if="filtrosResumen" #trailing>
<UBadge
:label="filtrosResumen.count.toString()"
color="primary"
size="xs"
variant="subtle"
/>
</template>
</UButton>
<!-- Botón de Metadatos -->
<UButton
color="neutral"
variant="subtle"
:icon="metadatosCollapsed ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
@click="toggleMetadatos"
class="relative"
>
<template #leading>
<UIcon name="i-lucide-database" />
</template>
<template #trailing>
<UBadge
v-if="totalDatasourceRecords > 0"
:label="totalDatasourceRecords.toString()"
color="primary"
size="xs"
variant="subtle"
/>
</template>
</UButton>
<!-- Resumen compacto de resultados y filtros activos -->
<div class="flex items-center gap-3">
<!-- Contadores -->
@@ -132,7 +88,83 @@
</template>
<template #body>
<div class="px-4 pb-10 lg:px-8">
<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>
@@ -169,15 +201,50 @@ interface ActiveFilter {
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
const filtrosCollapsed = ref(true)
const metadatosCollapsed = ref(true)
// 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(() => {
@@ -201,9 +268,14 @@ 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')
@@ -215,10 +287,58 @@ 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>

View File

@@ -0,0 +1,176 @@
<template>
<div class="flex flex-col">
<!-- Toolbar con selector de cosechas -->
<UDashboardToolbar>
<template #left>
<div class="flex items-center gap-4">
<span class="text-sm text-[var(--brand-text-muted)]">Seleccionar cosechas a comparar:</span>
</div>
</template>
<template #right>
<div class="flex items-center gap-3">
<USwitch
v-model="pageSections.totales"
size="xs"
label="Totales"
/>
<USwitch
v-model="pageSections.evolucion"
size="xs"
label="Evolución"
/>
<USwitch
v-model="pageSections.porTipo"
size="xs"
label="Por Tipo"
/>
</div>
</template>
</UDashboardToolbar>
<div class="flex flex-col gap-8 p-6">
<!-- Loading State -->
<UCard v-if="loading && !ingresosStore.hasData" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
<div class="flex items-center gap-3">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
</div>
</div>
</UCard>
<!-- Error State -->
<div v-else-if="error" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
<p>Error al cargar datos: {{ error }}</p>
</div>
<!-- Main Content -->
<template v-else>
<!-- Selector de Cosechas -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-calendar-range" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Cosechas a Comparar</h3>
</div>
</template>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<label
v-for="cosecha in cosechasDisponibles"
:key="cosecha.id"
class="flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-all"
:class="cosechasSeleccionadas.includes(cosecha.id)
? 'border-[#c08040] bg-[#c08040]/10'
: 'border-[var(--brand-border)] hover:border-[#c08040]/50'"
>
<input
type="checkbox"
:value="cosecha.id"
v-model="cosechasSeleccionadas"
class="rounded border-[var(--brand-border)] text-[#c08040] focus:ring-[#c08040]"
/>
<div class="flex flex-col">
<span class="text-sm font-medium text-[var(--brand-text)]">{{ cosecha.label }}</span>
<span class="text-xs text-[var(--brand-text-muted)]">{{ cosecha.periodo }}</span>
</div>
</label>
</div>
</UCard>
<!-- Resumen General por Cosecha -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.totales">
<ComparativaCosechasTotales :ingresos="ingresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Comparativa por Tipo de Café -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.porTipo">
<ComparativaCosechasPorTipo :ingresos="ingresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Evolución Temporal Comparada -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.evolucion">
<ComparativaCosechasEvolucion :ingresos="ingresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Empty State -->
<UCard v-if="cosechasSeleccionadas.length === 0" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-16 text-[var(--brand-text-muted)]">
<UIcon name="i-lucide-bar-chart-2" class="size-16 opacity-50" />
<p class="text-sm">Selecciona al menos una cosecha para ver las comparativas</p>
</div>
</UCard>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { useTableDataStore } from '~/stores/tableDataFactory'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
definePageMeta({
layout: 'informe',
title: 'Comparativa Cosechas'
})
// Definir secciones específicas de esta página
const pageSections = ref({
totales: true,
evolucion: true,
porTipo: true
})
// Definición de cosechas disponibles
const cosechasDisponibles = [
{ id: 'cosecha-20-21', label: 'Cosecha 20-21', periodo: 'Sep 2020 - Sep 2021', fechaInicio: '2020-09-25', fechaFin: '2021-09-24' },
{ id: 'cosecha-21-22', label: 'Cosecha 21-22', periodo: 'Sep 2021 - Sep 2022', fechaInicio: '2021-09-25', fechaFin: '2022-09-24' },
{ id: 'cosecha-22-23', label: 'Cosecha 22-23', periodo: 'Sep 2022 - Sep 2023', fechaInicio: '2022-09-25', fechaFin: '2023-09-24' },
{ id: 'cosecha-23-24', label: 'Cosecha 23-24', periodo: 'Sep 2023 - Sep 2024', fechaInicio: '2023-09-25', fechaFin: '2024-09-24' },
{ id: 'cosecha-24-25', label: 'Cosecha 24-25', periodo: 'Sep 2024 - Sep 2025', fechaInicio: '2024-09-25', fechaFin: '2025-09-09' },
{ id: 'cosecha-25-26', label: 'Cosecha 25-26', periodo: 'Sep 2025 - Hoy', fechaInicio: '2025-09-10', fechaFin: new Date().toISOString().split('T')[0] }
]
// Cosechas seleccionadas (por defecto las 3 más recientes)
const cosechasSeleccionadas = ref<string[]>(['cosecha-23-24', 'cosecha-24-25'])
// Store de ingresos
const ingresosStore = useTableDataStore('ingresos')
const loading = ref(false)
const error = ref<string | null>(null)
// Cargar datos
onMounted(async () => {
try {
loading.value = true
error.value = null
await ingresosStore.fetch()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Error desconocido'
} finally {
loading.value = false
}
})
// Datos de ingresos
const ingresos = computed(() => {
return ingresosStore.data.map(row => {
const ingreso: IngresoRecord = {
id: row.id as number,
created_at: row.created_at as string,
peso_neto: row.peso_neto as number,
precio: row.precio as number,
tipo: row.tipo as string,
cliente: row.cliente as string,
peso_seco: row.peso_seco as number | undefined,
pagado: row.pagado as boolean | undefined
}
return ingreso
})
})
// Exportar cosechas para los componentes
provide('cosechasDisponibles', cosechasDisponibles)
</script>

View File

@@ -1,6 +1,58 @@
<template>
<div class="flex flex-col gap-8 p-6">
<div class="flex flex-col">
<!-- Toolbar con switches -->
<UDashboardToolbar>
<template #left>
<div class="flex items-center gap-4">
<USwitch
v-model="showFiltros"
checked-icon="i-lucide-eye"
unchecked-icon="i-lucide-eye-off"
label="Filtros"
size="sm"
/>
<USwitch
v-model="showMetadatos"
checked-icon="i-lucide-eye"
unchecked-icon="i-lucide-eye-off"
label="Metadatos"
size="sm"
/>
</div>
</template>
<template #right>
<div class="flex items-center gap-3">
<USwitch
v-model="pageSections.totalesCafe"
size="xs"
label="Totales"
/>
<USwitch
v-model="pageSections.totalesVerde"
size="xs"
label="Verde"
/>
<USwitch
v-model="pageSections.tablaIngresos"
size="xs"
label="Tabla"
/>
<USwitch
v-model="pageSections.top10Clientes"
size="xs"
label="Top 10"
/>
<USwitch
v-model="pageSections.graficas"
size="xs"
label="Gráficas"
/>
</div>
</template>
</UDashboardToolbar>
<div class="flex flex-col gap-8 p-6">
<!-- Loading State -->
<UCard v-if="loading && !ingresosStore.hasData" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
@@ -94,7 +146,7 @@
</div>
<!-- Totales por Café -->
<UCard class="brand-card border border-transparent">
<UCard v-show="pageSections.totalesCafe" class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
@@ -122,7 +174,7 @@
</UCard>
<!-- Totales Netos de Verde -->
<IngresosTotalesVerde v-if="hasVerdeData" :metrics="ingresosMetrics" />
<IngresosTotalesVerde v-if="hasVerdeData" v-show="pageSections.totalesVerde" :metrics="ingresosMetrics" />
<!-- Backdrop para fullscreen -->
<Transition
@@ -142,6 +194,7 @@
<!-- Vista Tabla según tab activo -->
<UCard
v-show="pageSections.tablaIngresos"
:class="[
'brand-card border border-transparent transition-all duration-300',
isTableFullscreen ? 'fixed inset-4 z-50 rounded-lg overflow-auto' : ''
@@ -273,10 +326,10 @@
</UCard>
<!-- Top 10 Clientes -->
<IngresosTopClientes :ingresos="ingresosFiltrados" :clientes="clientesFiltrados" />
<IngresosTopClientes v-show="pageSections.top10Clientes" :ingresos="ingresosFiltrados" :clientes="clientesFiltrados" />
<!-- Sección de Gráficas: Acumuladores -->
<div class="space-y-6 w-full">
<div v-show="pageSections.graficas" class="space-y-6 w-full">
<div class="flex items-center gap-3 mb-4">
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--brand-border)] to-transparent"></div>
<h2 class="text-2xl font-bold brand-section-title">Acumuladores en el Tiempo</h2>
@@ -295,7 +348,7 @@
</div>
<!-- Sección de Gráficas: Series Temporales -->
<div class="space-y-6 w-full">
<div v-show="pageSections.graficas" class="space-y-6 w-full">
<div class="flex items-center gap-3 mb-4">
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--brand-border)] to-transparent"></div>
<h2 class="text-2xl font-bold brand-section-title">Series Temporales</h2>
@@ -312,7 +365,8 @@
<IngresosGraficaSerieInversion :ingresos="ingresosFiltrados" />
</div>
</div>
</template>
</template>
</div>
</div>
</template>
@@ -328,8 +382,19 @@ definePageMeta({
title: 'Informe Ingresos'
})
// Obtener estado colapsado desde el layout
const { filtrosCollapsed, metadatosCollapsed, setFiltrosResumen, setDatasourceCounts, setFilteredResults, setActiveFilters } = useInformeLayout()
// Obtener estado colapsado y visibilidad de secciones desde el layout
const { filtrosCollapsed, metadatosCollapsed, pageSections, setFiltrosResumen, setDatasourceCounts, setFilteredResults, setActiveFilters, setMetadatosNeedUpdate } = useInformeLayout()
// Computed properties invertidos para switches (ON = visible, OFF = oculto)
const showFiltros = computed({
get: () => !filtrosCollapsed.value,
set: (value) => { filtrosCollapsed.value = !value }
})
const showMetadatos = computed({
get: () => !metadatosCollapsed.value,
set: (value) => { metadatosCollapsed.value = !value }
})
// View modes with explicit hierarchy
type ViewMode = 'ingresos-only' | 'clientes-only' | 'ingresos-clientes' | 'clientes-ingresos'
@@ -750,6 +815,30 @@ const clientesMetadata = computed(() => {
return meta ? { ...meta, name: 'clientes' } : null
})
// Detectar si los metadatos necesitan actualización
const metadatosNeedUpdate = computed(() => {
// Comparar count de ingresos
if (ingresosMetadata.value && ingresosMetadata.value.count) {
if (ingresos.value.length !== ingresosMetadata.value.count) {
return true
}
}
// Comparar count de clientes
if (clientesMetadata.value && clientesMetadata.value.count) {
if (clientes.value.length !== clientesMetadata.value.count) {
return true
}
}
return false
})
// Actualizar el estado de warning en el layout
watch(metadatosNeedUpdate, (needsUpdate) => {
setMetadatosNeedUpdate(needsUpdate)
}, { immediate: true })
// Load data on mount
onMounted(async () => {
try {