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

@@ -102,10 +102,13 @@
<span class="text-xs text-blue-400">
(Día {{ primerDiaSeleccionado }})
</span>
<span class="text-xs text-[var(--brand-text-muted)] italic ml-2">
Click otra celda para completar
</span>
</div>
<!-- Estado inicial -->
<span v-else class="text-xs text-[var(--brand-text-muted)] italic">
Click en una celda para iniciar selección
Click en una celda para iniciar selección Click derecho para cancelar
</span>
<UButton
v-if="rangoSeleccionado || primerDiaSeleccionado !== null"
@@ -118,57 +121,69 @@
</div>
<label class="text-xs text-[var(--brand-text-muted)] ml-4">Métrica:</label>
<div class="flex gap-2">
<div class="flex gap-2 flex-wrap">
<button
@click="metrica = 'peso'"
@click="metrica = 'total_peso_seco'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'peso'
:class="metrica === 'total_peso_seco'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Peso (qq)
Peso Seco (qq)
</button>
<button
@click="metrica = 'cantidad'"
@click="metrica = 'peso_neto_uva'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'cantidad'
:class="metrica === 'peso_neto_uva'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Cantidad
Peso Neto Uva (qq)
</button>
<button
@click="metrica = 'inversion'"
@click="metrica = 'peso_neto_verde'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'inversion'
:class="metrica === 'peso_neto_verde'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Inversión (L)
</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
Peso Neto Verde (qq)
</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
@click="metrica = 'sacos_total_dia'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'sacos_total_dia'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
{{ tipo }}
Sacos Total
</button>
<button
@click="metrica = 'total_lempiras_uva'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'total_lempiras_uva'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Lempiras Uva
</button>
<button
@click="metrica = 'total_lempiras_verde'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'total_lempiras_verde'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Lempiras Verde
</button>
<button
@click="metrica = 'total_lempiras_mojado_oreado'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'total_lempiras_mojado_oreado'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Lempiras Mojado+Oreado
</button>
</div>
</div>
@@ -179,20 +194,49 @@
<div class="min-w-max">
<!-- Vista Heatmap -->
<template v-if="vistaMode === 'heatmap'">
<!-- Leyenda de totales -->
<div class="flex items-center gap-3 mb-3 px-2 py-2 rounded-lg bg-[var(--brand-bg-secondary)]/50 border border-[var(--brand-border)]/30">
<span class="text-[10px] text-[var(--brand-text-muted)] uppercase font-semibold">Leyenda:</span>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-[#c08040]"></div>
<span class="text-[10px] text-[var(--brand-text-muted)]">Total completo</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
<span class="text-[10px] text-[var(--brand-text-muted)]">Acumulado hasta hoy (día {{ diaActualRelativo }})</span>
</div>
</div>
<!-- Header con labels de cosechas -->
<div class="flex gap-1 mb-2">
<div class="w-16 flex-shrink-0"></div>
<div
v-for="(cosecha, index) in datosCosechas"
:key="cosecha.id"
class="flex flex-col items-center"
class="flex flex-col items-center gap-1 p-2 rounded-lg bg-[var(--brand-bg-secondary)]"
:style="{ width: `${cellWidth}px` }"
>
<div class="text-xs font-medium text-[var(--brand-text)] mb-1">
<div class="text-xs font-semibold text-[var(--brand-text)]">
{{ cosecha.label }}
</div>
<div class="text-xs text-[var(--brand-text-muted)]">
{{ formatTotal(cosecha.total) }}
<div class="flex flex-col items-center gap-0.5">
<div class="text-xs font-medium text-[#c08040]" :title="`Total completo de ${cosecha.label}`">
{{ formatTotal(cosecha.total) }}
</div>
<div
v-if="cosecha.totalALaFecha !== null"
class="text-[10px] text-blue-400 font-medium"
:title="`Acumulado hasta el día ${diaActualRelativo} (equivalente a hoy)`"
>
{{ formatTotal(cosecha.totalALaFecha) }}
</div>
<div
v-else
class="text-[10px] text-gray-500 italic"
title="Esta cosecha aún no ha llegado a este día del año"
>
Sin datos
</div>
</div>
</div>
</div>
@@ -228,6 +272,10 @@
:key="`${cosecha.id}-${dia}`"
class="border-r border-b transition-all cursor-pointer relative"
:class="[
// Marcador del día actual
dia - 1 === diaActualRelativo
? 'border-l-2 border-l-blue-400'
: '',
// Rango seleccionado (naranja)
isInSelectedRange(dia - 1)
? 'ring-2 ring-[#c08040] border-[#c08040] z-10'
@@ -243,6 +291,7 @@
backgroundColor: getCellColor(cosecha.valoresPorDia[dia - 1] || 0, maxValorDia)
}"
@click="handleCellClick(dia - 1)"
@contextmenu.prevent="cancelarSeleccion"
@mouseenter="showTooltip($event, cosecha, dia - 1)"
@mouseleave="hideTooltip"
>
@@ -251,6 +300,11 @@
v-if="primerDiaSeleccionado === dia - 1 && rangoSeleccionado === null"
class="absolute inset-0 bg-blue-500/30 pointer-events-none"
/>
<!-- Indicador del día actual -->
<div
v-if="dia - 1 === diaActualRelativo"
class="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-400 pointer-events-none z-20"
/>
</div>
</div>
</div>
@@ -259,20 +313,49 @@
<!-- Vista Barras -->
<template v-else-if="vistaMode === 'barras'">
<!-- Leyenda de totales -->
<div class="flex items-center gap-3 mb-3 px-2 py-2 rounded-lg bg-[var(--brand-bg-secondary)]/50 border border-[var(--brand-border)]/30">
<span class="text-[10px] text-[var(--brand-text-muted)] uppercase font-semibold">Leyenda:</span>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-[#c08040]"></div>
<span class="text-[10px] text-[var(--brand-text-muted)]">Total completo</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
<span class="text-[10px] text-[var(--brand-text-muted)]">Acumulado hasta hoy (día {{ diaActualRelativo }})</span>
</div>
</div>
<!-- Header con labels de cosechas -->
<div class="flex gap-1 mb-2">
<div class="w-16 flex-shrink-0"></div>
<div
v-for="(cosecha, index) in datosCosechas"
:key="cosecha.id"
class="flex flex-col items-center"
class="flex flex-col items-center gap-1 p-2 rounded-lg bg-[var(--brand-bg-secondary)]"
:style="{ width: `${barMaxWidth}px` }"
>
<div class="text-xs font-medium mb-1" :style="{ color: getCosechaColor(cosecha.id) }">
<div class="text-xs font-semibold" :style="{ color: getCosechaColor(cosecha.id) }">
{{ cosecha.label }}
</div>
<div class="text-xs text-[var(--brand-text-muted)]">
{{ formatTotal(cosecha.total) }}
<div class="flex flex-col items-center gap-0.5">
<div class="text-xs font-medium text-[#c08040]" :title="`Total completo de ${cosecha.label}`">
{{ formatTotal(cosecha.total) }}
</div>
<div
v-if="cosecha.totalALaFecha !== null"
class="text-[10px] text-blue-400 font-medium"
:title="`Acumulado hasta el día ${diaActualRelativo} (equivalente a hoy)`"
>
{{ formatTotal(cosecha.totalALaFecha) }}
</div>
<div
v-else
class="text-[10px] text-gray-500 italic"
title="Esta cosecha aún no ha llegado a este día del año"
>
Sin datos
</div>
</div>
</div>
</div>
@@ -309,6 +392,10 @@
:key="`${cosecha.id}-${dia}`"
class="relative border-b cursor-pointer group"
:class="[
// Marcador del día actual
dia - 1 === diaActualRelativo
? 'border-l-2 border-l-blue-400'
: '',
// Rango seleccionado (naranja)
isInSelectedRange(dia - 1)
? 'border-[#c08040] border-2 bg-[#c08040]/10'
@@ -320,6 +407,7 @@
]"
:style="{ height: `${barHeight}px` }"
@click="handleCellClick(dia - 1)"
@contextmenu.prevent="cancelarSeleccion"
@mouseenter="showTooltip($event, cosecha, dia - 1)"
@mouseleave="hideTooltip"
>
@@ -332,6 +420,11 @@
backgroundColor: getCosechaColor(cosecha.id)
}"
/>
<!-- Indicador del día actual -->
<div
v-if="dia - 1 === diaActualRelativo"
class="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-400 pointer-events-none z-20"
/>
</div>
</div>
</div>
@@ -465,10 +558,22 @@
</template>
<script setup lang="ts">
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
// Tipo para los registros de vista_resumen_ingresos
interface ResumenIngresoRecord {
fecha: string
created_at?: string
total_peso_seco: number
peso_neto_uva: number
peso_neto_verde: number
sacos_total_dia: number
total_lempiras_uva: number
total_lempiras_verde: number
total_lempiras_mojado: number
total_lempiras_oreado: number
}
interface Props {
ingresos: IngresoRecord[]
ingresos: ResumenIngresoRecord[]
cosechasSeleccionadas: string[]
}
@@ -494,10 +599,10 @@ const isFullscreen = ref(false)
// Estado de zoom (0.5x a 2x)
const nivelZoom = ref(1)
// Selección de vista, métrica y tipo
// Selección de vista y métrica
const vistaMode = ref<'heatmap' | 'barras'>('barras')
const metrica = ref<'peso' | 'cantidad' | 'inversion'>('peso')
const tipoSeleccionado = ref<string>('todos')
type MetricaType = 'total_peso_seco' | 'peso_neto_uva' | 'peso_neto_verde' | 'sacos_total_dia' | 'total_lempiras_uva' | 'total_lempiras_verde' | 'total_lempiras_mojado_oreado'
const metrica = ref<MetricaType>('total_peso_seco')
// Sistema de selección interactiva de rango
const primerDiaSeleccionado = ref<number | null>(null)
@@ -531,35 +636,57 @@ const tooltipX = ref(0)
const tooltipY = ref(0)
const tooltipData = ref({ cosecha: '', dia: 0, valor: '', fecha: '' })
// Calcular datos por cosecha
// Calcular el día actual relativo desde el inicio del año de cosecha (8 de septiembre)
const diaActualRelativo = computed(() => {
const hoy = new Date()
// Fecha de inicio del año de cosecha actual (8 de septiembre del año correspondiente)
let anioInicioCosecha = hoy.getFullYear()
const inicioCosechaEsteAnio = new Date(anioInicioCosecha, 8, 8) // 8 de septiembre
// Si aún no hemos llegado al 8 de septiembre de este año, el inicio fue el año pasado
if (hoy < inicioCosechaEsteAnio) {
anioInicioCosecha--
}
const inicioCosecha = new Date(anioInicioCosecha, 8, 8) // 8 de septiembre
const diaRelativo = Math.floor((hoy.getTime() - inicioCosecha.getTime()) / (1000 * 60 * 60 * 24))
return diaRelativo
})
// Calcular datos por cosecha usando vista_resumen_ingresos
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)
// Filtrar registros que pertenecen a esta cosecha
const registrosEnCosecha = props.ingresos.filter(registro => {
const fechaStr = registro.fecha || registro.created_at
if (!fechaStr) return false
const fecha = new Date(fechaStr)
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
return fecha >= inicio && fecha <= fin
})
// Ordenar por fecha
const ingresosOrdenados = ingresosEnCosecha.sort((a, b) => {
return new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime()
const registrosOrdenados = registrosEnCosecha.sort((a, b) => {
const fechaA = new Date(a.fecha || a.created_at!)
const fechaB = new Date(b.fecha || b.created_at!)
return fechaA.getTime() - fechaB.getTime()
})
// Calcular valores por día
// Calcular valores por día (ya son 1 registro por día)
const fechaInicio = new Date(cosechaDef.fechaInicio)
const valoresPorDia: number[] = []
ingresosOrdenados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!)
registrosOrdenados.forEach(registro => {
const fechaStr = registro.fecha || registro.created_at!
const fecha = new Date(fechaStr)
const diaRelativo = Math.floor((fecha.getTime() - fechaInicio.getTime()) / (1000 * 60 * 60 * 24))
// Asegurar que el array tenga suficiente espacio
@@ -567,21 +694,26 @@ const datosCosechas = computed(() => {
valoresPorDia.push(0)
}
// Calcular valor según métrica
// Obtener valor según métrica (ya están agregados por día)
let valor = 0
if (metrica.value === 'peso') {
valor = ingreso.peso_neto
} else if (metrica.value === 'cantidad') {
valor = 1
} else if (metrica.value === 'inversion') {
if (ingreso.tipo === 'uva' || ingreso.tipo === 'verde') {
valor = ingreso.precio * ingreso.peso_neto
} else if (ingreso.tipo === 'oreado' || ingreso.tipo === 'mojado') {
valor = (ingreso.precio / 2) * (ingreso.peso_seco || 0)
}
if (metrica.value === 'total_peso_seco') {
valor = registro.total_peso_seco || 0
} else if (metrica.value === 'peso_neto_uva') {
valor = registro.peso_neto_uva || 0
} else if (metrica.value === 'peso_neto_verde') {
valor = registro.peso_neto_verde || 0
} else if (metrica.value === 'sacos_total_dia') {
valor = registro.sacos_total_dia || 0
} else if (metrica.value === 'total_lempiras_uva') {
valor = registro.total_lempiras_uva || 0
} else if (metrica.value === 'total_lempiras_verde') {
valor = registro.total_lempiras_verde || 0
} else if (metrica.value === 'total_lempiras_mojado_oreado') {
// Combinar mojado + oreado
valor = (registro.total_lempiras_mojado || 0) + (registro.total_lempiras_oreado || 0)
}
valoresPorDia[diaRelativo] += valor
valoresPorDia[diaRelativo] = valor
})
// Calcular total considerando el rango seleccionado
@@ -615,17 +747,30 @@ const datosCosechas = computed(() => {
}
}
// Calcular total acumulado hasta la fecha actual (día relativo de hoy)
let totalALaFecha: number | null = null
// Solo calcular si el día actual está dentro del rango de datos disponibles
if (diaActualRelativo.value >= 0 && diaActualRelativo.value < valoresPorDia.length) {
totalALaFecha = 0
for (let i = 0; i <= diaActualRelativo.value; i++) {
totalALaFecha += valoresPorDia[i] || 0
}
}
return {
id: cosechaId,
label: cosechaDef.label.replace('Cosecha ', ''),
valoresPorDia,
total
total,
totalALaFecha
}
}).filter(Boolean) as Array<{
id: string
label: string
valoresPorDia: number[]
total: number
totalALaFecha: number | null
}>
})
@@ -784,22 +929,28 @@ function hideTooltip() {
}
function formatTooltipValue(valor: number): string {
if (metrica.value === 'peso') {
return `${valor.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })} qq`
} else if (metrica.value === 'cantidad') {
return `${valor.toLocaleString('es-HN', { maximumFractionDigits: 0 })} ingresos`
const formatted = valor.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })
if (metrica.value === 'total_peso_seco' || metrica.value === 'peso_neto_uva' || metrica.value === 'peso_neto_verde') {
return `${formatted} qq`
} else if (metrica.value === 'sacos_total_dia') {
return `${Math.round(valor).toLocaleString('es-HN')} sacos`
} else {
return `L ${valor.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`
// Lempiras
return `L ${formatted}`
}
}
function formatTotal(total: number): string {
if (metrica.value === 'peso') {
return `${total.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })} qq`
} else if (metrica.value === 'cantidad') {
return `${total.toLocaleString('es-HN', { maximumFractionDigits: 0 })} ing.`
const formatted = total.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })
if (metrica.value === 'total_peso_seco' || metrica.value === 'peso_neto_uva' || metrica.value === 'peso_neto_verde') {
return `${formatted} qq`
} else if (metrica.value === 'sacos_total_dia') {
return `${Math.round(total).toLocaleString('es-HN')} sac.`
} else {
return `L ${total.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`
// Lempiras
return `L ${formatted}`
}
}
@@ -863,6 +1014,19 @@ function clearRangoSeleccionado() {
primerDiaSeleccionado.value = null
}
// Función para cancelar la selección con click derecho
function cancelarSeleccion(event: MouseEvent) {
// Prevenir el menú contextual del navegador
event.preventDefault()
// Resetear completamente la selección
rangoSeleccionado.value = null
primerDiaSeleccionado.value = null
// Ocultar tooltip si está visible
hideTooltip()
}
// Función para formatear fecha en formato legible
function formatRangoFecha(diaRelativo: number): string {
// Obtener la primera cosecha para calcular la fecha