graficas meh. pueden mejorar, pueden desaparecer
This commit is contained in:
326
nuxt4-app/app/components/ingresos/GraficaAcumuladoresUva.vue
Normal file
326
nuxt4-app/app/components/ingresos/GraficaAcumuladoresUva.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<!-- Gráfica de Acumulación de Uva/Oreado/Mojado/Verde -->
|
||||
<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">Acumulación de Café</h3>
|
||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||
Evolución acumulada en el tiempo
|
||||
</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 under curve -->
|
||||
<path
|
||||
:d="getAreaPathForTipo(tipo.value)"
|
||||
:fill="tipo.color"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
|
||||
<!-- Line -->
|
||||
<path
|
||||
:d="getLinePathForTipo(tipo.value)"
|
||||
:stroke="tipo.color"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Points -->
|
||||
<circle
|
||||
v-for="(point, i) in getPointsForTipo(tipo.value)"
|
||||
:key="`point-${tipo.value}-${i}`"
|
||||
:cx="point.x"
|
||||
:cy="point.y"
|
||||
r="4"
|
||||
:fill="tipo.color"
|
||||
class="cursor-pointer hover:r-6 transition-all"
|
||||
>
|
||||
<title>{{ tipo.label }} - {{ point.label }}: {{ point.acumulado.toFixed(2) }} {{ getTipoUnidad(tipo) }}</title>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- X-axis labels (usando el primer tipo activo como referencia) -->
|
||||
<g v-if="tiposActivos.length > 0" v-for="(point, i) in getPointsForTipo(tiposActivos[0].value)" :key="`label-${i}`">
|
||||
<text
|
||||
v-if="i % Math.ceil(getPointsForTipo(tiposActivos[0].value).length / 6) === 0 || i === getPointsForTipo(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="`total-${tipo.value}`" class="flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full" :style="{ backgroundColor: tipo.color }"></div>
|
||||
<span class="text-[var(--brand-text-muted)]">{{ tipo.label }}:</span>
|
||||
<span class="font-bold" :style="{ color: tipo.color }">
|
||||
{{ formatValue(getTotalForTipo(tipo.value)) }} {{ getTipoUnidad(tipo) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[var(--brand-text-muted)]">
|
||||
{{ allDates.length }} días
|
||||
</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', unidad: 'lb' },
|
||||
{ value: 'oreado', label: 'Oreado', color: '#f97316', unidad: 'qq' },
|
||||
{ value: 'mojado', label: 'Mojado', color: '#06b6d4', unidad: 'qq' },
|
||||
{ value: 'verde', label: 'Verde', color: '#22c55e', 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, color: 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, color: 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
|
||||
acumulado: number
|
||||
x: number
|
||||
y: number
|
||||
label: string
|
||||
dateLabel: string
|
||||
}
|
||||
|
||||
// Datos por tipo
|
||||
const dataByTipo = computed(() => {
|
||||
const result: Record<string, DataPoint[]> = {}
|
||||
|
||||
tiposSeleccionados.value.forEach(tipo => {
|
||||
const ingresosFiltrados = 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())
|
||||
|
||||
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: valor,
|
||||
acumulado,
|
||||
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(dataByTipo.value).forEach(puntos => {
|
||||
if (puntos.length > 0) {
|
||||
const maxAcumulado = Math.max(...puntos.map(p => p.acumulado))
|
||||
if (maxAcumulado > max) max = maxAcumulado
|
||||
}
|
||||
})
|
||||
return max * 1.1 || 100
|
||||
})
|
||||
|
||||
// Obtener todas las fechas únicas
|
||||
const allDates = computed(() => {
|
||||
const fechas = new Set<string>()
|
||||
Object.values(dataByTipo.value).forEach(puntos => {
|
||||
puntos.forEach(p => fechas.add(p.label))
|
||||
})
|
||||
return Array.from(fechas).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
||||
})
|
||||
|
||||
// Calcular puntos con coordenadas para cada tipo
|
||||
function getPointsForTipo(tipo: string) {
|
||||
const data = dataByTipo.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.acumulado / maxValue.value) * chartHeight
|
||||
|
||||
return { ...point, x, y }
|
||||
})
|
||||
}
|
||||
|
||||
function getLinePathForTipo(tipo: string) {
|
||||
const points = getPointsForTipo(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 getAreaPathForTipo(tipo: string) {
|
||||
const points = getPointsForTipo(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 getTotalForTipo(tipo: string) {
|
||||
const data = dataByTipo.value[tipo]
|
||||
if (!data || data.length === 0) return 0
|
||||
return data[data.length - 1]?.acumulado || 0
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,494 @@
|
||||
<!-- 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>
|
||||
277
nuxt4-app/app/components/ingresos/GraficaSerieIngresos.vue
Normal file
277
nuxt4-app/app/components/ingresos/GraficaSerieIngresos.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<!-- Gráfica de Serie Temporal de Ingresos Diarios -->
|
||||
<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: Ingresos Diarios</h3>
|
||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||
Cantidad ingresada por día
|
||||
</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)]"
|
||||
>
|
||||
{{ 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 }}: {{ point.value.toFixed(2) }} {{ getTipoUnidad(tipo) }}</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 }">
|
||||
{{ formatValue(getTotalForTipo(tipo.value)) }} {{ getTipoUnidad(tipo) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[var(--brand-text-muted)]">
|
||||
{{ fechasUnicas.length }} días
|
||||
</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', unidad: 'lb' },
|
||||
{ value: 'oreado', label: 'Oreado', color: '#f97316', unidad: 'qq' },
|
||||
{ value: 'mojado', label: 'Mojado', color: '#06b6d4', unidad: 'qq' },
|
||||
{ value: 'verde', label: 'Verde', color: '#22c55e', 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, color: 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, color: 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 = 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')
|
||||
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
|
||||
}
|
||||
|
||||
result[tipo].set(fecha, (result[tipo].get(fecha) || 0) + valor)
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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 >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 'k'
|
||||
}
|
||||
return value.toFixed(0)
|
||||
}
|
||||
</script>
|
||||
292
nuxt4-app/app/components/ingresos/GraficaSerieInversion.vue
Normal file
292
nuxt4-app/app/components/ingresos/GraficaSerieInversion.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<!-- 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>
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
<!-- Fila 3: Filtros Avanzados -->
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Tipos de Café -->
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
||||
@@ -185,6 +185,23 @@
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Calidad -->
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
||||
Calidad
|
||||
</h3>
|
||||
<UInputMenu
|
||||
v-model="selectedCalidades"
|
||||
:items="calidadesOptions"
|
||||
value-key="value"
|
||||
multiple
|
||||
placeholder="Todas las calidades"
|
||||
size="sm"
|
||||
icon="i-lucide-star"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
@@ -446,6 +463,44 @@
|
||||
|
||||
<!-- Top 10 Clientes -->
|
||||
<IngresosTopClientes :ingresos="ingresosFiltrados" :clientes="clientesFiltrados" />
|
||||
|
||||
<!-- Sección de Gráficas: Acumuladores -->
|
||||
<div class="space-y-6 w-full">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--brand-border)] to-transparent"></div>
|
||||
<h2 class="text-2xl font-bold brand-section-title">Acumuladores en el Tiempo</h2>
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--brand-border)] to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Gráfica de Acumulación -->
|
||||
<div v-if="ingresosFiltrados && ingresosFiltrados.length > 0" class="w-full">
|
||||
<IngresosGraficaAcumuladoresUva :ingresos="ingresosFiltrados" />
|
||||
</div>
|
||||
|
||||
<!-- Gráfica de Pagado vs Depósito -->
|
||||
<div v-if="ingresosFiltrados && ingresosFiltrados.length > 0" class="w-full">
|
||||
<IngresosGraficaDinamicaPagadoDeposito :ingresos="ingresosFiltrados" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sección de Gráficas: Series Temporales -->
|
||||
<div class="space-y-6 w-full">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--brand-border)] to-transparent"></div>
|
||||
<h2 class="text-2xl font-bold brand-section-title">Series Temporales</h2>
|
||||
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-[var(--brand-border)] to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Serie de Ingresos Diarios -->
|
||||
<div v-if="ingresosFiltrados && ingresosFiltrados.length > 0" class="w-full">
|
||||
<IngresosGraficaSerieIngresos :ingresos="ingresosFiltrados" />
|
||||
</div>
|
||||
|
||||
<!-- Serie de Inversión Diaria -->
|
||||
<div v-if="ingresosFiltrados && ingresosFiltrados.length > 0" class="w-full">
|
||||
<IngresosGraficaSerieInversion :ingresos="ingresosFiltrados" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -622,6 +677,7 @@ const selectedClienteIds = ref<number[]>([])
|
||||
const selectedTipos = ref<string[]>([])
|
||||
const selectedEstados = ref<string[]>([])
|
||||
const selectedUbicaciones = ref<string[]>([])
|
||||
const selectedCalidades = ref<string[]>([])
|
||||
|
||||
// Opciones para filtros avanzados
|
||||
const tiposCafeOptions = [
|
||||
@@ -647,6 +703,17 @@ const ubicacionesOptions = computed(() => {
|
||||
return Array.from(ubicaciones).sort().map(u => ({ value: u, label: u }))
|
||||
})
|
||||
|
||||
// Calidades dinámicas basadas en los ingresos
|
||||
const calidadesOptions = computed(() => {
|
||||
const calidades = new Set<string>()
|
||||
ingresos.value?.forEach(i => {
|
||||
if (i.calidad) {
|
||||
calidades.add(i.calidad)
|
||||
}
|
||||
})
|
||||
return Array.from(calidades).sort().map(c => ({ value: c, label: c }))
|
||||
})
|
||||
|
||||
// Labels for selected filters
|
||||
const selectedTiposLabels = computed(() => {
|
||||
return selectedTipos.value
|
||||
@@ -662,11 +729,16 @@ const selectedEstadosLabels = computed(() => {
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
const selectedCalidadesLabels = computed(() => {
|
||||
return selectedCalidades.value.join(', ')
|
||||
})
|
||||
|
||||
// Check if advanced filters are active
|
||||
const hasAdvancedFilters = computed(() => {
|
||||
return selectedTipos.value.length > 0 ||
|
||||
selectedEstados.value.length > 0 ||
|
||||
selectedUbicaciones.value.length > 0
|
||||
selectedUbicaciones.value.length > 0 ||
|
||||
selectedCalidades.value.length > 0
|
||||
})
|
||||
|
||||
// Clear advanced filters
|
||||
@@ -674,6 +746,7 @@ function clearAdvancedFilters() {
|
||||
selectedTipos.value = []
|
||||
selectedEstados.value = []
|
||||
selectedUbicaciones.value = []
|
||||
selectedCalidades.value = []
|
||||
}
|
||||
|
||||
async function onToggleAnulados(newValue: boolean | 'indeterminate') {
|
||||
@@ -749,6 +822,11 @@ function matchesUbicacion(ingreso: IngresoRecord): boolean {
|
||||
return cliente?.ubicacion ? selectedUbicaciones.value.includes(cliente.ubicacion) : false
|
||||
}
|
||||
|
||||
function matchesCalidad(ingreso: IngresoRecord): boolean {
|
||||
if (selectedCalidades.value.length === 0) return true
|
||||
return ingreso.calidad ? selectedCalidades.value.includes(ingreso.calidad) : false
|
||||
}
|
||||
|
||||
// Get selected clientes for display cards
|
||||
const clientesSeleccionados = computed((): ClienteRecord[] => {
|
||||
if (selectedClienteIds.value.length === 0) return []
|
||||
@@ -769,6 +847,7 @@ const ingresosFiltrados = computed(() => {
|
||||
.filter(r => matchesTipoCafe(r))
|
||||
.filter(r => matchesEstado(r))
|
||||
.filter(r => matchesUbicacion(r))
|
||||
.filter(r => matchesCalidad(r))
|
||||
})
|
||||
|
||||
const clientesFiltrados = computed((): ClienteRecord[] => {
|
||||
|
||||
Reference in New Issue
Block a user