1164 lines
42 KiB
Vue
1164 lines
42 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-activity" class="size-5 text-[#c08040]" />
|
|
<h3 class="text-base font-semibold text-[var(--brand-text)]">Vista de Calor por Día</h3>
|
|
</div>
|
|
<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>
|
|
<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>
|
|
<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 derecho para cancelar
|
|
</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 flex-wrap">
|
|
<button
|
|
@click="metrica = 'total_peso_seco'"
|
|
class="px-3 py-1 rounded text-xs transition-all"
|
|
: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 Seco (qq)
|
|
</button>
|
|
<button
|
|
@click="metrica = 'peso_neto_uva'"
|
|
class="px-3 py-1 rounded text-xs transition-all"
|
|
:class="metrica === 'peso_neto_uva'
|
|
? 'bg-[#c08040] text-[#1b1209]'
|
|
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
|
|
>
|
|
Peso Neto Uva (qq)
|
|
</button>
|
|
<button
|
|
@click="metrica = 'peso_neto_verde'"
|
|
class="px-3 py-1 rounded text-xs transition-all"
|
|
:class="metrica === 'peso_neto_verde'
|
|
? 'bg-[#c08040] text-[#1b1209]'
|
|
: 'bg-[var(--brand-bg-secondary)] text-[var(--brand-text-muted)] hover:text-[var(--brand-text)]'"
|
|
>
|
|
Peso Neto Verde (qq)
|
|
</button>
|
|
<button
|
|
@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)]'"
|
|
>
|
|
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>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="w-full overflow-x-auto">
|
|
<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 de la cosecha</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 la fecha</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 gap-1 p-2 rounded-lg bg-[var(--brand-bg-secondary)]"
|
|
:style="{ width: `${cellWidth}px` }"
|
|
>
|
|
<div class="text-xs font-semibold text-[var(--brand-text)]">
|
|
{{ cosecha.label }}
|
|
</div>
|
|
<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 && cosecha.totalALaFecha > 0"
|
|
class="text-[10px] text-blue-400 font-medium"
|
|
title="Acumulado hasta la fecha actual"
|
|
>
|
|
↗ {{ formatTotal(cosecha.totalALaFecha || 0) }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="text-[10px] text-gray-500 italic"
|
|
title="Sin datos hasta la fecha [actual]"
|
|
>
|
|
0
|
|
</div>
|
|
</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)"
|
|
@contextmenu.prevent="cancelarSeleccion"
|
|
@mouseenter="showTooltip($event, cosecha, dia - 1)"
|
|
@mouseleave="hideTooltip"
|
|
>
|
|
<!-- Overlay gris para días ya transcurridos -->
|
|
<div
|
|
v-if="isDiaPasado(cosecha.id, dia - 1)"
|
|
class="absolute inset-0 bg-gray-400/15 pointer-events-none"
|
|
/>
|
|
|
|
<!-- 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'">
|
|
<!-- 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 de la cosecha</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 la fecha</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 gap-1 p-2 rounded-lg bg-[var(--brand-bg-secondary)]"
|
|
:style="{ width: `${barMaxWidth}px` }"
|
|
>
|
|
<div class="text-xs font-semibold" :style="{ color: getCosechaColor(cosecha.id) }">
|
|
{{ cosecha.label }}
|
|
</div>
|
|
<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 && cosecha.totalALaFecha > 0"
|
|
class="text-[10px] text-blue-400 font-medium"
|
|
title="Acumulado hasta la fecha actual"
|
|
>
|
|
↗ {{ formatTotal(cosecha.totalALaFecha || 0) }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="text-[10px] text-gray-500 italic"
|
|
title="Sin datos hasta la fecha actual"
|
|
>
|
|
{{ formatTotal(cosecha.totalALaFecha || 0) }}
|
|
</div>
|
|
</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)"
|
|
@contextmenu.prevent="cancelarSeleccion"
|
|
@mouseenter="showTooltip($event, cosecha, dia - 1)"
|
|
@mouseleave="hideTooltip"
|
|
>
|
|
<!-- Overlay gris para días ya transcurridos -->
|
|
<div
|
|
v-if="isDiaPasado(cosecha.id, dia - 1)"
|
|
class="absolute inset-0 bg-gray-400/15 pointer-events-none"
|
|
/>
|
|
|
|
<!-- 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(cosecha.id)
|
|
}"
|
|
/>
|
|
</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(cosecha.id) }"
|
|
/>
|
|
<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(cosecha.id) }"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Valor total -->
|
|
<div class="text-2xl font-bold" :style="{ color: getCosechaColor(cosecha.id) }">
|
|
{{ 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(cosecha.id)
|
|
}"
|
|
/>
|
|
</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">
|
|
// 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: ResumenIngresoRecord[]
|
|
cosechasSeleccionadas: string[]
|
|
}
|
|
|
|
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 y métrica
|
|
const vistaMode = ref<'heatmap' | 'barras'>('barras')
|
|
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)
|
|
const rangoSeleccionado = ref<{ diaDesde: number, diaHasta: number, diasTotales: number } | null>(null)
|
|
|
|
// 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)
|
|
const tooltipX = ref(0)
|
|
const tooltipY = ref(0)
|
|
const tooltipData = ref({ cosecha: '', dia: 0, valor: '', fecha: '' })
|
|
|
|
// Día y mes actual para comparar
|
|
const fechaActual = computed(() => {
|
|
const hoy = new Date()
|
|
// Normalizar a medianoche para comparaciones
|
|
hoy.setHours(0, 0, 0, 0)
|
|
return {
|
|
dia: hoy.getDate(),
|
|
mes: hoy.getMonth(), // 0-11
|
|
fecha: hoy,
|
|
formatted: hoy.toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
|
|
}
|
|
})
|
|
|
|
// Calcular cuántos días han pasado desde el 7 de septiembre de este año
|
|
const diasTranscurridosDesdeInicio = computed(() => {
|
|
const hoy = new Date()
|
|
hoy.setHours(0, 0, 0, 0)
|
|
|
|
// 7 de septiembre del año actual
|
|
const inicioDelAnio = new Date(hoy.getFullYear(), 8, 7) // mes 8 = septiembre (0-indexed)
|
|
inicioDelAnio.setHours(0, 0, 0, 0)
|
|
|
|
// Si todavía no llegamos al 7 de septiembre de este año, usar el del año pasado
|
|
if (hoy < inicioDelAnio) {
|
|
inicioDelAnio.setFullYear(inicioDelAnio.getFullYear() - 1)
|
|
}
|
|
|
|
// Calcular diferencia en días
|
|
const diffMs = hoy.getTime() - inicioDelAnio.getTime()
|
|
const diffDias = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
|
|
return diffDias
|
|
})
|
|
|
|
// Función para verificar si un día ya pasó (basado en días desde el inicio, no en año)
|
|
function isDiaPasado(cosechaId: string, diaIndex: number): boolean {
|
|
return diaIndex <= diasTranscurridosDesdeInicio.value
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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)
|
|
|
|
return fecha >= inicio && fecha <= fin
|
|
})
|
|
|
|
// Ordenar por fecha
|
|
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 (ya son 1 registro por día)
|
|
const fechaInicio = new Date(cosechaDef.fechaInicio)
|
|
const valoresPorDia: number[] = []
|
|
|
|
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
|
|
while (valoresPorDia.length <= diaRelativo) {
|
|
valoresPorDia.push(0)
|
|
}
|
|
|
|
// Obtener valor según métrica (ya están agregados por día)
|
|
let valor = 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
|
|
})
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calcular total acumulado hasta la fecha actual (mismo día/mes pero del año de cada cosecha)
|
|
let totalALaFecha: number | null = null
|
|
|
|
// Obtener el año de inicio de esta cosecha
|
|
const anioInicioCosecha = fechaInicio.getFullYear()
|
|
|
|
// Construir la fecha objetivo: mismo día y mes de hoy, pero del año de inicio de esta cosecha
|
|
// Ej: Si hoy es 1 oct 2025 y la cosecha inició en sep 2023, usamos 1 oct 2023
|
|
const fechaObjetivoCosecha = new Date(anioInicioCosecha, fechaActual.value.mes, fechaActual.value.dia)
|
|
|
|
// Calcular el día relativo desde el inicio de la cosecha hasta esa fecha objetivo
|
|
const diaObjetivo = Math.floor((fechaObjetivoCosecha.getTime() - fechaInicio.getTime()) / (1000 * 60 * 60 * 24))
|
|
|
|
// Acumular desde el inicio de la cosecha hasta la fecha objetivo
|
|
totalALaFecha = 0
|
|
if (diaObjetivo >= 0) {
|
|
// Sumar todos los días desde el inicio hasta la fecha objetivo (o hasta donde haya datos)
|
|
const limiteSuperior = Math.min(diaObjetivo, valoresPorDia.length - 1)
|
|
for (let i = 0; i <= limiteSuperior; i++) {
|
|
totalALaFecha += valoresPorDia[i] || 0
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: cosechaId,
|
|
label: cosechaDef.label.replace('Cosecha ', ''),
|
|
valoresPorDia,
|
|
total,
|
|
totalALaFecha
|
|
}
|
|
}).filter(Boolean) as Array<{
|
|
id: string
|
|
label: string
|
|
valoresPorDia: number[]
|
|
total: number
|
|
totalALaFecha: number | null
|
|
}>
|
|
})
|
|
|
|
// 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] || 0
|
|
}
|
|
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, ${estilosGraficas.value.opacidadVacias})` // Opacidad configurable
|
|
}
|
|
|
|
// 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})`
|
|
}
|
|
|
|
// 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 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.value // .value es necesario en el script setup
|
|
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 {
|
|
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 {
|
|
// Lempiras
|
|
return `L ${formatted}`
|
|
}
|
|
}
|
|
|
|
function formatTotal(total: number): string {
|
|
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 {
|
|
// Lempiras
|
|
return `L ${formatted}`
|
|
}
|
|
}
|
|
|
|
// 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 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
|
|
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 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
|
|
|
|
try {
|
|
if (!document.fullscreenElement) {
|
|
// Entrar a pantalla completa
|
|
await cardContainer.value.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
|
|
}
|
|
|
|
// 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
|
|
if (container) {
|
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
|
|
|
// Remover listener de zoom
|
|
const container = cardContainer.value
|
|
if (container) {
|
|
container.removeEventListener('wheel', handleWheel)
|
|
}
|
|
})
|
|
</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>
|