mejora ui x exageradas
This commit is contained in:
BIN
Untitled.png
Normal file
BIN
Untitled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -26,11 +26,11 @@
|
||||
<div class="grid grid-cols-2 gap-4 flex-1">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--brand-text-muted)]">Fecha desde</label>
|
||||
<UInput :model-value="fechaDesde" type="date" @input="onManualDateChange" />
|
||||
<UInput :model-value="fechaDesde" type="date" @input="(e) => onManualDateChange('desde', e)" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--brand-text-muted)]">Fecha hasta</label>
|
||||
<UInput :model-value="fechaHasta" type="date" @input="onManualDateChange" />
|
||||
<UInput :model-value="fechaHasta" type="date" @input="(e) => onManualDateChange('hasta', e)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,24 +114,21 @@ function selectPreset(preset: PresetValue) {
|
||||
}
|
||||
}
|
||||
|
||||
function onManualDateChange(event: Event) {
|
||||
function onManualDateChange(type: 'desde' | 'hasta', event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value
|
||||
const value = target.value || null
|
||||
|
||||
// Si el usuario modifica las fechas manualmente, cambiar a "Personalizado"
|
||||
emit('update:selectedPreset', 'custom')
|
||||
|
||||
// Actualizar la fecha correspondiente según el input
|
||||
if (target.type === 'date') {
|
||||
const label = target.labels?.[0]?.textContent
|
||||
if (label?.includes('desde')) {
|
||||
emit('update:fechaDesde', value)
|
||||
} else if (label?.includes('hasta')) {
|
||||
emit('update:fechaHasta', value)
|
||||
}
|
||||
// Actualizar la fecha correspondiente
|
||||
if (type === 'desde') {
|
||||
emit('update:fechaDesde', value)
|
||||
} else {
|
||||
emit('update:fechaHasta', value)
|
||||
}
|
||||
|
||||
console.log('Manual date change, preset set to custom')
|
||||
console.log(`Manual date change (${type}):`, value, 'preset set to custom')
|
||||
}
|
||||
|
||||
function clearPreset() {
|
||||
|
||||
@@ -1,20 +1,106 @@
|
||||
<template>
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold brand-section-title">Tabla {{ metadata.table }}</h2>
|
||||
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
|
||||
{{ formatNumber(recordCount) }} registros en memoria
|
||||
<UCard
|
||||
class="brand-card cursor-pointer transition-all duration-300"
|
||||
:class="[
|
||||
compact ? 'border-0 !p-0' : 'border border-transparent',
|
||||
compact && tableStore?.isStale ? 'bg-yellow-500/10 border-l-4 !border-l-yellow-500' : ''
|
||||
]"
|
||||
@click="toggleCompact"
|
||||
:ui="compact ? { body: { padding: '0' }, header: { padding: 'px-3 py-2' }, footer: { padding: '0' } } : {}"
|
||||
>
|
||||
<template v-if="compact" #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- Stale Data Warning (when applicable) -->
|
||||
<div v-if="tableStore?.isStale" class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 bg-yellow-500/20 border border-yellow-500/50 rounded px-2 py-0.5 animate-pulse">
|
||||
<UIcon name="i-lucide-alert-triangle" class="text-yellow-400 text-sm flex-shrink-0" />
|
||||
<span class="text-xs font-bold text-yellow-300 whitespace-nowrap">
|
||||
DATOS DESACTUALIZADOS
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)] font-medium truncate">
|
||||
{{ metadata.table }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="metadata.description" class="text-sm text-[var(--brand-text-muted)]">
|
||||
{{ metadata.description }}
|
||||
</p>
|
||||
|
||||
<!-- Normal State -->
|
||||
<div v-else class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h2 class="text-xs font-medium brand-section-title truncate">
|
||||
{{ metadata.table }}
|
||||
</h2>
|
||||
<span class="text-[10px] text-[var(--brand-text-muted)] font-medium whitespace-nowrap">
|
||||
{{ formatNumber(recordCount) }} reg
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<UButton
|
||||
:loading="isLoadingLatest"
|
||||
:disabled="isLoadingAll"
|
||||
:ui="{ base: tableStore?.isStale ? 'bg-yellow-500 text-black border-0 hover:bg-yellow-400 font-bold' : 'bg-[#c08040] text-[#1b1209] border-0 hover:bg-[#d99a56]' }"
|
||||
size="2xs"
|
||||
icon="i-lucide-clock"
|
||||
@click.stop="loadLatestData"
|
||||
:class="{ 'animate-spin': isLoadingLatest, 'animate-bounce': tableStore?.isStale && !isLoadingLatest }"
|
||||
:title="tableStore?.isStale ? '¡Actualizar ahora!' : 'Últimos'"
|
||||
/>
|
||||
<UButton
|
||||
:loading="isLoadingAll"
|
||||
:disabled="isLoadingLatest"
|
||||
:ui="{ base: tableStore?.isStale ? 'bg-yellow-500 text-black border-0 hover:bg-yellow-400 font-bold' : 'bg-[#c08040] text-[#1b1209] border-0 hover:bg-[#d99a56]' }"
|
||||
size="2xs"
|
||||
icon="i-lucide-database"
|
||||
@click.stop="loadAllData"
|
||||
:class="{ 'animate-spin': isLoadingAll }"
|
||||
:title="tableStore?.isStale ? '¡Actualizar ahora!' : 'Todos'"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-down"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="2xs"
|
||||
@click.stop="toggleCompact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-3 text-sm text-[var(--brand-text-muted)]">
|
||||
<template v-else #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold brand-section-title">
|
||||
Tabla {{ metadata.table }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
|
||||
{{ formatNumber(recordCount) }} registros
|
||||
</span>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-up"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@click.stop="toggleCompact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<p v-if="metadata.description" class="text-sm text-[var(--brand-text-muted)] overflow-hidden">
|
||||
{{ metadata.description }}
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal Mode Body -->
|
||||
<dl v-if="!compact" class="grid grid-cols-2 gap-3 text-sm text-[var(--brand-text-muted)]">
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Clave primaria</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ metadata.primaryKey || '—' }}</dd>
|
||||
@@ -33,7 +119,17 @@
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<template #footer>
|
||||
<!-- Compact Mode: Progress bar only -->
|
||||
<div v-if="compact && (isLoadingLatest || isLoadingAll)" class="px-3 pb-2">
|
||||
<UProgress
|
||||
:model-value="loadingProgress"
|
||||
:max="100"
|
||||
status
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="!compact" #footer>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="brand-divider pt-3 text-xs text-[var(--brand-text-muted)]">
|
||||
Columnas detectadas ({{ metadata.columns?.length || 0 }}): {{ (metadata.columns || []).join(', ') || 'Ninguna' }}
|
||||
@@ -56,7 +152,7 @@
|
||||
:disabled="isLoadingAll"
|
||||
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||
size="sm"
|
||||
@click="loadLatestData"
|
||||
@click.stop="loadLatestData"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-clock" :class="{ 'animate-spin': isLoadingLatest }" />
|
||||
@@ -69,7 +165,7 @@
|
||||
:disabled="isLoadingLatest"
|
||||
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||
size="sm"
|
||||
@click="loadAllData"
|
||||
@click.stop="loadAllData"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-database" :class="{ 'animate-spin': isLoadingAll }" />
|
||||
@@ -112,17 +208,28 @@ interface MetadataProps {
|
||||
lastRefreshed?: string
|
||||
description?: string
|
||||
}
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<MetadataProps>()
|
||||
const props = withDefaults(defineProps<MetadataProps>(), {
|
||||
compact: true
|
||||
})
|
||||
|
||||
const { $getTableStore } = useNuxtApp()
|
||||
|
||||
// Compact mode state (persistent per table)
|
||||
const compact = useState(`metadata-compact-${props.metadata.name}`, () => props.compact)
|
||||
|
||||
// Loading states
|
||||
const isLoadingLatest = ref(false)
|
||||
const isLoadingAll = ref(false)
|
||||
const loadingProgress = ref(0)
|
||||
|
||||
// Toggle compact mode
|
||||
function toggleCompact() {
|
||||
compact.value = !compact.value
|
||||
}
|
||||
|
||||
// Get the table store for this specific datasource (using name, not table)
|
||||
// The plugin has already loaded all caches, so this just retrieves the existing instance
|
||||
const tableStore = computed(() => {
|
||||
|
||||
848
nuxt4-app/app/components/comparativa/CosechasHeatmap.vue
Normal file
848
nuxt4-app/app/components/comparativa/CosechasHeatmap.vue
Normal file
@@ -0,0 +1,848 @@
|
||||
<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>
|
||||
@@ -97,8 +97,15 @@ import { upperFirst } from 'scule'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
||||
|
||||
interface ClienteRecord {
|
||||
id: number
|
||||
name: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
records: IngresoRecord[]
|
||||
clientes?: ClienteRecord[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -113,6 +120,7 @@ const dateFormat = ref<'short' | 'long'>('short')
|
||||
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
const UIcon = resolveComponent('UIcon')
|
||||
const UTooltip = resolveComponent('UTooltip')
|
||||
|
||||
function toggleDateFormat() {
|
||||
dateFormat.value = dateFormat.value === 'short' ? 'long' : 'short'
|
||||
@@ -363,6 +371,37 @@ function formatCellValue(value: unknown, column: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
// cliente_id - Badge neutral soft con ID y nombre truncado
|
||||
if (column === 'cliente_id' && typeof value === 'number') {
|
||||
const cliente = props.clientes?.find(c => c.id === value)
|
||||
const clienteNombre = cliente?.name || 'Sin nombre'
|
||||
const fullLabel = `${value} - ${clienteNombre}`
|
||||
const shouldTruncate = fullLabel.length > 30
|
||||
|
||||
const badge = h(UBadge, {
|
||||
color: 'neutral',
|
||||
variant: 'soft',
|
||||
size: 'sm',
|
||||
class: shouldTruncate ? 'max-w-[220px]' : ''
|
||||
}, {
|
||||
default: () => h('span', {
|
||||
class: shouldTruncate ? 'truncate block' : ''
|
||||
}, fullLabel)
|
||||
})
|
||||
|
||||
// Si está truncado, agregar tooltip con el texto completo
|
||||
if (shouldTruncate) {
|
||||
return h(UTooltip, {
|
||||
text: fullLabel,
|
||||
shortcuts: []
|
||||
}, {
|
||||
default: () => badge
|
||||
})
|
||||
}
|
||||
|
||||
return badge
|
||||
}
|
||||
|
||||
// pagado_id - ID en verde
|
||||
if (column === 'pagado_id' && typeof value === 'number') {
|
||||
return h(UBadge, {
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
|
||||
<template #right>
|
||||
<div class="flex items-center gap-3">
|
||||
<USwitch
|
||||
v-model="pageSections.heatmap"
|
||||
size="xs"
|
||||
label="Heatmap"
|
||||
/>
|
||||
<USwitch
|
||||
v-model="pageSections.totales"
|
||||
size="xs"
|
||||
@@ -79,6 +84,11 @@
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Vista Heatmap -->
|
||||
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.heatmap">
|
||||
<ComparativaCosechasHeatmap :ingresos="ingresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
|
||||
</div>
|
||||
|
||||
<!-- Resumen General por Cosecha -->
|
||||
<div v-if="cosechasSeleccionadas.length > 0 && pageSections.totales">
|
||||
<ComparativaCosechasTotales :ingresos="ingresos" :cosechas-seleccionadas="cosechasSeleccionadas" />
|
||||
@@ -117,9 +127,10 @@ definePageMeta({
|
||||
|
||||
// Definir secciones específicas de esta página
|
||||
const pageSections = ref({
|
||||
totales: true,
|
||||
evolucion: true,
|
||||
porTipo: true
|
||||
heatmap: true,
|
||||
totales: false,
|
||||
evolucion: false,
|
||||
porTipo: false
|
||||
})
|
||||
|
||||
// Definición de cosechas disponibles
|
||||
@@ -136,40 +147,14 @@ const cosechasDisponibles = [
|
||||
const cosechasSeleccionadas = ref<string[]>(['cosecha-23-24', 'cosecha-24-25'])
|
||||
|
||||
// Store de ingresos
|
||||
const ingresosStore = useTableDataStore('ingresos')
|
||||
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
// Datos de ingresos desde el store
|
||||
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
|
||||
|
||||
// Cargar datos
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
await ingresosStore.fetch()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Error desconocido'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Datos de ingresos
|
||||
const ingresos = computed(() => {
|
||||
return ingresosStore.data.map(row => {
|
||||
const ingreso: IngresoRecord = {
|
||||
id: row.id as number,
|
||||
created_at: row.created_at as string,
|
||||
peso_neto: row.peso_neto as number,
|
||||
precio: row.precio as number,
|
||||
tipo: row.tipo as string,
|
||||
cliente: row.cliente as string,
|
||||
peso_seco: row.peso_seco as number | undefined,
|
||||
pagado: row.pagado as boolean | undefined
|
||||
}
|
||||
return ingreso
|
||||
})
|
||||
})
|
||||
// Loading and error states
|
||||
const loading = computed(() => ingresosStore.isLoading)
|
||||
const error = computed(() => ingresosStore.error)
|
||||
|
||||
// Exportar cosechas para los componentes
|
||||
provide('cosechasDisponibles', cosechasDisponibles)
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-show="!metadatosCollapsed" class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
|
||||
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" />
|
||||
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" :compact="true" />
|
||||
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" :compact="true" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -299,6 +299,7 @@
|
||||
<IngresosVistaTablaIngresos
|
||||
v-if="selectedView === 'ingresos-only'"
|
||||
:records="ingresosFiltrados"
|
||||
:clientes="clientes"
|
||||
/>
|
||||
|
||||
<!-- Single view: Clientes -->
|
||||
@@ -665,15 +666,20 @@ function isAnulado(row: any): boolean {
|
||||
}
|
||||
|
||||
function isWithinDate(row: any, from?: string | null, to?: string | null): boolean {
|
||||
const created = row?.created_at ? new Date(row.created_at) : null
|
||||
if (!created || isNaN(created.getTime())) return false
|
||||
// Intentar con 'fecha' primero, luego con 'created_at'
|
||||
const fechaStr = row?.fecha || row?.created_at
|
||||
if (!fechaStr) return false
|
||||
|
||||
const fecha = new Date(fechaStr)
|
||||
if (isNaN(fecha.getTime())) return false
|
||||
|
||||
if (from) {
|
||||
const fd = new Date(from + 'T00:00:00-06:00')
|
||||
if (created < fd) return false
|
||||
if (fecha < fd) return false
|
||||
}
|
||||
if (to) {
|
||||
const td = new Date(to + 'T23:59:59-06:00')
|
||||
if (created > td) return false
|
||||
if (fecha > td) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
<template v-else>
|
||||
<!-- Metadatos Cards de Ingresos y Rechazos -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
|
||||
<MetadatosCard v-if="rechazosMetadata" :metadata="rechazosMetadata" />
|
||||
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" :compact="true" />
|
||||
<MetadatosCard v-if="rechazosMetadata" :metadata="rechazosMetadata" :compact="true" />
|
||||
</div>
|
||||
|
||||
<!-- 🔻 Card de Filtros -->
|
||||
|
||||
Reference in New Issue
Block a user