Files
analiticaNucleo/nuxt4-app/app/components/ingresos/GraficaDinamicaPagadoDeposito.vue

495 lines
15 KiB
Vue

<!-- Gráfica de Uva Pagada vs Uva en Depósito -->
<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">Dinámica: Pagado vs Depósito</h3>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
Evolución de café pagado y en depósito
</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: 400px;">
<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)]"
>
{{ formatValue(maxValue - (i - 1) * (maxValue / 4)) }}
</text>
</g>
<!-- Gráficas por cada tipo -->
<g v-for="tipo in tiposActivos" :key="`tipo-${tipo.value}`">
<!-- Area Pagado -->
<path
:d="getAreaPagadoPathForTipo(tipo.value)"
:fill="getTipoColor(tipo.value)"
fill-opacity="0.2"
/>
<!-- Area Deposito -->
<path
:d="getAreaDepositoPathForTipo(tipo.value)"
:fill="getTipoColor(tipo.value)"
fill-opacity="0.1"
stroke-dasharray="5,5"
/>
<!-- Line Pagado -->
<path
:d="getLinePagadoPathForTipo(tipo.value)"
:stroke="getTipoColor(tipo.value)"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Line Deposito -->
<path
:d="getLineDepositoPathForTipo(tipo.value)"
:stroke="getTipoColor(tipo.value)"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="8,4"
/>
<!-- Points Pagado -->
<circle
v-for="(point, i) in getPointsPagadoForTipo(tipo.value)"
:key="`pagado-${tipo.value}-${i}`"
:cx="point.x"
:cy="point.y"
r="4"
:fill="getTipoColor(tipo.value)"
class="cursor-pointer hover:r-6 transition-all"
>
<title>{{ tipo.label }} Pagado - {{ point.label }}: {{ point.value.toFixed(2) }} {{ getTipoUnidad(tipo) }}</title>
</circle>
<!-- Points Deposito -->
<circle
v-for="(point, i) in getPointsDepositoForTipo(tipo.value)"
:key="`deposito-${tipo.value}-${i}`"
:cx="point.x"
:cy="point.y"
r="4"
:fill="getTipoColor(tipo.value)"
fill-opacity="0.6"
class="cursor-pointer hover:r-6 transition-all"
>
<title>{{ tipo.label }} Depósito - {{ point.label }}: {{ point.value.toFixed(2) }} {{ getTipoUnidad(tipo) }}</title>
</circle>
</g>
<!-- X-axis labels -->
<g v-if="tiposActivos.length > 0" v-for="(point, i) in getPointsPagadoForTipo(tiposActivos[0].value)" :key="`label-${i}`">
<text
v-if="i % Math.ceil(getPointsPagadoForTipo(tiposActivos[0].value).length / 6) === 0 || i === getPointsPagadoForTipo(tiposActivos[0].value).length - 1"
:x="point.x"
:y="height - padding + 20"
text-anchor="middle"
class="text-xs fill-[var(--brand-text-muted)]"
>
{{ point.dateLabel }}
</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="`totales-${tipo.value}`" class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full" :style="{ backgroundColor: getTipoColor(tipo.value) }"></div>
<span class="text-[var(--brand-text-muted)] text-xs">{{ tipo.label }}:</span>
</div>
<div class="flex items-center gap-2">
<span class="font-bold" :style="{ color: getTipoColor(tipo.value) }">
{{ formatValue(getTotalPagadoForTipo(tipo.value)) }} {{ getTipoUnidad(tipo) }}
</span>
<span class="text-[var(--brand-text-muted)]">pagado</span>
</div>
<div class="flex items-center gap-2">
<span class="font-bold opacity-60" :style="{ color: getTipoColor(tipo.value) }">
{{ formatValue(getTotalDepositoForTipo(tipo.value)) }} {{ getTipoUnidad(tipo) }}
</span>
<span class="text-[var(--brand-text-muted)]">depósito</span>
</div>
</div>
</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', unidad: 'lb' },
{ value: 'oreado', label: 'Oreado', unidad: 'qq' },
{ value: 'mojado', label: 'Mojado', unidad: 'qq' },
{ value: 'verde', label: 'Verde', unidad: 'lb' }
]
const tiposSeleccionados = ref(['uva'])
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, unidad: string }): string {
if (tipo.value === 'uva') {
return modoSeco.value ? `${tipo.label} (qq)` : `${tipo.label} (lb)`
}
return `${tipo.label} (${tipo.unidad})`
}
// Obtener unidad dinámica según el modo
function getTipoUnidad(tipo: { value: string, label: string, unidad: string }): string {
if (tipo.value === 'uva') {
return modoSeco.value ? 'qq' : 'lb'
}
return tipo.unidad
}
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 = 400
const padding = 60
const chartWidth = width - 2 * padding
const chartHeight = height - 2 * padding
interface DataPoint {
date: Date
value: number
x: number
y: number
label: string
dateLabel: string
}
// Datos pagado por tipo
const dataPagadoByTipo = computed(() => {
const result: Record<string, DataPoint[]> = {}
tiposSeleccionados.value.forEach(tipo => {
const ingresosFiltrados = props.ingresos
.filter(i => i.tipo === tipo)
.filter(i => i.estado === 'pagado')
.filter(i => i.created_at)
.sort((a, b) => new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime())
if (ingresosFiltrados.length === 0) {
result[tipo] = []
return
}
const porDia = new Map<string, number>()
ingresosFiltrados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
let valor = 0
if (tipo === 'uva') {
// Si estamos en modo seco, convertir uva a qq (dividir entre 500)
valor = modoSeco.value ? ingreso.peso_neto / 500 : ingreso.peso_neto
} else if (tipo === 'verde') {
valor = ingreso.peso_neto
} else {
valor = ingreso.peso_seco
}
porDia.set(fecha, (porDia.get(fecha) || 0) + valor)
})
let acumulado = 0
const puntos: DataPoint[] = []
Array.from(porDia.entries()).forEach(([fecha, valor]) => {
acumulado += valor
puntos.push({
date: new Date(fecha),
value: acumulado,
x: 0,
y: 0,
label: fecha,
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
})
})
result[tipo] = puntos
})
return result
})
// Datos depósito por tipo
const dataDepositoByTipo = computed(() => {
const result: Record<string, DataPoint[]> = {}
tiposSeleccionados.value.forEach(tipo => {
const todosFiltrados = props.ingresos
.filter(i => i.tipo === tipo)
.filter(i => i.created_at)
.sort((a, b) => new Date(a.created_at!).getTime() - new Date(b.created_at!).getTime())
const pagadosFiltrados = props.ingresos
.filter(i => i.tipo === tipo)
.filter(i => i.estado === 'pagado')
.filter(i => i.created_at)
if (todosFiltrados.length === 0) {
result[tipo] = []
return
}
const totalPorDia = new Map<string, number>()
const pagadoPorDia = new Map<string, number>()
todosFiltrados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
let valor = 0
if (tipo === 'uva') {
valor = modoSeco.value ? ingreso.peso_neto / 500 : ingreso.peso_neto
} else if (tipo === 'verde') {
valor = ingreso.peso_neto
} else {
valor = ingreso.peso_seco
}
totalPorDia.set(fecha, (totalPorDia.get(fecha) || 0) + valor)
})
pagadosFiltrados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
let valor = 0
if (tipo === 'uva') {
valor = modoSeco.value ? ingreso.peso_neto / 500 : ingreso.peso_neto
} else if (tipo === 'verde') {
valor = ingreso.peso_neto
} else {
valor = ingreso.peso_seco
}
pagadoPorDia.set(fecha, (pagadoPorDia.get(fecha) || 0) + valor)
})
let acumuladoTotal = 0
let acumuladoPagado = 0
const puntos: DataPoint[] = []
const fechasUnicas = new Set([...totalPorDia.keys(), ...pagadoPorDia.keys()])
const fechasOrdenadas = Array.from(fechasUnicas).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
fechasOrdenadas.forEach(fecha => {
acumuladoTotal += totalPorDia.get(fecha) || 0
acumuladoPagado += pagadoPorDia.get(fecha) || 0
const deposito = acumuladoTotal - acumuladoPagado
puntos.push({
date: new Date(fecha),
value: deposito,
x: 0,
y: 0,
label: fecha,
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
})
})
result[tipo] = puntos
})
return result
})
const maxValue = computed(() => {
let max = 0
Object.values(dataPagadoByTipo.value).forEach(puntos => {
if (puntos.length > 0) {
const maxPagado = Math.max(...puntos.map(p => p.value))
if (maxPagado > max) max = maxPagado
}
})
Object.values(dataDepositoByTipo.value).forEach(puntos => {
if (puntos.length > 0) {
const maxDeposito = Math.max(...puntos.map(p => p.value))
if (maxDeposito > max) max = maxDeposito
}
})
return max * 1.1 || 100
})
function getTipoColor(tipo: string) {
const tipoConfig = tipos.find(t => t.value === tipo)
return tipoConfig?.value === 'uva' ? '#a855f7' :
tipoConfig?.value === 'oreado' ? '#f97316' :
tipoConfig?.value === 'mojado' ? '#06b6d4' : '#22c55e'
}
function getPointsPagadoForTipo(tipo: string) {
const data = dataPagadoByTipo.value[tipo]
if (!data || data.length === 0) return []
return data.map((point, i) => {
const x = padding + (i / (data.length - 1 || 1)) * chartWidth
const y = height - padding - (point.value / maxValue.value) * chartHeight
return { ...point, x, y }
})
}
function getPointsDepositoForTipo(tipo: string) {
const data = dataDepositoByTipo.value[tipo]
if (!data || data.length === 0) return []
return data.map((point, i) => {
const x = padding + (i / (data.length - 1 || 1)) * chartWidth
const y = height - padding - (point.value / maxValue.value) * chartHeight
return { ...point, x, y }
})
}
function getLinePagadoPathForTipo(tipo: string) {
const points = getPointsPagadoForTipo(tipo)
if (points.length === 0) return ''
return points.map((point, i) => {
return i === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`
}).join(' ')
}
function getLineDepositoPathForTipo(tipo: string) {
const points = getPointsDepositoForTipo(tipo)
if (points.length === 0) return ''
return points.map((point, i) => {
return i === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`
}).join(' ')
}
function getAreaPagadoPathForTipo(tipo: string) {
const points = getPointsPagadoForTipo(tipo)
if (points.length === 0) return ''
const linePart = points.map((point, i) => {
return i === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`
}).join(' ')
const lastPoint = points[points.length - 1]
const firstPoint = points[0]
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
}
function getAreaDepositoPathForTipo(tipo: string) {
const points = getPointsDepositoForTipo(tipo)
if (points.length === 0) return ''
const linePart = points.map((point, i) => {
return i === 0 ? `M ${point.x} ${point.y}` : `L ${point.x} ${point.y}`
}).join(' ')
const lastPoint = points[points.length - 1]
const firstPoint = points[0]
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
}
function getTotalPagadoForTipo(tipo: string) {
const data = dataPagadoByTipo.value[tipo]
if (!data || data.length === 0) return 0
return data[data.length - 1]?.value || 0
}
function getTotalDepositoForTipo(tipo: string) {
const data = dataDepositoByTipo.value[tipo]
if (!data || data.length === 0) return 0
return data[data.length - 1]?.value || 0
}
function formatValue(value: number): string {
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'k'
}
return value.toFixed(0)
}
</script>