mejoras ui/ux

This commit is contained in:
2025-10-01 06:51:39 -06:00
parent c4719d95cc
commit 32b10e1ad6
6 changed files with 557 additions and 171 deletions

View File

@@ -36,11 +36,6 @@
size="xs"
label="Evolución"
/>
<USwitch
v-model="pageSections.porTipo"
size="xs"
label="Por Tipo"
/>
</div>
</template>
</UDashboardToolbar>
@@ -76,43 +71,79 @@
<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'"
class="flex items-center gap-2 p-3 rounded-lg border transition-all"
:class="[
cosecha.disabled
? 'border-gray-600/20 bg-gray-800/20 cursor-not-allowed opacity-50'
: cosechasSeleccionadas.includes(cosecha.id)
? 'border-[#c08040] bg-[#c08040]/10 cursor-pointer'
: 'border-[var(--brand-border)] hover:border-[#c08040]/50 cursor-pointer'
]"
>
<input
type="checkbox"
:value="cosecha.id"
v-model="cosechasSeleccionadas"
class="rounded border-[var(--brand-border)] text-[#c08040] focus:ring-[#c08040]"
:disabled="cosecha.disabled"
class="rounded border-[var(--brand-border)] text-[#c08040] focus:ring-[#c08040] disabled:cursor-not-allowed"
/>
<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 class="flex flex-col flex-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-[var(--brand-text)]">{{ cosecha.label }}</span>
<span
v-if="!cosecha.disabled"
class="text-[10px] px-1.5 py-0.5 rounded-full bg-[#c08040]/20 text-[#c08040] font-semibold"
>
{{ cosecha.registros }}
</span>
</div>
<span class="text-xs text-[var(--brand-text-muted)]">
{{ cosecha.disabled ? 'Sin datos' : cosecha.periodo }}
</span>
</div>
</label>
</div>
</UCard>
<!-- Metadatos de Resumen de Ingresos -->
<div v-if="cosechasSeleccionadas.length > 0" class="grid grid-cols-1 gap-5">
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-database" class="size-5 text-[#c08040]" />
<h3 class="text-base font-semibold text-[var(--brand-text)]">Fuente de Datos: Resumen Diario de Ingresos</h3>
</div>
</template>
<div class="flex flex-col gap-3">
<MetadatosCard v-if="resumenIngresosMetadata" :metadata="resumenIngresosMetadata" :compact="true" />
<div class="text-xs text-[var(--brand-text-muted)] p-3 rounded-lg bg-[var(--brand-bg-secondary)] border border-[var(--brand-border)]">
<p class="font-semibold mb-2">Métricas utilizadas (registros diarios):</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li><strong>total_peso_seco</strong> (qq)</li>
<li><strong>peso_neto_uva</strong> (qq)</li>
<li><strong>peso_neto_verde</strong> (qq)</li>
<li><strong>sacos_total_dia</strong></li>
<li><strong>Lempiras por tipo:</strong> uva, verde, mojado+oreado (combinados)</li>
</ul>
<p class="mt-2 text-[10px] italic">Todas las cosechas inician el 8 de septiembre y terminan el 7 de septiembre del siguiente año.</p>
</div>
</div>
</UCard>
</div>
<!-- Vista Heatmap -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.heatmap">
<ComparativaCosechasHeatmap :ingresos="ingresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
<ComparativaCosechasHeatmap :ingresos="resumenIngresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- 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" />
<ComparativaCosechasTotales :ingresos="resumenIngresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Evolución Temporal Comparada -->
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.evolucion">
<ComparativaCosechasEvolucion :ingresos="ingresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
<ComparativaCosechasEvolucion :ingresos="resumenIngresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
</div>
<!-- Empty State -->
@@ -126,8 +157,7 @@
</div>
<!-- Modal de Configuración de Estilos -->
<UModal v-model:open="mostrarConfiguracion">
<UCard class="brand-card">
<UCard class="brand-card" v-if="mostrarConfiguracion">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
@@ -161,7 +191,7 @@
</p>
<div class="grid grid-cols-2 gap-3">
<div
v-for="(cosecha, index) in cosechasDisponibles"
v-for="(cosecha, index) in cosechasDefiniciones"
:key="`color-${cosecha.id}`"
class="flex items-center gap-3 p-3 rounded-lg border border-[var(--brand-border)] bg-[var(--brand-bg-secondary)]"
>
@@ -305,12 +335,12 @@
</div>
</template>
</UCard>
</UModal>
</div>
</template>
<script setup lang="ts">
import { useTableDataStore } from '~/stores/tableDataFactory'
import { useMetadataStore } from '~/stores/metadata'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
definePageMeta({
@@ -322,8 +352,7 @@ definePageMeta({
const pageSections = ref({
heatmap: true,
totales: false,
evolucion: false,
porTipo: false
evolucion: false
})
// Estado del modal de configuración
@@ -382,30 +411,131 @@ 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' },
{ 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] }
// Store de resumen de ingresos (registros por día con métricas agregadas)
const resumenIngresosStore = useTableDataStore<any>('vista_resumen_ingresos')
// Datos de resumen desde el store
const resumenIngresos = computed(() => resumenIngresosStore.allRecords)
// Definición de cosechas disponibles (8 sep - 7 sep)
const cosechasDefiniciones = [
{ id: 'cosecha-20-21', label: 'Cosecha 20-21', periodo: '8 Sep 2020 - 7 Sep 2021', fechaInicio: '2020-09-08', fechaFin: '2021-09-07' },
{ id: 'cosecha-21-22', label: 'Cosecha 21-22', periodo: '8 Sep 2021 - 7 Sep 2022', fechaInicio: '2021-09-08', fechaFin: '2022-09-07' },
{ id: 'cosecha-22-23', label: 'Cosecha 22-23', periodo: '8 Sep 2022 - 7 Sep 2023', fechaInicio: '2022-09-08', fechaFin: '2023-09-07' },
{ id: 'cosecha-23-24', label: 'Cosecha 23-24', periodo: '8 Sep 2023 - 7 Sep 2024', fechaInicio: '2023-09-08', fechaFin: '2024-09-07' },
{ id: 'cosecha-24-25', label: 'Cosecha 24-25', periodo: '8 Sep 2024 - 7 Sep 2025', fechaInicio: '2024-09-08', fechaFin: '2025-09-07' },
{ id: 'cosecha-25-26', label: 'Cosecha 25-26', periodo: '8 Sep 2025 - Hoy', fechaInicio: '2025-09-08', 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'])
// Calcular cuántos registros tiene cada cosecha
const registrosPorCosecha = computed(() => {
const counts: Record<string, number> = {}
// Store de ingresos
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
cosechasDefiniciones.forEach(cosecha => {
const registros = resumenIngresos.value.filter((record: any) => {
const fecha = new Date(record.fecha || record.created_at)
const inicio = new Date(cosecha.fechaInicio)
const fin = new Date(cosecha.fechaFin)
return fecha >= inicio && fecha <= fin
})
counts[cosecha.id] = registros.length
})
// Datos de ingresos desde el store
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
return counts
})
// Cosechas disponibles con información de registros
const cosechasDisponibles = computed(() => {
return cosechasDefiniciones.map(cosecha => ({
...cosecha,
registros: registrosPorCosecha.value[cosecha.id] || 0,
disabled: (registrosPorCosecha.value[cosecha.id] || 0) === 0
}))
})
// Cosechas seleccionadas (por defecto las más recientes con datos)
const cosechasSeleccionadas = ref<string[]>([])
// Watch para filtrar cosechas deshabilitadas y seleccionar las más recientes con datos
watch(cosechasDisponibles, (disponibles) => {
// Remover cosechas deshabilitadas de la selección
cosechasSeleccionadas.value = cosechasSeleccionadas.value.filter(id => {
const cosecha = disponibles.find(c => c.id === id)
return cosecha && !cosecha.disabled
})
// Si no hay ninguna seleccionada, seleccionar las 2 más recientes con datos
if (cosechasSeleccionadas.value.length === 0) {
const conDatos = disponibles.filter(c => !c.disabled)
cosechasSeleccionadas.value = conDatos.slice(-2).map(c => c.id)
}
}, { immediate: true })
// Loading and error states
const loading = computed(() => ingresosStore.isLoading)
const error = computed(() => ingresosStore.error)
const loading = computed(() => resumenIngresosStore.isLoading)
const error = computed(() => resumenIngresosStore.error)
// Exportar cosechas y configuración para los componentes
provide('cosechasDisponibles', cosechasDisponibles)
// Metadatos desde el store de metadata
const metadataStore = useMetadataStore()
const resumenIngresosMetadata = computed(() => {
const meta = metadataStore.metadata.find((t: any) => t.table === 'vista_resumen_ingresos')
return meta ? { ...meta, name: 'vista_resumen_ingresos' } : null
})
// Definir las métricas disponibles para los componentes
const metricasDisponibles = {
pesoSeco: {
key: 'total_peso_seco',
label: 'Peso Seco Total',
unidad: 'qq',
descripcion: 'Peso seco total del día'
},
pesoNetoUva: {
key: 'peso_neto_uva',
label: 'Peso Neto Uva',
unidad: 'qq',
descripcion: 'Peso neto de café uva'
},
pesoNetoVerde: {
key: 'peso_neto_verde',
label: 'Peso Neto Verde',
unidad: 'qq',
descripcion: 'Peso neto de café verde'
},
sacos: {
key: 'sacos_total_dia',
label: 'Sacos Totales',
unidad: 'sacos',
descripcion: 'Total de sacos del día'
},
lempirasUva: {
key: 'total_lempiras_uva',
label: 'Lempiras Uva',
unidad: 'L',
descripcion: 'Total en lempiras de café uva'
},
lempirasVerde: {
key: 'total_lempiras_verde',
label: 'Lempiras Verde',
unidad: 'L',
descripcion: 'Total en lempiras de café verde'
},
lempirasMojadoOreado: {
key: 'total_lempiras_mojado_oreado',
label: 'Lempiras Mojado+Oreado',
unidad: 'L',
descripcion: 'Total en lempiras de café mojado y oreado (combinados)',
// Esta métrica se calcula combinando total_lempiras_mojado + total_lempiras_oreado
computed: true,
keys: ['total_lempiras_mojado', 'total_lempiras_oreado']
}
}
// Exportar definiciones de cosechas, métricas y configuración para los componentes
// Usamos cosechasDefiniciones (array estático) en lugar de cosechasDisponibles (computed)
// porque los componentes hijos no necesitan la info de disabled/registros
provide('cosechasDisponibles', cosechasDefiniciones)
provide('metricasDisponibles', metricasDisponibles)
provide('estilosGraficas', estilosGraficas)
</script>

View File

@@ -61,6 +61,31 @@
<!-- Main Content -->
<template v-else>
<!-- Loading overlay when filtering data -->
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isFilteringData"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
>
<UCard class="brand-card border border-[#c08040]/30">
<div class="flex flex-col items-center justify-center gap-4 py-6 px-8">
<span class="inline-flex h-12 w-12 animate-spin rounded-full border-4 border-[#c08040] border-t-transparent" />
<div class="flex flex-col items-center gap-2">
<span class="text-lg font-semibold text-[var(--brand-text)]">Filtrando datos...</span>
<span class="text-sm text-[var(--brand-text-muted)]">Por favor espere</span>
</div>
</div>
</UCard>
</div>
</Transition>
<!-- Metadatos Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" :compact="true" />
@@ -80,6 +105,7 @@
v-if="hasFiltrosActivos"
:clientes="clientes"
:selected-cliente-ids="selectedClienteIds"
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
:selected-tipos="selectedTipos"
@@ -598,7 +624,7 @@ type PresetValue =
| 'cosecha-20-21' | 'cosecha-21-22' | 'cosecha-22-23'
| 'cosecha-23-24' | 'cosecha-24-25' | 'cosecha-25-26'
const selectedPreset = useCookie<PresetValue>('informe-ingresos-preset', { default: () => 'cosecha-25-26' })
const selectedPreset = useCookie<PresetValue>('informe-ingresos-preset', { default: () => 'hoy' })
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: () => [] })
@@ -609,8 +635,17 @@ const selectedEstados = useCookie<string[]>('informe-ingresos-estados', { defaul
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], () => {
// Debounce para manejar cambios de filtros
let filterChangeTimeout: ReturnType<typeof setTimeout> | null = null
// Watch para actualizar filtrados cuando cambien los filtros o los datos
watch([ingresos, selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstados, selectedUbicaciones, selectedCalidades, includeAnulados], () => {
// Cancelar timeout previo si existe
if (filterChangeTimeout) {
clearTimeout(filterChangeTimeout)
}
// Actualizar noFilter si es necesario
const hasAnyFilter = selectedClienteIds.value.length > 0 ||
fechaDesde.value !== null ||
fechaHasta.value !== null ||
@@ -623,7 +658,12 @@ watch([selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstado
if (hasAnyFilter && noFilter.value) {
noFilter.value = false
}
})
// Debounce para evitar múltiples actualizaciones rápidas
filterChangeTimeout = setTimeout(() => {
updateFiltrados()
}, 100)
}, { immediate: true })
// Opciones para filtros avanzados
const tiposCafeOptions = [
@@ -789,8 +829,8 @@ function removeCliente(clienteId: number) {
selectedClienteIds.value = selectedClienteIds.value.filter(id => id !== clienteId)
}
// Filtrados que alimentan los métricos
const ingresosFiltrados = computed(() => {
// Helper para calcular ingresos filtrados
function calculateIngresosFiltrados() {
return (ingresos.value ?? [])
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
@@ -799,7 +839,33 @@ const ingresosFiltrados = computed(() => {
.filter(r => matchesEstado(r))
.filter(r => matchesUbicacion(r))
.filter(r => matchesCalidad(r))
})
}
// Usar shallowRef para controlar cuando se actualiza
const ingresosFiltrados = shallowRef<IngresoRecord[]>([])
// Función para actualizar filtrados de forma controlada
async function updateFiltrados() {
// Activar loading
isFilteringData.value = true
// Esperar a que el DOM se actualice
await nextTick()
// Dar dos frames para asegurar que el loading se renderice
await new Promise(resolve => requestAnimationFrame(() => {
requestAnimationFrame(resolve)
}))
// Ahora sí, calcular los filtrados
ingresosFiltrados.value = calculateIngresosFiltrados()
// Esperar un frame más para que Vue procese los cambios
await new Promise(resolve => requestAnimationFrame(resolve))
// Desactivar loading
isFilteringData.value = false
}
const clientesFiltrados = computed((): ClienteRecord[] => {
// Si hay clientes seleccionados, filtrar por ellos
@@ -907,6 +973,7 @@ function getViewCount(view: ViewMode): number {
const loading = computed(() => ingresosStore.isLoading || clientesStore.isLoading)
const error = computed(() => ingresosStore.error || clientesStore.error)
const loadingProgress = ref(0)
const isFilteringData = ref(false)
// Metadatos desde el store de metadata
const metadataStore = useMetadataStore()
@@ -981,11 +1048,25 @@ onMounted(async () => {
} catch (err) {
console.error('Error loading data:', err)
} finally {
// Default preset: cosecha 25-26
selectedPreset.value = 'cosecha-25-26'
// Re-evaluar presets dinámicos (hoy, semana, mes, ytd) al montar
const dynamicPresets = ['hoy', 'semana', 'mes', 'ytd']
if (selectedPreset.value && dynamicPresets.includes(selectedPreset.value)) {
// Forzar re-evaluación del preset para obtener fechas actualizadas
const currentPreset = selectedPreset.value
selectedPreset.value = ''
nextTick(() => {
selectedPreset.value = currentPreset as PresetValue
})
}
// Si no hay preset, usar 'hoy' por defecto
if (!selectedPreset.value) {
selectedPreset.value = 'hoy'
}
includeAnulados.value = false
// Clear advanced filters
// Clear advanced filters on mount
selectedTipos.value = []
selectedEstados.value = []
selectedUbicaciones.value = []