cambio heavys

This commit is contained in:
2025-10-01 03:11:10 -06:00
parent a2eae3e2bf
commit bf370de372
12 changed files with 1861 additions and 527 deletions

View File

@@ -0,0 +1,138 @@
<template>
<UDashboardSidebar
v-model:open="open"
v-model:collapsed="collapsed"
collapsible
resizable
:default-size="28"
:min-size="20"
:max-size="38"
:toggle="{ color: 'primary', variant: 'subtle', class: 'rounded-full' }"
>
<template #header="{ collapsed: isCollapsed }">
<div class="flex items-center gap-2">
<img
v-if="!isCollapsed"
src="/logo.png"
alt="Analítica Núcleo"
class="h-8 w-8 rounded-full border border-[#ffe0a0]/40"
/>
<UIcon v-else name="i-lucide-activity" class="size-5 text-[#ffe0a0]" />
<span v-if="!isCollapsed" class="text-sm font-semibold text-[var(--brand-text)]">Analítica Núcleo</span>
</div>
</template>
<template #default="{ collapsed: isCollapsed }">
<UButton
:label="isCollapsed ? undefined : 'Buscar...'"
icon="i-lucide-search"
color="neutral"
variant="outline"
block
:square="isCollapsed"
class="mb-4"
>
<template v-if="!isCollapsed" #trailing>
<div class="flex items-center gap-0.5 ms-auto text-[var(--brand-text-muted)]">
<UKbd value="⌘" variant="subtle" />
<UKbd value="K" variant="subtle" />
</div>
</template>
</UButton>
<UNavigationMenu
:collapsed="isCollapsed"
:items="navigationPrimary"
orientation="vertical"
class="gap-1"
/>
<UNavigationMenu
:collapsed="isCollapsed"
:items="navigationSecondary"
orientation="vertical"
class="mt-auto gap-1"
/>
</template>
<template #footer="{ collapsed: isCollapsed }">
<UButton
:avatar="{ src: 'https://avatars.githubusercontent.com/u/12011070?v=4' }"
:label="isCollapsed ? undefined : 'Equipo Núcleo'"
color="neutral"
variant="ghost"
class="w-full justify-start"
:block="isCollapsed"
>
<template #trailing>
<UIcon name="i-lucide-log-out" class="size-4" />
</template>
</UButton>
</template>
</UDashboardSidebar>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import type { NavigationMenuItem } from '@nuxt/ui'
const route = useRoute()
const open = defineModel<boolean>('open', { default: true })
const collapsed = defineModel<boolean>('collapsed', { default: false })
const navigationPrimary = computed<NavigationMenuItem[]>(() => [
{
label: 'Inicio',
icon: 'i-lucide-home',
to: '/',
active: route.path === '/'
},
{
label: 'Panorama Facturador',
icon: 'i-lucide-bar-chart-3',
to: '/panorama',
active: route.path === '/panorama'
},
{
label: 'Informe Ingresos',
icon: 'i-lucide-file-bar-chart',
to: '/informe-ingresos',
active: route.path === '/informe-ingresos'
},
{
label: 'Explorador de datos',
icon: 'i-lucide-table',
to: '/explorer',
active: route.path === '/explorer'
},
{
label: 'Metadatos',
icon: 'i-lucide-database',
to: '/metadatos',
active: route.path === '/metadatos'
},
{
label: 'Explorador de datos raw',
icon: 'i-lucide-table',
to: '/rawExplorer',
active: route.path === '/rawExplorer'
}
])
const navigationSecondary: NavigationMenuItem[] = [
{
label: 'Documentación',
icon: 'i-lucide-book-open',
to: 'https://ui.nuxt.com',
target: '_blank'
},
{
label: 'Repositorio',
icon: 'i-lucide-github',
to: 'https://gitea.nucleoriofrio.com/nucleo000/analiticaNucleo',
target: '_blank'
}
]
</script>

View File

