ui?ux mejorada, comparativa cosecha-cosecha
This commit is contained in:
@@ -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',
|
||||
|
||||
319
nuxt4-app/app/components/comparativa/CosechasEvolucion.vue
Normal file
319
nuxt4-app/app/components/comparativa/CosechasEvolucion.vue
Normal 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>
|
||||
296
nuxt4-app/app/components/comparativa/CosechasPorTipo.vue
Normal file
296
nuxt4-app/app/components/comparativa/CosechasPorTipo.vue
Normal 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>
|
||||
263
nuxt4-app/app/components/comparativa/CosechasTotales.vue
Normal file
263
nuxt4-app/app/components/comparativa/CosechasTotales.vue
Normal 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>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
176
nuxt4-app/app/pages/comparativa-cosechas.vue
Normal file
176
nuxt4-app/app/pages/comparativa-cosechas.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user