Files
analiticaNucleo/nuxt4-app/app/components/ingresos/GraficaSerieInversion.vue
josedario87 aa76fea286 Refactor: Adaptar todos los componentes al sistema de temas
- Reemplazar colores hardcoded del tema café con variables --brand-*
  - #c08040 → var(--brand-primary-strong)
  - #d99a56 → var(--brand-primary)
  - #f0c07c → var(--brand-accent)
  - #1c140c → var(--brand-surface)
  - #3a2a16 → var(--brand-border)
  - #1b1209, #14100b → var(--brand-bg)

- Reemplazar colores de tipos de café con variables --coffee-*
  - #a855f7 → var(--coffee-uva)
  - #f97316 → var(--coffee-oreado)
  - #06b6d4 → var(--coffee-mojado)
  - #22c55e → var(--coffee-verde)

- Reemplazar clases gray-scale de Tailwind con variables de tema
  - text-gray-400, text-gray-500 → text-[var(--brand-text-muted)]
  - bg-gray-700/30 → bg-[var(--brand-surface)]

- Todos los componentes ahora responden dinámicamente a cambios de tema

Archivos adaptados:
- Páginas: error, informe-ingresos, panorama, explorer, metabase-debug, profile, notifications, settings
- Componentes de ingresos: GraficaSerieIngresos, GraficaSerieInversion, GraficaDinamicaPagadoDeposito, GraficaAcumuladoresUva, TotalesIngresoCompra, TotalesMonetarios, TotalesVerde, SecosVendidos, TopClientes, VistaTablaIngresos, VistaTablaIngresosConClientes, FiltrosActivos
- Componentes de comparativa: CosechasHeatmap, CosechasPorTipo, CosechasEvolucion, CosechasTotales
- Componentes de UI: ClienteSelector, DateRangeSelector, MetadatosCard, MaintenanceMode
- Componentes de auth: UserAvatar, UserMetadata
- Componentes de clientes: ClienteCard, VistaTablaClientes
- Componentes de rechazos: RechazoCard, RechazosRechazoCard, RechazosSubproductos
- Componentes de metabase: MetabaseCardDisplay, MetabaseCardsTable
2025-10-30 17:54:42 -06:00

472 lines
15 KiB
Vue