@@ -0,0 +1,203 @@
<template>
<div class="flex flex-col gap-6">
<!-- Fila 1: Selector de Clientes -->
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Clientes
</h3>
<ClienteSelector
:clientes="clientes"
:selected-ids="selectedClienteIds"
@update:selected-ids="emit('update:selectedClienteIds', $event)"
/>
<UButton
v-if="selectedClienteIds.length > 0"
size="xs"
variant="ghost"
color="neutral"
@click="emit('update:selectedClienteIds', [])"
block
>
<template #leading>
<UIcon name="i-lucide-x" />
</template>
Limpiar selección
</UButton>
</div>
<!-- Fila 2: Selector de Rango de Fechas -->
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Rango de Fechas
</h3>
<DateRangeSelector
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
@update:selected-preset="emit('update:selectedPreset', $event)"
@update:fecha-desde="emit('update:fechaDesde', $event)"
@update:fecha-hasta="emit('update:fechaHasta', $event)"
/>
<UButton
v-if="fechaDesde || fechaHasta"
size="xs"
variant="ghost"
color="neutral"
@click="clearDates"
block
>
<template #leading>
<UIcon name="i-lucide-x" />
</template>
Limpiar fechas
</UButton>
</div>
<!-- Fila 3: Filtros Avanzados (grid de 4 columnas) -->
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide">
Filtros Avanzados
</h3>
<div class="grid grid-cols-2 gap-3">
<!-- Tipo de Café -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Tipo</label>
<UInputMenu
v-model="selectedTipos"
:items="tiposOptions"
value-key="value"
multiple
placeholder="Todos"
size="xs"
icon="i-lucide-coffee"
class="w-full"
/>
</div>
<!-- Estado -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Estado</label>
<UInputMenu
v-model="selectedEstados"
:items="estadosOptions"
value-key="value"
multiple
placeholder="Todos"
size="xs"
icon="i-lucide-check-circle"
class="w-full"
/>
</div>
<!-- Ubicación -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Ubicación</label>
<UInputMenu
v-model="selectedUbicaciones"
:items="ubicacionesOptions"
value-key="value"
multiple
placeholder="Todas"
size="xs"
icon="i-lucide-map-pin"
class="w-full"
/>
</div>
<!-- Calidad -->
<div class="flex flex-col gap-2">
<label class="text-xs text-[var(--brand-text-muted)]">Calidad</label>
<UInputMenu
v-model="selectedCalidades"
:items="calidadesOptions"
value-key="value"
multiple
placeholder="Todas"
size="xs"
icon="i-lucide-star"
class="w-full"
/>
</div>
</div>
</div>
<!-- Checkbox de incluir anulados -->
<div class="flex flex-col gap-3">
<UCheckbox v-model="includeAnulados" label="Incluir anulados" size="sm" />
<UAlert
v-if="includeAnulados"
color="error"
variant="soft"
icon="i-lucide-alert-triangle"
title="Incluir anulados activado"
description="Los cálculos incluyen registros anulados."
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Cliente } from '~/composables/useClienteSelector'
interface Props {
clientes: Cliente[]
selectedClienteIds: number[]
selectedPreset: string
fechaDesde: string | null
fechaHasta: string | null
selectedTipos: string[]
selectedEstados: string[]
selectedUbicaciones: string[]
selectedCalidades: string[]
tiposOptions: any[]
estadosOptions: any[]
ubicacionesOptions: any[]
calidadesOptions: any[]
includeAnulados: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:selectedClienteIds': [value: number[]]
'update:selectedPreset': [value: string]
'update:fechaDesde': [value: string | null]
'update:fechaHasta': [value: string | null]
'update:selectedTipos': [value: string[]]
'update:selectedEstados': [value: string[]]
'update:selectedUbicaciones': [value: string[]]
'update:selectedCalidades': [value: string[]]
'update:includeAnulados': [value: boolean]
}>()
const selectedTipos = computed({
get: () => props.selectedTipos,
set: (value) => emit('update:selectedTipos', value)
})
const selectedEstados = computed({
get: () => props.selectedEstados,
set: (value) => emit('update:selectedEstados', value)
})
const selectedUbicaciones = computed({
get: () => props.selectedUbicaciones,
set: (value) => emit('update:selectedUbicaciones', value)
})
const selectedCalidades = computed({
get: () => props.selectedCalidades,
set: (value) => emit('update:selectedCalidades', value)
})
const includeAnulados = computed({
get: () => props.includeAnulados,
set: (value) => emit('update:includeAnulados', value)
})
function clearDates() {
emit('update:fechaDesde', null)
emit('update:fechaHasta', null)
emit('update:selectedPreset', '')
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<div class="flex flex-col gap-3">
<!-- Versión ultra compacta de MetadatosCard -->
<div v-if="ingresosMetadata" class="flex flex-col gap-3">
<!-- Header compacto -->
<div class="flex items-center justify-between pb-2 border-b border-[var(--brand-border)]">
<h3 class="text-sm font-bold text-[var(--brand-text)]">{{ ingresosMetadata.table }}</h3>
<span class="text-xs font-semibold text-[var(--brand-primary)]">
{{ formatNumber(recordCount) }}
</span>
</div>
<!-- Info grid compacto -->
<div class="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<div>
<dt class="text-[var(--brand-text-muted)] uppercase text-[10px]">Tamaño</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ formatSize(ingresosMetadata.approxSizeBytes) }}</dd>
</div>
<div>
<dt class="text-[var(--brand-text-muted)] uppercase text-[10px]">Desde</dt>
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(ingresosMetadata.createdAtRange?.from) }}</dd>
</div>
<div class="col-span-2">
<dt class="text-[var(--brand-text-muted)] uppercase text-[10px]">Columnas ({{ ingresosMetadata.columns?.length || 0 }})</dt>
<dd class="font-medium text-[var(--brand-text)] truncate text-[10px]">
{{ (ingresosMetadata.columns || []).slice(0, 5).join(', ') }}{{ (ingresosMetadata.columns?.length || 0) > 5 ? '...' : '' }}
</dd>
</div>
</div>
<!-- Última actualización -->
<div class="text-[10px] text-[var(--brand-text-muted)] pt-2 border-t border-[var(--brand-border)]">
{{ tableStore ? tableStore.formattedLastUpdated : 'Sin datos' }}
</div>
<!-- Botones de acción compactos -->
<div class="flex gap-2">
<UButton
:loading="isLoadingLatest"
:disabled="isLoadingAll"
size="xs"
color="primary"
variant="soft"
@click="loadLatestData"
block
>
<template #leading>
<UIcon name="i-lucide-clock" class="w-3 h-3" />
</template>
Últimos
</UButton>
<UButton
:loading="isLoadingAll"
:disabled="isLoadingLatest"
size="xs"
color="primary"
variant="outline"
@click="loadAllData"
block
>
<template #leading>
<UIcon name="i-lucide-database" class="w-3 h-3" />
</template>
Todos
</UButton>
</div>
<!-- Progress bar compacto -->
<UProgress
v-if="isLoadingLatest || isLoadingAll"
:model-value="loadingProgress"
:max="100"
size="xs"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useTableDataStore } from '~/stores/tableDataFactory'
interface Props {
ingresosMetadata: any
}
const props = defineProps<Props>()
const { $getTableStore } = useNuxtApp()
// Loading states
const isLoadingLatest = ref(false)
const isLoadingAll = ref(false)
const loadingProgress = ref(0)
// Get the table store
const tableStore = computed(() => {
if (typeof $getTableStore === 'function') {
return $getTableStore(props.ingresosMetadata.name)
}
return useTableDataStore(props.ingresosMetadata.name)
})
// Calculate record count
const recordCount = computed(() => {
return tableStore.value?.recordCount || 0
})
async function loadLatestData() {
isLoadingLatest.value = true
loadingProgress.value = 0
try {
const store = tableStore.value
if (!store) return
await store.loadLatestDataInBatches((progress) => {
loadingProgress.value = progress
})
loadingProgress.value = 100
} catch (error) {
console.error('Error loading latest data:', error)
} finally {
setTimeout(() => {
isLoadingLatest.value = false
loadingProgress.value = 0
}, 500)
}
}
async function loadAllData() {
isLoadingAll.value = true
loadingProgress.value = 0
try {
const store = tableStore.value
if (!store) return
await store.loadAllDataInBatches((progress) => {
loadingProgress.value = progress
})
loadingProgress.value = 100
} catch (error) {
console.error('Error loading all data:', error)
} finally {
setTimeout(() => {
isLoadingAll.value = false
loadingProgress.value = 0
}, 500)
}
}
function formatSize(bytes: number | null | undefined): string {
if (!bytes) return 'N/A'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let size = bytes / 1024
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatNumber(value: number): string {
return new Intl.NumberFormat('es-ES').format(value)
}
</script>

View File

@@ -2,23 +2,46 @@
<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 class="flex flex-col gap-3">
<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>
<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"
/>
<!-- 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-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
]"
>
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
</template>
@@ -79,10 +102,12 @@
</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}`">
<!-- X-axis labels (distribuidos uniformemente) -->
<g v-if="tiposActivos.length > 0">
<text
v-if="i % Math.ceil(getPointsForTipo(tiposActivos[0].value).length / 6) === 0 || i === getPointsForTipo(tiposActivos[0].value).length - 1"
v-for="(point, i) in getPointsForTipo(tiposActivos[0].value)"
:key="`label-${i}`"
v-show="shouldShowLabel(i, getPointsForTipo(tiposActivos[0].value).length)"
:x="point.x"
:y="height - padding + 20"
text-anchor="middle"
@@ -133,6 +158,18 @@ const tiposSeleccionados = ref(['uva'])
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')
@@ -200,29 +237,120 @@ interface DataPoint {
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}`
}
}
// Datos por tipo
const dataByTipo = computed(() => {
const result: Record<string, DataPoint[]> = {}
// Encontrar rango temporal solo de los tipos seleccionados
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] = []
})
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)
const timeRange = generateTimeRange(minDate, maxDate, granularity)
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>()
const porPeriodo = new Map<string, number>()
ingresosFiltrados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
const fecha = new Date(ingreso.created_at!)
const key = getDateKey(fecha, granularity)
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
@@ -230,22 +358,25 @@ const dataByTipo = computed(() => {
valor = ingreso.peso_seco
}
porDia.set(fecha, (porDia.get(fecha) || 0) + valor)
porPeriodo.set(key, (porPeriodo.get(key) || 0) + valor)
})
let acumulado = 0
const puntos: DataPoint[] = []
Array.from(porDia.entries()).forEach(([fecha, valor]) => {
timeRange.forEach(fecha => {
const key = getDateKey(fecha, granularity)
const valor = porPeriodo.get(key) || 0
acumulado += valor
puntos.push({
date: new Date(fecha),
date: fecha,
value: valor,
acumulado,
x: 0,
y: 0,
label: fecha,
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
label: key,
dateLabel: formatDateByGranularity(fecha, granularity)
})
})
@@ -266,15 +397,27 @@ const maxValue = computed(() => {
return max * 1.1 || 100
})
// Obtener todas las fechas únicas
// Obtener todas las fechas únicas (ahora se genera automáticamente)
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())
const firstTipo = tiposSeleccionados.value[0]
if (!firstTipo || !dataByTipo.value[firstTipo]) return []
return dataByTipo.value[firstTipo].map(p => p.label)
})
// 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
}
// Calcular puntos con coordenadas para cada tipo
function getPointsForTipo(tipo: string) {
const data = dataByTipo.value[tipo]
@@ -308,6 +451,8 @@ function getAreaPathForTipo(tipo: string) {
const lastPoint = points[points.length - 1]
const firstPoint = points[0]
if (!lastPoint || !firstPoint) return ''
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
}

View File

@@ -2,23 +2,46 @@
<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 class="flex flex-col gap-3">
<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>
<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"
/>
<!-- 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-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
]"
>
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
</template>
@@ -112,10 +135,12 @@
</circle>
</g>
<!-- X-axis labels -->
<g v-if="tiposActivos.length > 0" v-for="(point, i) in getPointsPagadoForTipo(tiposActivos[0].value)" :key="`label-${i}`">
<!-- X-axis labels (distribuidos uniformemente) -->
<g v-if="tiposActivos.length > 0">
<text
v-if="i % Math.ceil(getPointsPagadoForTipo(tiposActivos[0].value).length / 6) === 0 || i === getPointsPagadoForTipo(tiposActivos[0].value).length - 1"
v-for="(point, i) in getPointsPagadoForTipo(tiposActivos[0].value)"
:key="`label-${i}`"
v-show="shouldShowLabel(i, getPointsPagadoForTipo(tiposActivos[0].value).length)"
:x="point.x"
:y="height - padding + 20"
text-anchor="middle"
@@ -174,6 +199,18 @@ const tiposSeleccionados = ref(['uva'])
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')
@@ -240,30 +277,135 @@ interface DataPoint {
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
}
// Datos pagado por tipo
const dataPagadoByTipo = computed(() => {
const result: Record<string, DataPoint[]> = {}
// Encontrar rango temporal solo de los tipos seleccionados
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] = []
})
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)
const timeRange = generateTimeRange(minDate, maxDate, granularity)
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>()
const porPeriodo = new Map<string, number>()
ingresosFiltrados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
const fecha = new Date(ingreso.created_at!)
const key = getDateKey(fecha, granularity)
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
@@ -271,21 +413,24 @@ const dataPagadoByTipo = computed(() => {
valor = ingreso.peso_seco
}
porDia.set(fecha, (porDia.get(fecha) || 0) + valor)
porPeriodo.set(key, (porPeriodo.get(key) || 0) + valor)
})
let acumulado = 0
const puntos: DataPoint[] = []
Array.from(porDia.entries()).forEach(([fecha, valor]) => {
timeRange.forEach(fecha => {
const key = getDateKey(fecha, granularity)
const valor = porPeriodo.get(key) || 0
acumulado += valor
puntos.push({
date: new Date(fecha),
date: fecha,
value: acumulado,
x: 0,
y: 0,
label: fecha,
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
label: key,
dateLabel: formatDateByGranularity(fecha, granularity)
})
})
@@ -299,27 +444,40 @@ const dataPagadoByTipo = computed(() => {
const dataDepositoByTipo = computed(() => {
const result: Record<string, DataPoint[]> = {}
// Encontrar rango temporal solo de los tipos seleccionados
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] = []
})
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)
const timeRange = generateTimeRange(minDate, maxDate, granularity)
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>()
const totalPorPeriodo = new Map<string, number>()
const pagadoPorPeriodo = new Map<string, number>()
todosFiltrados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
const fecha = new Date(ingreso.created_at!)
const key = getDateKey(fecha, granularity)
let valor = 0
if (tipo === 'uva') {
@@ -330,11 +488,12 @@ const dataDepositoByTipo = computed(() => {
valor = ingreso.peso_seco
}
totalPorDia.set(fecha, (totalPorDia.get(fecha) || 0) + valor)
totalPorPeriodo.set(key, (totalPorPeriodo.get(key) || 0) + valor)
})
pagadosFiltrados.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
const fecha = new Date(ingreso.created_at!)
const key = getDateKey(fecha, granularity)
let valor = 0
if (tipo === 'uva') {
@@ -345,28 +504,26 @@ const dataDepositoByTipo = computed(() => {
valor = ingreso.peso_seco
}
pagadoPorDia.set(fecha, (pagadoPorDia.get(fecha) || 0) + valor)
pagadoPorPeriodo.set(key, (pagadoPorPeriodo.get(key) || 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
timeRange.forEach(fecha => {
const key = getDateKey(fecha, granularity)
acumuladoTotal += totalPorPeriodo.get(key) || 0
acumuladoPagado += pagadoPorPeriodo.get(key) || 0
const deposito = acumuladoTotal - acumuladoPagado
puntos.push({
date: new Date(fecha),
date: fecha,
value: deposito,
x: 0,
y: 0,
label: fecha,
dateLabel: new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' })
label: key,
dateLabel: formatDateByGranularity(fecha, granularity)
})
})
@@ -456,6 +613,8 @@ function getAreaPagadoPathForTipo(tipo: string) {
const lastPoint = points[points.length - 1]
const firstPoint = points[0]
if (!lastPoint || !firstPoint) return ''
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
}
@@ -470,6 +629,8 @@ function getAreaDepositoPathForTipo(tipo: string) {
const lastPoint = points[points.length - 1]
const firstPoint = points[0]
if (!lastPoint || !firstPoint) return ''
return `${linePart} L ${lastPoint.x} ${height - padding} L ${firstPoint.x} ${height - padding} Z`
}

View File

@@ -2,23 +2,46 @@
<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 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: 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>
<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"
/>
<!-- 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-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
]"
>
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
</template>
@@ -63,16 +86,18 @@
</g>
</g>
<!-- X-axis labels -->
<g v-for="(fecha, i) in fechasUnicas" :key="`label-${i}`">
<!-- X-axis labels (distribuidos uniformemente) -->
<g v-if="tiposActivos.length > 0">
<text
v-if="i % Math.ceil(fechasUnicas.length / 8) === 0 || i === fechasUnicas.length - 1"
:x="padding + (i / (fechasUnicas.length - 1 || 1)) * chartWidth + barWidth / 2"
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)]"
>
{{ new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' }) }}
{{ point.dateLabel }}
</text>
</g>
</svg>
@@ -117,6 +142,18 @@ const tiposSeleccionados = ref(['uva'])
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')
@@ -180,19 +217,117 @@ interface DataPoint {
x: number
y: number
label: string
dateLabel: string
}
// Obtener todas las fechas únicas
// 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 fechas = new Set<string>()
props.ingresos
const allDates = 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())
.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(() => {
@@ -203,6 +338,22 @@ const barWidth = computed(() => {
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>()
@@ -210,7 +361,8 @@ const dataByTipo = computed(() => {
.filter(i => i.tipo === tipo)
.filter(i => i.created_at)
.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
const fecha = new Date(ingreso.created_at!)
const key = getDateKey(fecha, granularity)
let valor = 0
if (tipo === 'uva') {
@@ -221,7 +373,7 @@ const dataByTipo = computed(() => {
valor = ingreso.peso_seco
}
result[tipo].set(fecha, (result[tipo].get(fecha) || 0) + valor)
result[tipo].set(key, (result[tipo].get(key) || 0) + valor)
})
})
@@ -242,17 +394,44 @@ 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
label: fecha,
dateLabel: formatDateByGranularity(tempDate, granularity)
}
})
}

View File

@@ -2,23 +2,46 @@
<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 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>
<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"
/>
<!-- 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-gray-700/30 text-gray-400 hover:bg-gray-700/50 hover:text-gray-300'
]"
>
<UIcon :name="option.icon" class="w-3.5 h-3.5" />
<span>{{ option.label }}</span>
</button>
</div>
</div>
</div>
</template>
@@ -63,16 +86,18 @@
</g>
</g>
<!-- X-axis labels -->
<g v-for="(fecha, i) in fechasUnicas" :key="`label-${i}`">
<!-- X-axis labels (distribuidos uniformemente) -->
<g v-if="tiposActivos.length > 0">
<text
v-if="i % Math.ceil(fechasUnicas.length / 8) === 0 || i === fechasUnicas.length - 1"
:x="padding + (i / (fechasUnicas.length - 1 || 1)) * chartWidth + barWidth / 2"
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)]"
>
{{ new Date(fecha).toLocaleDateString('es-HN', { day: 'numeric', month: 'short' }) }}
{{ point.dateLabel }}
</text>
</g>
</svg>
@@ -122,6 +147,18 @@ 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')
@@ -183,19 +220,117 @@ interface DataPoint {
x: number
y: number
label: string
dateLabel: string
}
// Obtener todas las fechas únicas
// 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 fechas = new Set<string>()
props.ingresos
const allDates = 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())
.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(() => {
@@ -206,6 +341,22 @@ const barWidth = computed(() => {
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>()
@@ -213,7 +364,8 @@ const dataByTipo = computed(() => {
.filter(i => i.tipo === tipo)
.filter(i => i.created_at)
.forEach(ingreso => {
const fecha = new Date(ingreso.created_at!).toLocaleDateString('es-HN')
const fecha = new Date(ingreso.created_at!)
const key = getDateKey(fecha, granularity)
// Calcular inversión según el tipo
let inversion = 0
@@ -223,7 +375,7 @@ const dataByTipo = computed(() => {
inversion = (ingreso.precio / 2) * ingreso.peso_seco
}
result[tipo].set(fecha, (result[tipo].get(fecha) || 0) + inversion)
result[tipo].set(key, (result[tipo].get(key) || 0) + inversion)
})
})
@@ -254,17 +406,44 @@ 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
label: fecha,
dateLabel: formatDateByGranularity(tempDate, granularity)
}
})
}