sper mejoras de UI

This commit is contained in:
2025-10-01 05:39:26 -06:00
parent d4f6333812
commit c4719d95cc
8 changed files with 1151 additions and 209 deletions

View File

@@ -180,6 +180,11 @@ const props = defineProps<Props>()
// Obtener definiciones de cosechas
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
// Obtener configuración de estilos
const estilosGraficas = inject<any>('estilosGraficas', ref({
coloresCosechas: ['#c08040', '#d99a56', '#8b6f47', '#a0826e', '#b89968', '#f0c07c']
}))
// Dimensiones del SVG
const svgWidth = 900
const svgHeight = 450
@@ -303,11 +308,35 @@ function getLinePath(puntos: Array<{ diaRelativo: number, valor: number }>): str
return path
}
// Colores para las cosechas
const cosechaColors = ['#c08040', '#d99a56', '#f0c07c', '#8b6f47', '#a0826e', '#b89968']
// Mapeo de cosechas a índices fijos
const cosechaColorMap: Record<string, number> = {
'cosecha-20-21': 0,
'cosecha-21-22': 1,
'cosecha-22-23': 2,
'cosecha-23-24': 3,
'cosecha-24-25': 4,
'cosecha-25-26': 5
}
function getCosechaColor(index: number): string {
return cosechaColors[index % cosechaColors.length]
// Función para obtener color fijo de cosecha según su ID (no su orden de selección)
function getCosechaColor(cosechaIdOrIndex: string | number): string {
const colores = estilosGraficas.value.coloresCosechas
// Si es un string, es un ID de cosecha
if (typeof cosechaIdOrIndex === 'string') {
const index = cosechaColorMap[cosechaIdOrIndex] ?? 0
return colores[index % colores.length]
}
// Si es un número, buscar el ID de la cosecha en evolucionCosechas
const cosecha = evolucionCosechas.value[cosechaIdOrIndex]
if (cosecha?.id) {
const index = cosechaColorMap[cosecha.id] ?? cosechaIdOrIndex
return colores[index % colores.length]
}
// Fallback al índice directo
return colores[cosechaIdOrIndex % colores.length]
}
function formatValue(value: number): string {

View File

@@ -8,18 +8,56 @@
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-calendar-heat" class="size-5 text-[#c08040]" />
<UIcon name="i-lucide-activity" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Vista de Calor por Día</h3>
</div>
<UButton
@click="toggleFullscreen"
size="xs"
color="neutral"
variant="ghost"
:icon="isFullscreen ? 'i-lucide-minimize' : 'i-lucide-maximize'"
>
{{ isFullscreen ? 'Salir' : 'Pantalla completa' }}
</UButton>
<div class="flex items-center gap-2">
<!-- Controles de Zoom -->
<div
class="flex items-center gap-1 px-2 py-1 rounded-lg border border-[var(--brand-border)] bg-[var(--brand-bg-secondary)]"
title="También puedes usar Ctrl+Rueda del mouse para zoom"
>
<UButton
@click="zoomOut"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-zoom-out"
:disabled="nivelZoom <= 0.5"
title="Alejar (Ctrl+Rueda abajo)"
/>
<span class="text-xs text-[var(--brand-text-muted)] px-2 min-w-[3rem] text-center">
{{ (nivelZoom * 100).toFixed(0) }}%
</span>
<UButton
@click="zoomIn"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-zoom-in"
:disabled="nivelZoom >= 2"
title="Acercar (Ctrl+Rueda arriba)"
/>
<div class="w-px h-4 bg-[var(--brand-border)] mx-1" />
<UButton
@click="resetZoom"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-refresh-cw"
title="Restablecer zoom (100%)"
/>
</div>
<UButton
@click="toggleFullscreen"
size="xs"
color="neutral"
variant="ghost"
:icon="isFullscreen ? 'i-lucide-minimize' : 'i-lucide-maximize'"
>
{{ isFullscreen ? 'Salir' : 'Pantalla completa' }}
</UButton>
</div>
</div>
<div class="flex items-center gap-3 flex-wrap">
<label class="text-xs text-[var(--brand-text-muted)]">Vista:</label>
@@ -230,7 +268,7 @@
class="flex flex-col items-center"
:style="{ width: `${barMaxWidth}px` }"
>
<div class="text-xs font-medium mb-1" :style="{ color: getCosechaColor(index) }">
<div class="text-xs font-medium mb-1" :style="{ color: getCosechaColor(cosecha.id) }">
{{ cosecha.label }}
</div>
<div class="text-xs text-[var(--brand-text-muted)]">
@@ -291,7 +329,7 @@
:style="{
width: getBarWidth(cosecha.valoresPorDia[dia - 1] || 0),
height: `${barHeight - 2}px`,
backgroundColor: getCosechaColor(cosechaIndex)
backgroundColor: getCosechaColor(cosecha.id)
}"
/>
</div>
@@ -353,7 +391,7 @@
>
<div
class="w-4 h-3 rounded"
:style="{ backgroundColor: getCosechaColor(index) }"
:style="{ backgroundColor: getCosechaColor(cosecha.id) }"
/>
<span class="text-xs text-[var(--brand-text-muted)]">{{ cosecha.label }}</span>
</div>
@@ -383,12 +421,12 @@
</span>
<div
class="w-3 h-3 rounded-full"
:style="{ backgroundColor: getCosechaColor(index) }"
:style="{ backgroundColor: getCosechaColor(cosecha.id) }"
/>
</div>
<!-- Valor total -->
<div class="text-2xl font-bold" :style="{ color: getCosechaColor(index) }">
<div class="text-2xl font-bold" :style="{ color: getCosechaColor(cosecha.id) }">
{{ formatTotal(cosecha.total) }}
</div>
@@ -398,7 +436,7 @@
class="h-full transition-all"
:style="{
width: `${(cosecha.total / maxTotalEnRango) * 100}%`,
backgroundColor: getCosechaColor(index)
backgroundColor: getCosechaColor(cosecha.id)
}"
/>
</div>
@@ -439,10 +477,23 @@ const props = defineProps<Props>()
// Obtener definiciones de cosechas
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
// Obtener configuración de estilos
const estilosGraficas = inject<any>('estilosGraficas', ref({
coloresCosechas: ['#c08040', '#d99a56', '#8b6f47', '#a0826e', '#b89968', '#f0c07c'],
anchoCelda: 80,
altoCelda: 6,
anchoMaxBarra: 300,
altoBarra: 8,
opacidadVacias: 0.05
}))
// Referencia al contenedor y estado de fullscreen
const cardContainer = ref<HTMLElement | null>(null)
const isFullscreen = ref(false)
// Estado de zoom (0.5x a 2x)
const nivelZoom = ref(1)
// Selección de vista, métrica y tipo
const vistaMode = ref<'heatmap' | 'barras'>('barras')
const metrica = ref<'peso' | 'cantidad' | 'inversion'>('peso')
@@ -452,13 +503,27 @@ const tipoSeleccionado = ref<string>('todos')
const primerDiaSeleccionado = ref<number | null>(null)
const rangoSeleccionado = ref<{ diaDesde: number, diaHasta: number, diasTotales: number } | null>(null)
// Configuración de celdas (heatmap)
const cellWidth = 80
const cellHeight = 6
// Configuración de barras
const barMaxWidth = 300
const barHeight = 8
// Configuración de celdas y barras (ahora reactivas desde la configuración y zoom)
const cellWidth = computed(() => {
const value = estilosGraficas.value.anchoCelda
const baseValue = typeof value === 'number' ? value : 80
return Math.round(baseValue * nivelZoom.value)
})
const cellHeight = computed(() => {
const value = estilosGraficas.value.altoCelda
const baseValue = typeof value === 'number' ? value : 6
return Math.round(baseValue * nivelZoom.value)
})
const barMaxWidth = computed(() => {
const value = estilosGraficas.value.anchoMaxBarra
const baseValue = typeof value === 'number' ? value : 300
return Math.round(baseValue * nivelZoom.value)
})
const barHeight = computed(() => {
const value = estilosGraficas.value.altoBarra
const baseValue = typeof value === 'number' ? value : 8
return Math.round(baseValue * nivelZoom.value)
})
// Tooltip state
const tooltipVisible = ref(false)
@@ -630,7 +695,7 @@ function getCellColor(valor: number, max: number): string {
// Escala de color desde transparente a naranja intenso
if (intensity === 0) {
return 'rgba(192, 128, 64, 0.05)' // Muy suave para vacío
return `rgba(192, 128, 64, ${estilosGraficas.value.opacidadVacias})` // Opacidad configurable
}
// Gradiente de naranja con opacidad basada en intensidad
@@ -642,26 +707,42 @@ function getCellColor(valor: number, max: number): string {
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// Colores sólidos por cosecha (vista barras)
const cosechaColors = [
'#c08040', // Naranja primario
'#d99a56', // Naranja claro
'#8b6f47', // Marrón
'#a0826e', // Café claro
'#b89968', // Beige
'#f0c07c' // Amarillo dorado
]
// Mapeo de cosechas a índices fijos
const cosechaColorMap: Record<string, number> = {
'cosecha-20-21': 0,
'cosecha-21-22': 1,
'cosecha-22-23': 2,
'cosecha-23-24': 3,
'cosecha-24-25': 4,
'cosecha-25-26': 5
}
// Función para obtener color fijo de cosecha
function getCosechaColor(index: number): string {
return cosechaColors[index % cosechaColors.length]
// Función para obtener color fijo de cosecha según su ID (no su orden de selección)
function getCosechaColor(cosechaIdOrIndex: string | number): string {
const colores = estilosGraficas.value.coloresCosechas
// Si es un string, es un ID de cosecha
if (typeof cosechaIdOrIndex === 'string') {
const index = cosechaColorMap[cosechaIdOrIndex] ?? 0
return colores[index % colores.length]
}
// Si es un número, buscar el ID de la cosecha en datosCosechas
const cosecha = datosCosechas.value[cosechaIdOrIndex]
if (cosecha?.id) {
const index = cosechaColorMap[cosecha.id] ?? cosechaIdOrIndex
return colores[index % colores.length]
}
// Fallback al índice directo
return colores[cosechaIdOrIndex % colores.length]
}
// Función para calcular ancho de barra según valor
function getBarWidth(valor: number): string {
if (maxValorDia.value === 0) return '0px'
const percentage = (valor / maxValorDia.value) * 100
const width = (percentage / 100) * barMaxWidth
const width = (percentage / 100) * barMaxWidth.value // .value es necesario en el script setup
return `${width}px`
}
@@ -798,6 +879,23 @@ function formatRangoFecha(diaRelativo: number): string {
})
}
// Funciones de zoom
function zoomIn() {
if (nivelZoom.value < 2) {
nivelZoom.value = Math.min(2, nivelZoom.value + 0.25)
}
}
function zoomOut() {
if (nivelZoom.value > 0.5) {
nivelZoom.value = Math.max(0.5, nivelZoom.value - 0.25)
}
}
function resetZoom() {
nivelZoom.value = 1
}
// Funciones de pantalla completa
async function toggleFullscreen() {
if (!cardContainer.value) return
@@ -822,13 +920,40 @@ function handleFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
// Zoom con rueda del mouse + Ctrl
function handleWheel(event: WheelEvent) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
if (event.deltaY < 0) {
// Scroll up = zoom in
zoomIn()
} else {
// Scroll down = zoom out
zoomOut()
}
}
}
// Montar y desmontar listeners
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
// Agregar listener para zoom con rueda del mouse
const container = cardContainer.value?.$el
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false })
}
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
// Remover listener de zoom
const container = cardContainer.value?.$el
if (container) {
container.removeEventListener('wheel', handleWheel)
}
})
</script>

