Files
analiticaNucleo/nuxt4-app/app/components/comparativa/CosechasHeatmap.vue
2025-10-01 05:04:00 -06:00

849 lines
30 KiB
Vue

<template>
<UCard
ref="cardContainer"
class="brand-card border border-transparent transition-all"
:class="{ 'fullscreen-mode': isFullscreen }"
>
<template #header>
<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]" />
<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>
<div class="flex items-center gap-3 flex-wrap">
<label class="text-xs text-[var(--brand-text-muted)]">Vista:</label>
<div class="flex gap-2">
<button
@click="vistaMode = 'heatmap'"
class="px-3 py-1 rounded text-xs transition-all"
:class="vistaMode === 'heatmap'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Heatmap
</button>
<button
@click="vistaMode = 'barras'"
class="px-3 py-1 rounded text-xs transition-all"
:class="vistaMode === 'barras'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Barras
</button>
</div>
<label class="text-xs text-[var(--brand-text-muted)] ml-4">Selección:</label>
<div class="flex gap-2 items-center">
<!-- Rango completo seleccionado -->
<div v-if="rangoSeleccionado" class="flex items-center gap-2 px-3 py-1 rounded-lg bg-[#c08040]/20 border border-[#c08040]/50">
<span class="text-xs text-[var(--brand-text)]">
{{ formatRangoFecha(rangoSeleccionado.diaDesde) }} - {{ formatRangoFecha(rangoSeleccionado.diaHasta) }}
</span>
<span class="text-xs text-[var(--brand-text-muted)]">
({{ rangoSeleccionado.diasTotales }} días)
</span>
</div>
<!-- Primera celda seleccionada -->
<div v-else-if="primerDiaSeleccionado !== null" class="flex items-center gap-2 px-3 py-1 rounded-lg bg-blue-500/20 border border-blue-500/50">
<UIcon name="i-lucide-target" class="size-3 text-blue-400" />
<span class="text-xs text-[var(--brand-text)]">
{{ formatRangoFecha(primerDiaSeleccionado) }}
</span>
<span class="text-xs text-blue-400">
(Día {{ primerDiaSeleccionado }})
</span>
</div>
<!-- Estado inicial -->
<span v-else class="text-xs text-[var(--brand-text-muted)] italic">
Click en una celda para iniciar selección
</span>
<UButton
v-if="rangoSeleccionado || primerDiaSeleccionado !== null"
@click="clearRangoSeleccionado"
size="xs"
color="neutral"
variant="ghost"
icon="i-lucide-x"
/>
</div>
<label class="text-xs text-[var(--brand-text-muted)] ml-4">Métrica:</label>
<div class="flex gap-2">
<button
@click="metrica = 'peso'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'peso'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Peso (qq)
</button>
<button
@click="metrica = 'cantidad'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'cantidad'
? 'bg-[#c08040] text-[#1b1209]'
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
>
Cantidad
</button>
<button
@click="metrica = 'inversion'"
class="px-3 py-1 rounded text-xs transition-all"
:class="metrica === 'inversion'
? '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
</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 overflow-x-auto">
<div class="min-w-max">
<!-- Vista Heatmap -->
<template v-if="vistaMode === 'heatmap'">
<!-- 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"
:style="{ width: `${cellWidth}px` }"
>
<div class="text-xs font-medium text-[var(--brand-text)] mb-1">
{{ cosecha.label }}
</div>
<div class="text-xs text-[var(--brand-text-muted)]">
{{ formatTotal(cosecha.total) }}
</div>
</div>
</div>
<!-- Grid de celdas con scroll vertical -->
<div class="relative max-h-[600px] overflow-y-auto">
<!-- Etiquetas de día cada 10 días -->
<div class="absolute left-0 top-0 z-10">
<div
v-for="(dia, index) in etiquetasDias"
:key="`label-${dia}`"
class="text-xs text-[var(--brand-text-muted)] text-right pr-2 bg-[var(--brand-bg)]"
:style="{
height: `${cellHeight * 10 + 4.5}px`,
lineHeight: `${cellHeight}px`,
width: '60px',
paddingTop: `${cellHeight * 5}px`
}"
>
{{ dia }}
</div>
</div>
<!-- Columnas de celdas por cosecha -->
<div class="flex gap-1 ml-16">
<div
v-for="(cosecha, cosechaIndex) in datosCosechas"
:key="cosecha.id"
class="flex flex-col"
>
<div
v-for="dia in maxDias"
:key="`${cosecha.id}-${dia}`"
class="border-r border-b transition-all cursor-pointer relative"
:class="[
// Rango seleccionado (naranja)
isInSelectedRange(dia - 1)
? 'ring-2 ring-[#c08040] border-[#c08040] z-10'
: // Primera celda seleccionada (azul)
primerDiaSeleccionado === dia - 1 && rangoSeleccionado === null
? 'ring-2 ring-blue-500 border-blue-500 z-10'
: // Default
'border-[var(--brand-border)]/30 hover:ring-1 hover:ring-[#c08040]/50'
]"
:style="{
width: `${cellWidth}px`,
height: `${cellHeight}px`,
backgroundColor: getCellColor(cosecha.valoresPorDia[dia - 1] || 0, maxValorDia)
}"
@click="handleCellClick(dia - 1)"
@mouseenter="showTooltip($event, cosecha, dia - 1)"
@mouseleave="hideTooltip"
>
<!-- Marcador visual para primera celda seleccionada -->
<div
v-if="primerDiaSeleccionado === dia - 1 && rangoSeleccionado === null"
class="absolute inset-0 bg-blue-500/30 pointer-events-none"
/>
</div>
</div>
</div>
</div>
</template>
<!-- Vista Barras -->
<template v-else-if="vistaMode === 'barras'">
<!-- 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"
:style="{ width: `${barMaxWidth}px` }"
>
<div class="text-xs font-medium mb-1" :style="{ color: getCosechaColor(index) }">
{{ cosecha.label }}
</div>
<div class="text-xs text-[var(--brand-text-muted)]">
{{ formatTotal(cosecha.total) }}
</div>
</div>
</div>
<!-- Grid de barras con scroll vertical -->
<div class="relative max-h-[600px] overflow-y-auto">
<!-- Etiquetas de día cada 10 días -->
<div class="absolute left-0 top-0 z-10">
<div
v-for="(dia, index) in etiquetasDias"
:key="`label-${dia}`"
class="text-xs text-[var(--brand-text-muted)] text-right pr-2 bg-[var(--brand-bg)]"
:style="{
height: `${barHeight * 10}px`,
lineHeight: `${barHeight}px`,
width: '60px',
paddingTop: `${barHeight * 5}px`
}"
>
{{ dia }}
</div>
</div>
<!-- Columnas de barras por cosecha -->
<div class="flex gap-1 ml-16">
<div
v-for="(cosecha, cosechaIndex) in datosCosechas"
:key="cosecha.id"
class="flex flex-col"
:style="{ width: `${barMaxWidth}px` }"
>
<div
v-for="dia in maxDias"
:key="`${cosecha.id}-${dia}`"
class="relative border-b cursor-pointer group"
:class="[
// Rango seleccionado (naranja)
isInSelectedRange(dia - 1)
? 'border-[#c08040] border-2 bg-[#c08040]/10'
: // Primera celda seleccionada (azul)
primerDiaSeleccionado === dia - 1 && rangoSeleccionado === null
? 'border-blue-500 border-2 bg-blue-500/10'
: // Default
'border-[var(--brand-border)]/20'
]"
:style="{ height: `${barHeight}px` }"
@click="handleCellClick(dia - 1)"
@mouseenter="showTooltip($event, cosecha, dia - 1)"
@mouseleave="hideTooltip"
>
<!-- Barra horizontal -->
<div
class="absolute left-0 top-1/2 -translate-y-1/2 transition-all group-hover:opacity-90"
:style="{
width: getBarWidth(cosecha.valoresPorDia[dia - 1] || 0),
height: `${barHeight - 2}px`,
backgroundColor: getCosechaColor(cosechaIndex)
}"
/>
</div>
</div>
</div>
</div>
</template>
<!-- Tooltip -->
<Teleport to="body">
<div
v-if="tooltipVisible"
class="fixed z-50 px-3 py-2 text-xs rounded-lg border pointer-events-none shadow-lg opacity-100"
:class="'bg-[var(--brand-bg-secondary)] border-[var(--brand-border)] text-[var(--brand-text)]'"
:style="{
left: `${tooltipX}px`,
top: `${tooltipY}px`,
backgroundColor: 'rgb(29, 26, 19)',
opacity: 1
}"
>
<div class="font-semibold mb-1">{{ tooltipData.cosecha }}</div>
<div class="text-[var(--brand-text-muted)] mb-1 capitalize">
{{ tooltipData.fecha }}
</div>
<div class="text-xs text-[var(--brand-text-muted)] mb-1">
Día {{ tooltipData.dia }}
</div>
<div class="font-medium">
{{ tooltipData.valor }}
</div>
</div>
</Teleport>
<!-- Leyenda de colores -->
<div class="flex items-center gap-2 mt-4">
<!-- Leyenda para Heatmap -->
<template v-if="vistaMode === 'heatmap'">
<span class="text-xs text-[var(--brand-text-muted)]">Intensidad:</span>
<div class="flex gap-1">
<div
v-for="i in 10"
:key="`legend-${i}`"
class="w-6 h-4 border border-[var(--brand-border)] rounded"
:style="{ backgroundColor: getCellColor(i * 10, 100) }"
/>
</div>
<span class="text-xs text-[var(--brand-text-muted)]">0% 100%</span>
</template>
<!-- Leyenda para Barras -->
<template v-else-if="vistaMode === 'barras'">
<span class="text-xs text-[var(--brand-text-muted)]">Cosechas:</span>
<div class="flex gap-3 flex-wrap">
<div
v-for="(cosecha, index) in datosCosechas"
:key="`legend-bar-${cosecha.id}`"
class="flex items-center gap-1"
>
<div
class="w-4 h-3 rounded"
:style="{ backgroundColor: getCosechaColor(index) }"
/>
<span class="text-xs text-[var(--brand-text-muted)]">{{ cosecha.label }}</span>
</div>
</div>
</template>
</div>
<!-- Panel de Comparación Detallada (solo visible cuando hay rango seleccionado) -->
<div v-if="rangoSeleccionado" class="mt-6 pt-6 border-t border-[var(--brand-border)]">
<div class="flex items-center gap-2 mb-4">
<UIcon name="i-lucide-bar-chart-3" class="size-5 text-[#c08040]" />
<h4 class="text-sm font-semibold text-[var(--brand-text)]">
Comparativa del Rango Seleccionado
</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="(cosecha, index) in datosCosechas"
:key="`comp-${cosecha.id}`"
class="flex flex-col gap-2 p-4 rounded-lg border border-[var(--brand-border)] bg-[var(--brand-bg-secondary)]"
>
<!-- Header de la cosecha -->
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-[var(--brand-text)]">
{{ cosecha.label }}
</span>
<div
class="w-3 h-3 rounded-full"
:style="{ backgroundColor: getCosechaColor(index) }"
/>
</div>
<!-- Valor total -->
<div class="text-2xl font-bold" :style="{ color: getCosechaColor(index) }">
{{ formatTotal(cosecha.total) }}
</div>
<!-- Barra de progreso visual -->
<div class="h-2 bg-[var(--brand-bg)] rounded-full overflow-hidden">
<div
class="h-full transition-all"
:style="{
width: `${(cosecha.total / maxTotalEnRango) * 100}%`,
backgroundColor: getCosechaColor(index)
}"
/>
</div>
<!-- Porcentaje relativo -->
<div class="text-xs text-[var(--brand-text-muted)]">
{{ ((cosecha.total / maxTotalEnRango) * 100).toFixed(1) }}% del máximo
</div>
<!-- Diferencia con la mejor -->
<div v-if="cosecha.total < maxTotalEnRango" class="text-xs text-[var(--brand-text-muted)]">
<span class="text-red-400">
-{{ formatTotal(maxTotalEnRango - cosecha.total) }}
</span>
respecto al mayor
</div>
<div v-else class="text-xs text-green-400">
🏆 Mayor valor en el rango
</div>
</div>
</div>
</div>
</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', [])
// Referencia al contenedor y estado de fullscreen
const cardContainer = ref<HTMLElement | null>(null)
const isFullscreen = ref(false)
// Selección de vista, métrica y tipo
const vistaMode = ref<'heatmap' | 'barras'>('barras')
const metrica = ref<'peso' | 'cantidad' | 'inversion'>('peso')
const tipoSeleccionado = ref<string>('todos')
// Sistema de selección interactiva de rango
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
// Tooltip state
const tooltipVisible = ref(false)
const tooltipX = ref(0)
const tooltipY = ref(0)
const tooltipData = ref({ cosecha: '', dia: 0, valor: '', fecha: '' })
// 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)
// 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 valores por día
const fechaInicio = new Date(cosechaDef.fechaInicio)
const valoresPorDia: number[] = []
ingresosOrdenados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!)
const diaRelativo = Math.floor((fecha.getTime() - fechaInicio.getTime()) / (1000 * 60 * 60 * 24))
// Asegurar que el array tenga suficiente espacio
while (valoresPorDia.length <= diaRelativo) {
valoresPorDia.push(0)
}
// Calcular valor según métrica
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)
}
}
valoresPorDia[diaRelativo] += valor
})
// Calcular total considerando el rango seleccionado
let total = 0
// Si no hay rango seleccionado, sumar todo
if (!rangoSeleccionado.value) {
total = valoresPorDia.reduce((sum, val) => sum + val, 0)
} else {
// Usar directamente los días relativos del rango seleccionado
const desdeRelativo = rangoSeleccionado.value.diaDesde
const hastaRelativo = rangoSeleccionado.value.diaHasta
// Detectar si el rango cruza el final/inicio de la cosecha
const cruzaAnio = desdeRelativo > hastaRelativo
// Sumar valores en el rango
if (cruzaAnio) {
// Rango que cruza: desde "desde" hasta el final + desde inicio hasta "hasta"
for (let i = desdeRelativo; i < valoresPorDia.length; i++) {
total += valoresPorDia[i] || 0
}
for (let i = 0; i <= hastaRelativo && i < valoresPorDia.length; i++) {
total += valoresPorDia[i] || 0
}
} else {
// Rango normal
for (let i = desdeRelativo; i <= hastaRelativo && i < valoresPorDia.length; i++) {
total += valoresPorDia[i] || 0
}
}
}
return {
id: cosechaId,
label: cosechaDef.label.replace('Cosecha ', ''),
valoresPorDia,
total
}
}).filter(Boolean) as Array<{
id: string
label: string
valoresPorDia: number[]
total: number
}>
})
// Máximo número de días entre todas las cosechas
const maxDias = computed(() => {
let max = 0
datosCosechas.value.forEach(cosecha => {
if (cosecha.valoresPorDia.length > max) {
max = cosecha.valoresPorDia.length
}
})
return max || 365
})
// Máximo valor en un día (para normalizar colores)
const maxValorDia = computed(() => {
let max = 0
datosCosechas.value.forEach(cosecha => {
cosecha.valoresPorDia.forEach(valor => {
if (valor > max) {
max = valor
}
})
})
return max || 1
})
// Etiquetas de días (cada 10 días)
const etiquetasDias = computed(() => {
const labels: number[] = []
for (let i = 0; i < maxDias.value; i += 10) {
labels.push(i)
}
return labels
})
// Máximo total en el rango seleccionado (para la comparación)
const maxTotalEnRango = computed(() => {
if (!rangoSeleccionado.value) return 1
let max = 0
datosCosechas.value.forEach(cosecha => {
if (cosecha.total > max) {
max = cosecha.total
}
})
return max || 1
})
// Función para convertir mes-día a día del año (0-365)
function getDiaDelAnio(mes: number, dia: number, esBisiesto: boolean): number {
const diasPorMes = [31, esBisiesto ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
let diaDelAnio = 0
for (let i = 0; i < mes - 1; i++) {
diaDelAnio += diasPorMes[i]
}
diaDelAnio += dia - 1 // -1 porque empezamos en día 0
return diaDelAnio
}
// Función para obtener color de celda según intensidad (heatmap)
function getCellColor(valor: number, max: number): string {
const intensity = max > 0 ? valor / max : 0
// Escala de color desde transparente a naranja intenso
if (intensity === 0) {
return 'rgba(192, 128, 64, 0.05)' // Muy suave para vacío
}
// Gradiente de naranja con opacidad basada en intensidad
const r = 192
const g = Math.floor(128 + (intensity * (217 - 128))) // 128 -> 217
const b = Math.floor(64 + (intensity * (86 - 64))) // 64 -> 86
const alpha = 0.2 + (intensity * 0.8) // 0.2 -> 1.0
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
]
// Función para obtener color fijo de cosecha
function getCosechaColor(index: number): string {
return cosechaColors[index % cosechaColors.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
return `${width}px`
}
// Tooltip functions
function showTooltip(event: MouseEvent, cosecha: any, diaIndex: number) {
const valor = cosecha.valoresPorDia[diaIndex] || 0
// Obtener la fecha real del día
const cosechaDef = cosechasDisponibles.find(c => c.id === cosecha.id)
let fechaFormateada = ''
if (cosechaDef) {
const fechaInicio = new Date(cosechaDef.fechaInicio)
const fechaDia = new Date(fechaInicio)
fechaDia.setDate(fechaDia.getDate() + diaIndex)
fechaFormateada = fechaDia.toLocaleDateString('es-HN', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
tooltipData.value = {
cosecha: cosecha.label,
dia: diaIndex,
valor: formatTooltipValue(valor),
fecha: fechaFormateada
}
tooltipX.value = event.clientX + 10
tooltipY.value = event.clientY + 10
tooltipVisible.value = true
}
function hideTooltip() {
tooltipVisible.value = false
}
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`
} else {
return `L ${valor.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`
}
}
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.`
} else {
return `L ${total.toLocaleString('es-HN', { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`
}
}
// Función para manejar clic en celda
function handleCellClick(diaIndex: number) {
// Si ya hay un rango seleccionado, limpiar todo y empezar de nuevo con este click
if (rangoSeleccionado.value !== null) {
rangoSeleccionado.value = null
primerDiaSeleccionado.value = diaIndex
return
}
// Si no hay primera celda seleccionada, este es el primer click
if (primerDiaSeleccionado.value === null) {
primerDiaSeleccionado.value = diaIndex
return
}
// Si ya hay una primera celda, este es el segundo click → crear el rango
const desde = primerDiaSeleccionado.value
const hasta = diaIndex
// Calcular total de días considerando si cruza el año
let diasTotales = 0
if (desde <= hasta) {
diasTotales = hasta - desde + 1
} else {
// Cruza el año: desde "desde" hasta maxDias + desde 0 hasta "hasta"
diasTotales = (maxDias.value - desde) + hasta + 1
}
rangoSeleccionado.value = {
diaDesde: desde,
diaHasta: hasta,
diasTotales
}
// Resetear la primera selección (pero mantener el rango)
primerDiaSeleccionado.value = null
}
// Función para verificar si un día está en el rango seleccionado
function isInSelectedRange(diaIndex: number): boolean {
if (!rangoSeleccionado.value) return false
const desde = rangoSeleccionado.value.diaDesde
const hasta = rangoSeleccionado.value.diaHasta
if (desde <= hasta) {
// Rango normal
return diaIndex >= desde && diaIndex <= hasta
} else {
// Rango que cruza el año
return diaIndex >= desde || diaIndex <= hasta
}
}
// Función para limpiar el rango seleccionado
function clearRangoSeleccionado() {
rangoSeleccionado.value = null
primerDiaSeleccionado.value = null
}
// Función para formatear fecha en formato legible
function formatRangoFecha(diaRelativo: number): string {
// Obtener la primera cosecha para calcular la fecha
const primeraCosecha = cosechasDisponibles.find(c => c.id === props.cosechasSeleccionadas[0])
if (!primeraCosecha) return `Día ${diaRelativo}`
const fechaInicio = new Date(primeraCosecha.fechaInicio)
const fechaDia = new Date(fechaInicio)
fechaDia.setDate(fechaDia.getDate() + diaRelativo)
return fechaDia.toLocaleDateString('es-HN', {
day: 'numeric',
month: 'short'
})
}
// Funciones de pantalla completa
async function toggleFullscreen() {
if (!cardContainer.value) return
try {
if (!document.fullscreenElement) {
// Entrar a pantalla completa
await cardContainer.value.$el.requestFullscreen()
isFullscreen.value = true
} else {
// Salir de pantalla completa
await document.exitFullscreen()
isFullscreen.value = false
}
} catch (error) {
console.error('Error al cambiar modo pantalla completa:', error)
}
}
// Listener para detectar cuando el usuario sale de fullscreen con ESC
function handleFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
// Montar y desmontar listeners
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
})
</script>
<style scoped>
.fullscreen-mode {
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
overflow: auto !important;
background-color: var(--brand-bg) !important;
}
.fullscreen-mode :deep(.max-h-\[600px\]) {
max-height: calc(100vh - 300px) !important;
}
</style>