<!-- Gráfica de Serie Temporal de Inversión Diaria -->
<template>
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex flex-col gap-3">
<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>
<!-- Selector de Granularidad Temporal -->
<div class="flex items-center gap-2">
<span class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Granularidad:</span>
<div class="flex items-center gap-1">
<button
v-for="option in granularityOptions"
:key="option.value"
@click="selectedGranularity = option.value"
:class="[
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all',
selectedGranularity === option.value
? 'bg-[var(--brand-primary)] text-white shadow-sm'
: 'bg-[var(--brand-surface)] text-[var(--brand-text-muted)] hover:bg-[var(--brand-primary)]/10 hover:text-[var(--brand-text)]'
]"
>
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
<span>{{ option.label }}</span>
</button>
</div>
</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 (distribuidos uniformemente) -->
<g v-if="tiposActivos.length > 0">
<text
v-for="(point, i) in getPointsForTipo(tiposActivos[0].value)"
:key="`label-${i}`"
v-show="shouldShowLabel(i, fechasUnicas.length)"
:x="point.x + barWidth / 2"
: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" :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: 'var(--coffee-uva)' },
{ value: 'oreado', label: 'Oreado', color: 'var(--coffee-oreado)' },
{ value: 'mojado', label: 'Mojado', color: 'var(--coffee-mojado)' },
{ value: 'verde', label: 'Verde', color: 'var(--coffee-verde)' }
]
const tiposSeleccionados = ref(['uva', 'verde', 'oreado', 'mojado'])
const tiposActivos = computed(() => tipos.filter(t => tiposSeleccionados.value.includes(t.value)))
// Selector de granularidad temporal
type GranularityMode = 'auto' | 'year' | 'month' | 'day' | 'hour'
const selectedGranularity = ref<GranularityMode>('auto')
const granularityOptions = [
{ value: 'auto' as GranularityMode, label: 'Auto', icon: 'i-lucide-wand-2' },
{ value: 'year' as GranularityMode, label: 'Años', icon: 'i-lucide-calendar' },
{ value: 'month' as GranularityMode, label: 'Meses', icon: 'i-lucide-calendar-days' },
{ value: 'day' as GranularityMode, label: 'Días', icon: 'i-lucide-calendar-clock' },
{ value: 'hour' as GranularityMode, label: 'Horas', icon: 'i-lucide-clock' }
]
// 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
dateLabel: string
}
// Función para detectar granularidad temporal
function getTimeGranularity(startDate: Date, endDate: Date): 'year' | 'month' | 'day' | 'hour' {
// Si no es auto, usar la selección manual
if (selectedGranularity.value !== 'auto') {
return selectedGranularity.value
}
// Auto: detectar según el rango
const diffMs = endDate.getTime() - startDate.getTime()
const diffDays = diffMs / (1000 * 60 * 60 * 24)
const diffMonths = diffDays / 30
const diffYears = diffDays / 365
if (diffYears > 2) return 'year'
if (diffMonths > 2) return 'month'
if (diffDays > 2) return 'day'
return 'hour'
}
// Función para generar timestamps completos según granularidad
function generateTimeRange(startDate: Date, endDate: Date, granularity: 'year' | 'month' | 'day' | 'hour'): Date[] {
const dates: Date[] = []
const current = new Date(startDate)
while (current <= endDate) {
dates.push(new Date(current))
switch (granularity) {
case 'year':
current.setFullYear(current.getFullYear() + 1)
break
case 'month':
current.setMonth(current.getMonth() + 1)
break
case 'day':
current.setDate(current.getDate() + 1)
break
case 'hour':
current.setHours(current.getHours() + 1)
break
}
}
return dates
}
// Función para formatear fecha según granularidad
function formatDateByGranularity(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
switch (granularity) {
case 'year':
return date.getFullYear().toString()
case 'month':
return date.toLocaleDateString('es-HN', { year: 'numeric', month: 'short' })
case 'day':
return date.toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
case 'hour':
return date.toLocaleTimeString('es-HN', { hour: '2-digit', minute: '2-digit' })
}
}
// Función para agrupar fecha según granularidad (devuelve formato ISO parseable)
function getDateKey(date: Date, granularity: 'year' | 'month' | 'day' | 'hour'): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
switch (granularity) {
case 'year':
return `${year}`
case 'month':
return `${year}-${month}`
case 'day':
return `${year}-${month}-${day}`
case 'hour':
return `${year}-${month}-${day}T${hour}`
}
}
// Determinar si mostrar una etiqueta (máximo 10 etiquetas en el eje)
function shouldShowLabel(index: number, total: number): boolean {
if (total <= 10) return true
const maxLabels = 10
const step = Math.ceil(total / maxLabels)
// Siempre mostrar primera y última
if (index === 0 || index === total - 1) return true
// Mostrar cada N puntos
return index % step === 0
}
// Obtener todas las fechas únicas (ahora generadas completas)
const fechasUnicas = computed(() => {
const allDates = props.ingresos
.filter(i => tiposSeleccionados.value.includes(i.tipo))
.filter(i => i.created_at)
.map(i => new Date(i.created_at!))
if (allDates.length === 0) return []
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
const granularity = getTimeGranularity(minDate, maxDate)
const timeRange = generateTimeRange(minDate, maxDate, granularity)
return timeRange.map(fecha => getDateKey(fecha, granularity))
})
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>> = {}
const allDates = props.ingresos
.filter(i => tiposSeleccionados.value.includes(i.tipo))
.filter(i => i.created_at)
.map(i => new Date(i.created_at!))
if (allDates.length === 0) {
tiposSeleccionados.value.forEach(tipo => {
result[tipo] = new Map<string, number>()
})
return result
}
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
const granularity = getTimeGranularity(minDate, maxDate)
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!)
const key = getDateKey(fecha, granularity)
// 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(key, (result[tipo].get(key) || 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 []
const allDates = props.ingresos
.filter(i => tiposSeleccionados.value.includes(i.tipo))
.filter(i => i.created_at)
.map(i => new Date(i.created_at!))
if (allDates.length === 0) return []
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
const granularity = getTimeGranularity(minDate, maxDate)
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
// Parsear fecha según granularidad
let tempDate: Date
if (granularity === 'year') {
tempDate = new Date(parseInt(fecha), 0, 1)
} else if (granularity === 'month') {
const [year, month] = fecha.split('-')
tempDate = new Date(parseInt(year), parseInt(month) - 1, 1)
} else if (granularity === 'hour') {
// Formato: YYYY-MM-DDTHH
tempDate = new Date(fecha + ':00:00Z')
} else {
// granularity === 'day', formato: YYYY-MM-DD
tempDate = new Date(fecha + 'T00:00:00Z')
}
return {
date: fecha,
value,
x,
y,
label: fecha,
dateLabel: formatDateByGranularity(tempDate, granularity)
}
})
}
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>