View File

@@ -188,6 +188,11 @@ const props = defineProps<Props>()
// Obtener definiciones de cosechas
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
// Obtener configuración de estilos
const estilosGraficas = inject<any>('estilosGraficas', ref({
coloresCosechas: ['#c08040', '#d99a56', '#8b6f47', '#a0826e', '#b89968', '#f0c07c']
}))
// Dimensiones del SVG
const svgWidth = 500
const svgHeight = 350
@@ -287,10 +292,34 @@ function getBarHeight(value: number, max: number): number {
return (value / max) * chartHeight
}
// Colores para las cosechas
const cosechaColors = ['#c08040', '#d99a56', '#f0c07c', '#8b6f47', '#a0826e', '#b89968']
// Mapeo de cosechas a índices fijos
const cosechaColorMap: Record<string, number> = {
'cosecha-20-21': 0,
'cosecha-21-22': 1,
'cosecha-22-23': 2,
'cosecha-23-24': 3,
'cosecha-24-25': 4,
'cosecha-25-26': 5
}
function getCosechaColor(index: number): string {
return cosechaColors[index % cosechaColors.length]
// Función para obtener color fijo de cosecha según su ID (no su orden de selección)
function getCosechaColor(cosechaIdOrIndex: string | number): string {
const colores = estilosGraficas.value.coloresCosechas
// Si es un string, es un ID de cosecha
if (typeof cosechaIdOrIndex === 'string') {
const index = cosechaColorMap[cosechaIdOrIndex] ?? 0
return colores[index % colores.length]
}
// Si es un número, buscar el ID de la cosecha en datosPorTipo
const cosecha = datosPorTipo.value[cosechaIdOrIndex]
if (cosecha?.id) {
const index = cosechaColorMap[cosecha.id] ?? cosechaIdOrIndex
return colores[index % colores.length]
}
// Fallback al índice directo
return colores[cosechaIdOrIndex % colores.length]
}
</script>

View File

@@ -171,6 +171,11 @@ const props = defineProps<Props>()
// Obtener definiciones de cosechas
const cosechasDisponibles = inject<any[]>('cosechasDisponibles', [])
// Obtener configuración de estilos
const estilosGraficas = inject<any>('estilosGraficas', ref({
coloresCosechas: ['#c08040', '#d99a56', '#8b6f47', '#a0826e', '#b89968', '#f0c07c']
}))
// Dimensiones del SVG
const svgWidth = 400
const svgHeight = 300
@@ -245,10 +250,35 @@ function getBarHeight(value: number, max: number): number {
}
// Colores para las barras
const colors = ['#c08040', '#d99a56', '#f0c07c', '#8b6f47', '#a0826e', '#b89968']
// Mapeo de cosechas a índices fijos
const cosechaColorMap: Record<string, number> = {
'cosecha-20-21': 0,
'cosecha-21-22': 1,
'cosecha-22-23': 2,
'cosecha-23-24': 3,
'cosecha-24-25': 4,
'cosecha-25-26': 5
}
function getColor(index: number): string {
return colors[index % colors.length]
// Función para obtener color fijo de cosecha según su ID (no su orden de selección)
function getColor(cosechaIdOrIndex: string | number): string {
const colores = estilosGraficas.value.coloresCosechas
// Si es un string, es un ID de cosecha
if (typeof cosechaIdOrIndex === 'string') {
const index = cosechaColorMap[cosechaIdOrIndex] ?? 0
return colores[index % colores.length]
}
// Si es un número, buscar el ID de la cosecha en datosCosechas
const cosecha = datosCosechas.value[cosechaIdOrIndex]
if (cosecha?.id) {
const index = cosechaColorMap[cosecha.id] ?? cosechaIdOrIndex
return colores[index % colores.length]
}
// Fallback al índice directo
return colores[cosechaIdOrIndex % colores.length]
}
function formatMoney(value: number): string {

View File

@@ -1,138 +1,193 @@
<template>
<div class="flex flex-col gap-6">
<!-- Fila 1: Selector de Clientes -->
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Clientes
</h3>
<ClienteSelector
:clientes="clientes"
:selected-ids="selectedClienteIds"
@update:selected-ids="emit('update:selectedClienteIds', $event)"
/>
<UButton
v-if="selectedClienteIds.length > 0"
size="xs"
variant="ghost"
color="neutral"
@click="emit('update:selectedClienteIds', [])"
block
>
<template #leading>
<UIcon name="i-lucide-x" />
</template>
Limpiar selección
</UButton>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="selectedClienteIds.length === 0" class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Clientes
</h3>
<ClienteSelector
:clientes="clientes"
:selected-ids="selectedClienteIds"
@update:selected-ids="emit('update:selectedClienteIds', $event)"
/>
</div>
</Transition>
<!-- Fila 2: Selector de Rango de Fechas -->
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Rango de Fechas
</h3>
<DateRangeSelector
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
@update:selected-preset="emit('update:selectedPreset', $event)"
@update:fecha-desde="emit('update:fechaDesde', $event)"
@update:fecha-hasta="emit('update:fechaHasta', $event)"
/>
<UButton
v-if="fechaDesde || fechaHasta"
size="xs"
variant="ghost"
color="neutral"
@click="clearDates"
block
>
<template #leading>
<UIcon name="i-lucide-x" />
</template>
Limpiar fechas
</UButton>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="!fechaDesde && !fechaHasta" class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Rango de Fechas
</h3>
<DateRangeSelector
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
@update:selected-preset="emit('update:selectedPreset', $event)"
@update:fecha-desde="emit('update:fechaDesde', $event)"
@update:fecha-hasta="emit('update:fechaHasta', $event)"
/>
</div>
</Transition>
<!-- Fila 3: Filtros Avanzados (grid de 4 columnas) -->
<div class="flex flex-col gap-3">
<div v-if="hasAvailableAdvancedFilters" class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Filtros Avanzados
</h3>
<div class="grid grid-cols-2 gap-3">
<!-- Tipo de Café -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Tipo</label>
<UInputMenu
v-model="selectedTipos"
:items="tiposOptions"
value-key="value"
multiple
placeholder="Todos"
size="xs"
icon="i-lucide-coffee"
class="w-full"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="selectedTipos.length === 0" class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Tipo</label>
<UInputMenu
v-model="selectedTipos"
:items="tiposOptions"
value-key="value"
multiple
placeholder="Todos"
size="xs"
icon="i-lucide-coffee"
class="w-full"
/>
</div>
</Transition>
<!-- Estado -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Estado</label>
<UInputMenu
v-model="selectedEstados"
:items="estadosOptions"
value-key="value"
multiple
placeholder="Todos"
size="xs"
icon="i-lucide-check-circle"
class="w-full"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="selectedEstados.length === 0" class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Estado</label>
<UInputMenu
v-model="selectedEstados"
:items="estadosOptions"
value-key="value"
multiple
placeholder="Todos"
size="xs"
icon="i-lucide-check-circle"
class="w-full"
/>
</div>
</Transition>
<!-- Ubicación -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Ubicación</label>
<UInputMenu
v-model="selectedUbicaciones"
:items="ubicacionesOptions"
value-key="value"
multiple
placeholder="Todas"
size="xs"
icon="i-lucide-map-pin"
class="w-full"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="selectedUbicaciones.length === 0" class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Ubicación</label>
<UInputMenu
v-model="selectedUbicaciones"
:items="ubicacionesOptions"
value-key="value"
multiple
placeholder="Todas"
size="xs"
icon="i-lucide-map-pin"
class="w-full"
/>
</div>
</Transition>
<!-- Calidad -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Calidad</label>
<UInputMenu
v-model="selectedCalidades"
:items="calidadesOptions"
value-key="value"
multiple
placeholder="Todas"
size="xs"
icon="i-lucide-star"
class="w-full"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div v-if="selectedCalidades.length === 0" class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Calidad</label>
<UInputMenu
v-model="selectedCalidades"
:items="calidadesOptions"
value-key="value"
multiple
placeholder="Todas"
size="xs"
icon="i-lucide-star"
class="w-full"
/>
</div>
</Transition>
</div>
</div>
<!-- Checkbox de incluir anulados -->
<div class="flex flex-col gap-3">
<UCheckbox v-model="includeAnulados" label="Incluir anulados" size="sm" />
<UAlert
v-if="includeAnulados"
color="error"
variant="soft"
icon="i-lucide-alert-triangle"
title="Incluir anulados activado"
description="Los cálculos incluyen registros anulados."
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="!includeAnulados" class="flex flex-col gap-3">
<UCheckbox v-model="includeAnulados" label="Incluir anulados" size="sm" />
</div>
</Transition>
<!-- Botón "Sin filtros" -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="!noFilter && !hasAnyActiveFilter" class="flex flex-col gap-3">
<UButton
@click="handleNoFilter"
size="sm"
variant="outline"
color="neutral"
icon="i-lucide-ban"
block
>
Sin filtros
</UButton>
<p class="text-xs text-[var(--brand-text-muted)] text-center">
Confirma que no quieres aplicar ningún filtro
</p>
</div>
</Transition>
</div>
</template>
@@ -154,6 +209,7 @@ interface Props {
ubicacionesOptions: any[]
calidadesOptions: any[]
includeAnulados: boolean
noFilter: boolean
}
const props = defineProps<Props>()
@@ -168,6 +224,8 @@ const emit = defineEmits<{
'update:selectedUbicaciones': [value: string[]]
'update:selectedCalidades': [value: string[]]
'update:includeAnulados': [value: boolean]
'update:noFilter': [value: boolean]
'hideFiltros': []
}>()
const selectedTipos = computed({
@@ -195,9 +253,37 @@ const includeAnulados = computed({
set: (value) => emit('update:includeAnulados', value)
})
const noFilter = computed({
get: () => props.noFilter,
set: (value) => emit('update:noFilter', value)
})
const hasAvailableAdvancedFilters = computed(() => {
return selectedTipos.value.length === 0 ||
selectedEstados.value.length === 0 ||
selectedUbicaciones.value.length === 0 ||
selectedCalidades.value.length === 0
})
const hasAnyActiveFilter = computed(() => {
return props.selectedClienteIds.length > 0 ||
props.fechaDesde !== null ||
props.fechaHasta !== null ||
props.selectedTipos.length > 0 ||
props.selectedEstados.length > 0 ||
props.selectedUbicaciones.length > 0 ||
props.selectedCalidades.length > 0 ||
props.includeAnulados
})
function clearDates() {
emit('update:fechaDesde', null)
emit('update:fechaHasta', null)
emit('update:selectedPreset', '')
}
function handleNoFilter() {
emit('update:noFilter', true)
emit('hideFiltros')
}
</script>

View File

@@ -0,0 +1,293 @@
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-4 text-[#c08040]" />
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Filtros Activos</h3>
<span class="text-xs text-[var(--brand-text-muted)]">({{ totalFiltros }})</span>
</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
variant="ghost"
color="neutral"
@click="showFiltros"
icon="i-lucide-plus"
>
Agregar filtros
</UButton>
<UButton
v-if="totalFiltros > 0"
size="xs"
variant="ghost"
color="neutral"
@click="limpiarTodos"
icon="i-lucide-x"
>
Limpiar todos
</UButton>
</div>
</div>
</template>
<div v-if="totalFiltros === 0" class="text-center py-4 text-sm text-[var(--brand-text-muted)]">
No hay filtros activos
</div>
<div v-else class="flex flex-wrap gap-2">
<!-- Clientes -->
<div
v-for="clienteId in selectedClienteIds"
:key="`cliente-${clienteId}`"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/30 transition-all hover:bg-blue-500/20"
>
<UIcon name="i-lucide-user" class="size-3" />
<span>{{ getNombreCliente(clienteId) }}</span>
<button
@click="removeCliente(clienteId)"
class="ml-1 hover:text-blue-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<!-- Botón para agregar más clientes -->
<button
v-if="selectedClienteIds.length > 0"
@click="showClienteSelector"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-500/5 text-blue-400 border border-blue-500/20 border-dashed transition-all hover:bg-blue-500/10 hover:border-blue-500/40"
title="Agregar más clientes"
>
<UIcon name="i-lucide-user-plus" class="size-3" />
<span>Agregar cliente</span>
</button>
<!-- Fechas -->
<div
v-if="fechaDesde || fechaHasta"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-400 border border-purple-500/30 transition-all hover:bg-purple-500/20"
>
<UIcon name="i-lucide-calendar" class="size-3" />
<span>{{ fechaDesde || '—' }} {{ fechaHasta || '—' }}</span>
<button
@click="removeFechas"
class="ml-1 hover:text-purple-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<!-- Tipos -->
<div
v-for="tipo in selectedTipos"
:key="`tipo-${tipo}`"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-orange-500/10 text-orange-400 border border-orange-500/30 transition-all hover:bg-orange-500/20"
>
<UIcon name="i-lucide-coffee" class="size-3" />
<span>{{ getLabelTipo(tipo) }}</span>
<button
@click="removeTipo(tipo)"
class="ml-1 hover:text-orange-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<!-- Estados -->
<div
v-for="estado in selectedEstados"
:key="`estado-${estado}`"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/30 transition-all hover:bg-green-500/20"
>
<UIcon name="i-lucide-check-circle" class="size-3" />
<span>{{ getLabelEstado(estado) }}</span>
<button
@click="removeEstado(estado)"
class="ml-1 hover:text-green-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<!-- Ubicaciones -->
<div
v-for="ubicacion in selectedUbicaciones"
:key="`ubicacion-${ubicacion}`"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-cyan-500/10 text-cyan-400 border border-cyan-500/30 transition-all hover:bg-cyan-500/20"
>
<UIcon name="i-lucide-map-pin" class="size-3" />
<span>{{ ubicacion }}</span>
<button
@click="removeUbicacion(ubicacion)"
class="ml-1 hover:text-cyan-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<!-- Calidades -->
<div
v-for="calidad in selectedCalidades"
:key="`calidad-${calidad}`"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/30 transition-all hover:bg-yellow-500/20"
>
<UIcon name="i-lucide-star" class="size-3" />
<span>{{ calidad }}</span>
<button
@click="removeCalidad(calidad)"
class="ml-1 hover:text-yellow-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<!-- Incluir Anulados -->
<div
v-if="includeAnulados"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/30 transition-all hover:bg-red-500/20"
>
<UIcon name="i-lucide-alert-triangle" class="size-3" />
<span>Con anulados</span>
<button
@click="removeAnulados"
class="ml-1 hover:text-red-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
<!-- Sin Filtros -->
<div
v-if="noFilter"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-400 border border-gray-500/30 transition-all hover:bg-gray-500/20"
>
<UIcon name="i-lucide-ban" class="size-3" />
<span>Sin filtros</span>
<button
@click="removeNoFilter"
class="ml-1 hover:text-gray-300 transition-colors"
>
<UIcon name="i-lucide-x" class="size-3" />
</button>
</div>
</div>
</UCard>
</template>
<script setup lang="ts">
interface Props {
clientes: any[]
selectedClienteIds: number[]
fechaDesde: string | null
fechaHasta: string | null
selectedTipos: string[]
selectedEstados: string[]
selectedUbicaciones: string[]
selectedCalidades: string[]
includeAnulados: boolean
noFilter: boolean
tiposOptions: any[]
estadosOptions: any[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:selectedClienteIds': [value: number[]]
'update:fechaDesde': [value: string | null]
'update:fechaHasta': [value: string | null]
'update:selectedPreset': [value: string]
'update:selectedTipos': [value: string[]]
'update:selectedEstados': [value: string[]]
'update:selectedUbicaciones': [value: string[]]
'update:selectedCalidades': [value: string[]]
'update:includeAnulados': [value: boolean]
'update:noFilter': [value: boolean]
'showClienteSelector': []
'showFiltros': []
}>()
const totalFiltros = computed(() => {
let count = 0
count += props.selectedClienteIds.length
count += (props.fechaDesde || props.fechaHasta) ? 1 : 0
count += props.selectedTipos.length
count += props.selectedEstados.length
count += props.selectedUbicaciones.length
count += props.selectedCalidades.length
count += props.includeAnulados ? 1 : 0
count += props.noFilter ? 1 : 0
return count
})
function showClienteSelector() {
emit('showClienteSelector')
}
function showFiltros() {
emit('showFiltros')
}
function getNombreCliente(id: number): string {
const cliente = props.clientes.find(c => c.id === id)
return cliente?.name || `Cliente ${id}`
}
function getLabelTipo(value: string): string {
const tipo = props.tiposOptions.find(t => t.value === value)
return tipo?.label || value
}
function getLabelEstado(value: string): string {
const estado = props.estadosOptions.find(e => e.value === value)
return estado?.label || value
}
function removeCliente(id: number) {
emit('update:selectedClienteIds', props.selectedClienteIds.filter(cid => cid !== id))
}
function removeFechas() {
emit('update:fechaDesde', null)
emit('update:fechaHasta', null)
emit('update:selectedPreset', '')
}
function removeTipo(tipo: string) {
emit('update:selectedTipos', props.selectedTipos.filter(t => t !== tipo))
}
function removeEstado(estado: string) {
emit('update:selectedEstados', props.selectedEstados.filter(e => e !== estado))
}
function removeUbicacion(ubicacion: string) {
emit('update:selectedUbicaciones', props.selectedUbicaciones.filter(u => u !== ubicacion))
}
function removeCalidad(calidad: string) {
emit('update:selectedCalidades', props.selectedCalidades.filter(c => c !== calidad))
}
function removeAnulados() {
emit('update:includeAnulados', false)
}
function removeNoFilter() {
emit('update:noFilter', false)
}
function limpiarTodos() {
emit('update:selectedClienteIds', [])
emit('update:fechaDesde', null)
emit('update:fechaHasta', null)
emit('update:selectedPreset', '')
emit('update:selectedTipos', [])
emit('update:selectedEstados', [])
emit('update:selectedUbicaciones', [])
emit('update:selectedCalidades', [])
emit('update:includeAnulados', false)
emit('update:noFilter', false)
}
</script>

View File

@@ -10,6 +10,17 @@
<template #right>
<div class="flex items-center gap-3">
<UButton
@click="mostrarConfiguracion = !mostrarConfiguracion"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-palette"
:class="{ 'bg-[#c08040]/20 text-[#c08040]': mostrarConfiguracion }"
>
Configurar estilos
</UButton>
<div class="w-px h-4 bg-[var(--brand-border)]" />
<USwitch
v-model="pageSections.heatmap"
size="xs"
@@ -113,6 +124,188 @@
</UCard>
</template>
</div>
<!-- Modal de Configuración de Estilos -->
<UModal v-model:open="mostrarConfiguracion">
<UCard class="brand-card">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-palette" class="size-5 text-[#c08040]" />
<h3 id="modal-title" class="text-base font-semibold text-[var(--brand-text)]">Configuración de Estilos</h3>
</div>
<UButton
@click="resetearConfiguracion"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-rotate-ccw"
>
Resetear
</UButton>
</div>
<p id="modal-description" class="sr-only">
Personaliza los colores, dimensiones y opacidad de las gráficas
</p>
</template>
<div class="flex flex-col gap-6">
<!-- Sección: Colores de Cosechas -->
<div>
<h4 class="text-sm font-semibold text-[var(--brand-text)] mb-3 flex items-center gap-2">
<UIcon name="i-lucide-droplets" class="size-4" />
Colores de Cosechas (por año)
</h4>
<p class="text-xs text-[var(--brand-text-muted)] mb-3">
Los colores son fijos para cada cosecha, sin importar el orden en que se seleccionen
</p>
<div class="grid grid-cols-2 gap-3">
<div
v-for="(cosecha, index) in cosechasDisponibles"
:key="`color-${cosecha.id}`"
class="flex items-center gap-3 p-3 rounded-lg border border-[var(--brand-border)] bg-[var(--brand-bg-secondary)]"
>
<label class="text-xs text-[var(--brand-text-muted)] flex-1">
{{ cosecha.label }}
</label>
<input
type="color"
v-model="estilosGraficas.coloresCosechas[index]"
class="w-10 h-8 rounded cursor-pointer border border-[var(--brand-border)]"
/>
</div>
</div>
</div>
<!-- Sección: Dimensiones -->
<div>
<h4 class="text-sm font-semibold text-[var(--brand-text)] mb-3 flex items-center gap-2">
<UIcon name="i-lucide-ruler" class="size-4" />
Dimensiones
</h4>
<div class="grid grid-cols-2 gap-4">
<!-- Ancho de celda (Heatmap) -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Ancho de celda (Heatmap)
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.anchoCelda"
min="40"
max="150"
step="10"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.anchoCelda }}px
</span>
</div>
</div>
<!-- Alto de celda (Heatmap) -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Alto de celda (Heatmap)
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.altoCelda"
min="4"
max="20"
step="1"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.altoCelda }}px
</span>
</div>
</div>
<!-- Ancho máximo de barra -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Ancho máximo de barra
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.anchoMaxBarra"
min="150"
max="500"
step="25"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.anchoMaxBarra }}px
</span>
</div>
</div>
<!-- Alto de barra -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Alto de barra
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.altoBarra"
min="6"
max="24"
step="2"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ estilosGraficas.altoBarra }}px
</span>
</div>
</div>
</div>
</div>
<!-- Sección: Opacidad -->
<div>
<h4 class="text-sm font-semibold text-[var(--brand-text)] mb-3 flex items-center gap-2">
<UIcon name="i-lucide-sun-dim" class="size-4" />
Opacidad
</h4>
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">
Opacidad de celdas vacías (Heatmap)
</label>
<div class="flex items-center gap-2">
<input
type="range"
v-model.number="estilosGraficas.opacidadVacias"
min="0"
max="0.3"
step="0.05"
class="flex-1"
/>
<span class="text-xs text-[var(--brand-text)] w-12 text-right">
{{ (estilosGraficas.opacidadVacias * 100).toFixed(0) }}%
</span>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
@click="mostrarConfiguracion = false"
color="neutral"
variant="ghost"
>
Cerrar
</UButton>
</div>
</template>
</UCard>
</UModal>
</div>
</template>
@@ -133,6 +326,62 @@ const pageSections = ref({
porTipo: false
})
// Estado del modal de configuración
const mostrarConfiguracion = ref(false)
// Configuración de estilos por defecto
const estilosGraficasDefault = {
coloresCosechas: [
'#c08040', // Cosecha 20-21
'#d99a56', // Cosecha 21-22
'#8b6f47', // Cosecha 22-23
'#a0826e', // Cosecha 23-24
'#b89968', // Cosecha 24-25
'#f0c07c' // Cosecha 25-26
],
anchoCelda: 80,
altoCelda: 6,
anchoMaxBarra: 300,
altoBarra: 8,
opacidadVacias: 0.05
}
// Cargar configuración desde cookies
function cargarConfiguracionDesdeCookies() {
const cookie = useCookie('estilos-graficas', {
maxAge: 60 * 60 * 24 * 365, // 1 año
sameSite: 'lax'
})
if (cookie.value) {
try {
return { ...estilosGraficasDefault, ...cookie.value }
} catch (e) {
console.error('Error al cargar configuración desde cookies:', e)
return { ...estilosGraficasDefault }
}
}
return { ...estilosGraficasDefault }
}
// Configuración reactiva de estilos
const estilosGraficas = ref(cargarConfiguracionDesdeCookies())
// Guardar en cookies cuando cambie la configuración
watch(estilosGraficas, (newValue) => {
const cookie = useCookie('estilos-graficas', {
maxAge: 60 * 60 * 24 * 365, // 1 año
sameSite: 'lax'
})
cookie.value = newValue
}, { deep: true })
// Función para resetear la configuración
function resetearConfiguracion() {
estilosGraficas.value = { ...estilosGraficasDefault }
}
// 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' },
@@ -156,6 +405,7 @@ const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
const loading = computed(() => ingresosStore.isLoading)
const error = computed(() => ingresosStore.error)
// Exportar cosechas para los componentes
// Exportar cosechas y configuración para los componentes
provide('cosechasDisponibles', cosechasDisponibles)
provide('estilosGraficas', estilosGraficas)
</script>

