293 lines
9.0 KiB
Vue
293 lines
9.0 KiB
Vue
<!-- Gráfica de Serie Temporal de Inversión Diaria -->
|
|
<template>
|
|
<UCard class="brand-card border border-transparent">
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-bold brand-section-title">Serie Temporal: Inversión Diaria</h3>
|
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
|
Cantidad invertida por día (en Lempiras)
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<UCheckbox
|
|
v-for="tipo in tipos"
|
|
:key="tipo.value"
|
|
:model-value="tiposSeleccionados.includes(tipo.value)"
|
|
@update:model-value="(checked) => toggleTipo(tipo.value, checked)"
|
|
:label="getTipoLabel(tipo)"
|
|
:disabled="isTipoDisabled(tipo.value)"
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="relative w-full" style="height: 450px;">
|
|
<svg :viewBox="`0 0 ${width} ${height}`" class="w-full h-full" preserveAspectRatio="none">
|
|
<!-- Grid lines -->
|
|
<g v-for="i in 5" :key="`grid-${i}`">
|
|
<line
|
|
:x1="padding"
|
|
:y1="padding + (i - 1) * (chartHeight / 4)"
|
|
:x2="width - padding"
|
|
:y2="padding + (i - 1) * (chartHeight / 4)"
|
|
stroke="currentColor"
|
|
:stroke-opacity="0.1"
|
|
stroke-width="1"
|
|
/>
|
|
<text
|
|
:x="padding - 10"
|
|
:y="padding + (i - 1) * (chartHeight / 4) + 5"
|
|
text-anchor="end"
|
|
class="text-xs fill-[var(--brand-text-muted)]"
|
|
>
|
|
L {{ formatValue(maxValue - (i - 1) * (maxValue / 4)) }}
|
|
</text>
|
|
</g>
|
|
|
|
<!-- Bars por cada tipo -->
|
|
<g v-for="(tipo, tipoIndex) in tiposActivos" :key="`tipo-${tipo.value}`">
|
|
<g v-for="(point, i) in getPointsForTipo(tipo.value)" :key="`bar-${tipo.value}-${i}`">
|
|
<rect
|
|
:x="point.x + tipoIndex * barWidth / tiposActivos.length"
|
|
:y="point.y"
|
|
:width="barWidth / tiposActivos.length"
|
|
:height="height - padding - point.y"
|
|
:fill="tipo.color"
|
|
:fill-opacity="0.8"
|
|
class="cursor-pointer hover:fill-opacity-100 transition-all"
|
|
>
|
|
<title>{{ tipo.label }} - {{ point.label }}: L {{ point.value.toFixed(2) }}</title>
|
|
</rect>
|
|
</g>
|
|
</g>
|
|
|
|
<!-- X-axis labels -->
|
|
<g v-for="(fecha, i) in fechasUnicas" :key="`label-${i}`">
|
|
<text
|
|
v-if="i % Math.ceil(fechasUnicas.length / 8) === 0 || i === fechasUnicas.length - 1"
|
|
:x="padding + (i / (fechasUnicas.length - 1 || 1)) * chartWidth + barWidth / 2"
|
|
:y="height - padding + 20"
|
|
text-anchor="middle"
|
|
class="text-xs fill-[var(--brand-text-muted)]"
|
|
>
|
|
{{ new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' }) }}
|
|
</text>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex items-center justify-between text-xs flex-wrap gap-2">
|
|
<div class="flex items-center gap-4 flex-wrap">
|
|
<div v-for="tipo in tiposActivos" :key="`total-${tipo.value}`" class="flex items-center gap-2">
|
|
<div class="w-3 h-3 rounded" :style="{ backgroundColor: tipo.color }"></div>
|
|
<span class="text-[var(--brand-text-muted)]">{{ tipo.label }}:</span>
|
|
<span class="font-bold" :style="{ color: tipo.color }">
|
|
L {{ formatValue(getTotalForTipo(tipo.value)) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-[var(--brand-text-muted)]">Total:</span>
|
|
<span class="font-bold text-[var(--brand-primary)]">
|
|
L {{ formatValue(totalInversion) }}
|
|
</span>
|
|
<span class="text-[var(--brand-text-muted)]">|</span>
|
|
<span class="text-[var(--brand-text-muted)]">{{ fechasUnicas.length }} días</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
|
|
|
interface Props {
|
|
ingresos: IngresoRecord[]
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const tipos = [
|
|
{ value: 'uva', label: 'Uva', color: '#a855f7' },
|
|
{ value: 'oreado', label: 'Oreado', color: '#f97316' },
|
|
{ value: 'mojado', label: 'Mojado', color: '#06b6d4' },
|
|
{ value: 'verde', label: 'Verde', color: '#22c55e' }
|
|
]
|
|
|
|
const tiposSeleccionados = ref(['uva', 'verde', 'oreado', 'mojado'])
|
|
|
|
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
|
|
|
|
// Determinar si estamos en modo seco (oreado o mojado seleccionado)
|
|
const modoSeco = computed(() => {
|
|
return tiposSeleccionados.value.includes('oreado') || tiposSeleccionados.value.includes('mojado')
|
|
})
|
|
|
|
// Determinar si un tipo está deshabilitado
|
|
function isTipoDisabled(tipo: string): boolean {
|
|
if (tipo === 'verde') {
|
|
return modoSeco.value
|
|
}
|
|
if (tipo === 'oreado' || tipo === 'mojado') {
|
|
return tiposSeleccionados.value.includes('verde')
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Obtener label dinámico con unidad correcta
|
|
function getTipoLabel(tipo: { value: string, label: string, color: string }): string {
|
|
if (tipo.value === 'uva') {
|
|
return modoSeco.value ? `${tipo.label} (qq)` : `${tipo.label} (lb)`
|
|
}
|
|
if (tipo.value === 'oreado' || tipo.value === 'mojado') {
|
|
return `${tipo.label} (qq)`
|
|
}
|
|
if (tipo.value === 'verde') {
|
|
return `${tipo.label} (lb)`
|
|
}
|
|
return tipo.label
|
|
}
|
|
|
|
function toggleTipo(value: string, checked: boolean) {
|
|
if (checked) {
|
|
if (!tiposSeleccionados.value.includes(value)) {
|
|
tiposSeleccionados.value.push(value)
|
|
}
|
|
// Si se activa verde, desactivar oreado y mojado
|
|
if (value === 'verde') {
|
|
tiposSeleccionados.value = tiposSeleccionados.value.filter(v => v !== 'oreado' && v !== 'mojado')
|
|
}
|
|
// Si se activa oreado o mojado, desactivar verde
|
|
if (value === 'oreado' || value === 'mojado') {
|
|
tiposSeleccionados.value = tiposSeleccionados.value.filter(v => v !== 'verde')
|
|
}
|
|
} else {
|
|
tiposSeleccionados.value = tiposSeleccionados.value.filter(v => v !== value)
|
|
}
|
|
}
|
|
|
|
const width = 1200
|
|
const height = 450
|
|
const padding = 60
|
|
|
|
const chartWidth = width - 2 * padding
|
|
const chartHeight = height - 2 * padding
|
|
|
|
interface DataPoint {
|
|
date: string
|
|
value: number
|
|
x: number
|
|
y: number
|
|
label: string
|
|
}
|
|
|
|
// Obtener todas las fechas únicas
|
|
const fechasUnicas = computed(() => {
|
|
const fechas = new Set<string>()
|
|
props.ingresos
|
|
.filter(i => tiposSeleccionados.value.includes(i.tipo))
|
|
.filter(i => i.created_at)
|
|
.forEach(i => {
|
|
const fecha = new Date(i.created_at!).toLocaleDateString('es-HN')
|
|
fechas.add(fecha)
|
|
})
|
|
return Array.from(fechas).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
|
})
|
|
|
|
const barWidth = computed(() => {
|
|
if (fechasUnicas.value.length === 0) return 0
|
|
return chartWidth / fechasUnicas.value.length * 0.8
|
|
})
|
|
|
|
const dataByTipo = computed(() => {
|
|
const result: Record<string, Map<string, number>> = {}
|
|
|
|
tiposSeleccionados.value.forEach(tipo => {
|
|
result[tipo] = new Map<string, number>()
|
|
|
|
props.ingresos
|
|
.filter(i => i.tipo === tipo)
|
|
.filter(i => i.created_at)
|
|
.forEach(ingreso => {
|
|
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
|
|
|
|
// Calcular inversión según el tipo
|
|
let inversion = 0
|
|
if (tipo === 'uva' || tipo === 'verde') {
|
|
inversion = ingreso.precio * ingreso.peso_neto
|
|
} else if (tipo === 'oreado' || tipo === 'mojado') {
|
|
inversion = (ingreso.precio / 2) * ingreso.peso_seco
|
|
}
|
|
|
|
result[tipo].set(fecha, (result[tipo].get(fecha) || 0) + inversion)
|
|
})
|
|
})
|
|
|
|
return result
|
|
})
|
|
|
|
const maxValue = computed(() => {
|
|
let max = 0
|
|
Object.values(dataByTipo.value).forEach(map => {
|
|
Array.from(map.values()).forEach(value => {
|
|
if (value > max) max = value
|
|
})
|
|
})
|
|
return max * 1.1 || 100
|
|
})
|
|
|
|
const totalInversion = computed(() => {
|
|
let total = 0
|
|
Object.values(dataByTipo.value).forEach(map => {
|
|
map.forEach(value => {
|
|
total += value
|
|
})
|
|
})
|
|
return total
|
|
})
|
|
|
|
function getPointsForTipo(tipo: string): DataPoint[] {
|
|
const data = dataByTipo.value[tipo]
|
|
if (!data) return []
|
|
|
|
return fechasUnicas.value.map((fecha, i) => {
|
|
const value = data.get(fecha) || 0
|
|
const x = padding + (i / (fechasUnicas.value.length - 1 || 1)) * chartWidth
|
|
const y = height - padding - (value / maxValue.value) * chartHeight
|
|
|
|
return {
|
|
date: fecha,
|
|
value,
|
|
x,
|
|
y,
|
|
label: fecha
|
|
}
|
|
})
|
|
}
|
|
|
|
function getTotalForTipo(tipo: string): number {
|
|
const data = dataByTipo.value[tipo]
|
|
if (!data) return 0
|
|
|
|
let total = 0
|
|
data.forEach(value => {
|
|
total += value
|
|
})
|
|
return total
|
|
}
|
|
|
|
function formatValue(value: number): string {
|
|
if (value >= 1000000) {
|
|
return (value / 1000000).toFixed(1) + 'M'
|
|
}
|
|
if (value >= 1000) {
|
|
return (value / 1000).toFixed(1) + 'k'
|
|
}
|
|
return value.toFixed(0)
|
|
}
|
|
</script>
|