View File

@@ -1,26 +1,7 @@
<template>
<div class="flex flex-col">
<!-- Toolbar con switches -->
<!-- Toolbar con switches de secciones -->
<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
@@ -80,7 +61,13 @@
<!-- Main Content -->
<template v-else>
<!-- Metadatos Card Colapsable -->
<!-- Metadatos Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" :compact="true" />
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" :compact="true" />
</div>
<!-- Filtros Activos Badge Card -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
@@ -89,10 +76,63 @@
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-show="!metadatosCollapsed" class="grid grid-cols-1 md:grid-cols-2 gap-5">
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" :compact="true" />
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" :compact="true" />
</div>
<IngresosFiltrosActivos
v-if="hasFiltrosActivos"
:clientes="clientes"
:selected-cliente-ids="selectedClienteIds"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
:selected-tipos="selectedTipos"
:selected-estados="selectedEstados"
:selected-ubicaciones="selectedUbicaciones"
:selected-calidades="selectedCalidades"
:include-anulados="includeAnulados"
:no-filter="noFilter"
:tipos-options="tiposCafeOptions"
:estados-options="estadosOptions"
@update:selected-cliente-ids="selectedClienteIds = $event"
@update:fecha-desde="fechaDesde = $event"
@update:fecha-hasta="fechaHasta = $event"
@update:selected-preset="selectedPreset = $event"
@update:selected-tipos="selectedTipos = $event"
@update:selected-estados="selectedEstados = $event"
@update:selected-ubicaciones="selectedUbicaciones = $event"
@update:selected-calidades="selectedCalidades = $event"
@update:include-anulados="includeAnulados = $event"
@update:no-filter="noFilter = $event"
@show-cliente-selector="showClienteSelectorTemp = true"
@show-filtros="filtrosCollapsed = false"
/>
</Transition>
<!-- Selector de clientes temporal (cuando se hace clic en "Agregar cliente") -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<UCard v-if="showClienteSelectorTemp" class="brand-card border border-blue-500/30">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Agregar más clientes</h3>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-x"
@click="showClienteSelectorTemp = false"
/>
</div>
</template>
<ClienteSelector
:clientes="clientes"
:selected-ids="selectedClienteIds"
@update:selected-ids="handleClienteSelection"
/>
</UCard>
</Transition>
<!-- Filtros Card Colapsable -->
@@ -105,6 +145,24 @@
leave-to-class="opacity-0 -translate-y-2"
>
<UCard v-show="!filtrosCollapsed" class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-4 text-[#c08040]" />
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Filtros Disponibles</h3>
</div>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-chevron-up"
@click="filtrosCollapsed = true"
>
Ocultar
</UButton>
</div>
</template>
<InformeIngresosFiltrosPanel
:clientes="clientes"
:selected-cliente-ids="selectedClienteIds"
@@ -120,6 +178,7 @@
:ubicaciones-options="ubicacionesOptions"
:calidades-options="calidadesOptions"
:include-anulados="includeAnulados"
:no-filter="noFilter"
@update:selected-cliente-ids="selectedClienteIds = $event"
@update:selected-preset="selectedPreset = $event"
@update:fecha-desde="fechaDesde = $event"
@@ -129,22 +188,12 @@
@update:selected-ubicaciones="selectedUbicaciones = $event"
@update:selected-calidades="selectedCalidades = $event"
@update:include-anulados="includeAnulados = $event"
@update:no-filter="noFilter = $event"
@hide-filtros="filtrosCollapsed = true"
/>
</UCard>
</Transition>
<!-- Clientes Seleccionados Cards -->
<div v-if="clientesSeleccionados.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<TransitionGroup name="cliente-card">
<ClientesClienteCard
v-for="cliente in clientesSeleccionados"
:key="cliente.id"
:cliente="cliente"
@remove="removeCliente(cliente.id)"
/>
</TransitionGroup>
</div>
<!-- Totales por Café -->
<UCard v-show="pageSections.totalesCafe" class="brand-card border border-transparent">
<template #header>
@@ -539,25 +588,42 @@ const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
const clientes = computed(() => clientesStore.allRecords as ClienteRecord[])
// -------------------------------
// Filtros
// Filtros con persistencia en cookies
// -------------------------------
const includeAnulados = ref(false)
const includeAnulados = useCookie<boolean>('informe-ingresos-anulados', { default: () => false })
const noFilter = useCookie<boolean>('informe-ingresos-no-filter', { default: () => false })
type PresetValue =
| '' | 'custom' | 'hoy' | 'semana' | 'mes' | 'ytd'
| 'cosecha-20-21' | 'cosecha-21-22' | 'cosecha-22-23'
| 'cosecha-23-24' | 'cosecha-24-25' | 'cosecha-25-26'
const selectedPreset = ref<PresetValue>('cosecha-25-26')
const fechaDesde = ref<string | null>(null)
const fechaHasta = ref<string | null>(null)
const selectedClienteIds = ref<number[]>([])
const selectedPreset = useCookie<PresetValue>('informe-ingresos-preset', { default: () => 'cosecha-25-26' })
const fechaDesde = useCookie<string | null>('informe-ingresos-fecha-desde', { default: () => null })
const fechaHasta = useCookie<string | null>('informe-ingresos-fecha-hasta', { default: () => null })
const selectedClienteIds = useCookie<number[]>('informe-ingresos-clientes', { default: () => [] })
// Filtros avanzados
const selectedTipos = ref<string[]>([])
const selectedEstados = ref<string[]>([])
const selectedUbicaciones = ref<string[]>([])
const selectedCalidades = ref<string[]>([])
const selectedTipos = useCookie<string[]>('informe-ingresos-tipos', { default: () => [] })
const selectedEstados = useCookie<string[]>('informe-ingresos-estados', { default: () => [] })
const selectedUbicaciones = useCookie<string[]>('informe-ingresos-ubicaciones', { default: () => [] })
const selectedCalidades = useCookie<string[]>('informe-ingresos-calidades', { default: () => [] })
// Watch para sobrescribir noFilter cuando se agregue cualquier filtro
watch([selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstados, selectedUbicaciones, selectedCalidades, includeAnulados], () => {
const hasAnyFilter = selectedClienteIds.value.length > 0 ||
fechaDesde.value !== null ||
fechaHasta.value !== null ||
selectedTipos.value.length > 0 ||
selectedEstados.value.length > 0 ||
selectedUbicaciones.value.length > 0 ||
selectedCalidades.value.length > 0 ||
includeAnulados.value
if (hasAnyFilter && noFilter.value) {
noFilter.value = false
}
})
// Opciones para filtros avanzados
const tiposCafeOptions = [
@@ -744,6 +810,27 @@ const clientesFiltrados = computed((): ClienteRecord[] => {
// Métricos basados en filtrados
const ingresosMetrics = useIngresosMetrics(ingresosFiltrados)
// Detectar si hay filtros activos (incluyendo "Sin filtros")
const hasFiltrosActivos = computed(() => {
return selectedClienteIds.value.length > 0 ||
fechaDesde.value !== null ||
fechaHasta.value !== null ||
selectedTipos.value.length > 0 ||
selectedEstados.value.length > 0 ||
selectedUbicaciones.value.length > 0 ||
selectedCalidades.value.length > 0 ||
includeAnulados.value ||
noFilter.value
})
// Watch para mostrar automáticamente el panel de filtros si no hay barras visibles
watch([hasFiltrosActivos, filtrosCollapsed], () => {
// Si no hay filtros activos Y el panel de filtros está colapsado, expandirlo
if (!hasFiltrosActivos.value && filtrosCollapsed.value) {
filtrosCollapsed.value = false
}
})
// Toggle para vista de totales
const showMonetaryView = ref(false)
@@ -761,6 +848,19 @@ function toggleTableFullscreen() {
}
}
// Estado temporal para mostrar selector de clientes desde el botón "+"
const showClienteSelectorTemp = ref(false)
function handleClienteSelection(newIds: number[]) {
selectedClienteIds.value = newIds
// Cerrar el selector temporal cuando se hace una selección
if (newIds.length > selectedClienteIds.value.length) {
nextTick(() => {
showClienteSelectorTemp.value = false
})
}
}
// Atajo de teclado para salir de fullscreen con Escape
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isTableFullscreen.value) {