Files
analiticaNucleo/nuxt4-app/app/pages/informe-ingresos.vue

1182 lines
41 KiB
Vue

<template>
<div class="flex flex-col">
<!-- Toolbar con switches de secciones -->
<UDashboardToolbar>
<template #right>
<div class="flex items-center gap-3">
<USwitch
v-model="pageSections.totalesCafe"
size="xs"
label="Totales"
/>
<USwitch
v-model="pageSections.totalesVerde"
size="xs"
label="Verde"
/>
<USwitch
v-model="pageSections.tablaIngresos"
size="xs"
label="Tabla"
/>
<USwitch
v-model="pageSections.top10Clientes"
size="xs"
label="Top 10"
/>
<USwitch
v-model="pageSections.graficas"
size="xs"
label="Gráficas"
/>
</div>
</template>
</UDashboardToolbar>
<div class="flex flex-col gap-8 p-6">
<!-- Loading State -->
<UCard v-if="loading && !ingresosStore.hasData" class="brand-card border border-transparent">
<div class="flex flex-col items-center justify-center gap-4 py-10 text-[var(--brand-text-muted)]">
<div class="flex items-center gap-3">
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
</div>
<UProgress
v-if="loadingProgress > 0"
:model-value="loadingProgress"
:max="100"
size="sm"
class="w-64"
/>
<span v-if="loadingProgress > 0" class="text-xs text-[var(--brand-text-muted)]">
{{ Math.round(loadingProgress) }}%
</span>
</div>
</UCard>
<!-- Error State -->
<div v-else-if="error" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
<p>Error al cargar datos: {{ error }}</p>
</div>
<!-- Main Content -->
<template v-else>
<!-- Loading overlay when filtering data -->
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isFilteringData"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
>
<UCard class="brand-card border border-[#c08040]/30">
<div class="flex flex-col items-center justify-center gap-4 py-6 px-8">
<span class="inline-flex h-12 w-12 animate-spin rounded-full border-4 border-[#c08040] border-t-transparent" />
<div class="flex flex-col items-center gap-2">
<span class="text-lg font-semibold text-[var(--brand-text)]">Filtrando datos...</span>
<span class="text-sm text-[var(--brand-text-muted)]">Por favor espere</span>
</div>
</div>
</UCard>
</div>
</Transition>
<!-- Metadatos Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" :compact="true" />
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" :compact="true" />
</div>
<!-- Filtros Activos Badge Card -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<IngresosFiltrosActivos
v-if="hasFiltrosActivos"
:clientes="clientes"
:selected-cliente-ids="selectedClienteIds"
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
:selected-tipos="selectedTipos"
:selected-estados="selectedEstados"
:selected-ubicaciones="selectedUbicaciones"
:selected-calidades="selectedCalidades"
:include-anulados="includeAnulados"
:no-filter="noFilter"
:tipos-options="tiposCafeOptions"
:estados-options="estadosOptions"
@update:selected-cliente-ids="selectedClienteIds = $event"
@update:fecha-desde="fechaDesde = $event"
@update:fecha-hasta="fechaHasta = $event"
@update:selected-preset="selectedPreset = $event"
@update:selected-tipos="selectedTipos = $event"
@update:selected-estados="selectedEstados = $event"
@update:selected-ubicaciones="selectedUbicaciones = $event"
@update:selected-calidades="selectedCalidades = $event"
@update:include-anulados="includeAnulados = $event"
@update:no-filter="noFilter = $event"
@show-cliente-selector="showClienteSelectorTemp = true"
@show-filtros="filtrosCollapsed = false"
/>
</Transition>
<!-- Selector de clientes temporal (cuando se hace clic en "Agregar cliente") -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<UCard v-if="showClienteSelectorTemp" class="brand-card border border-blue-500/30">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Agregar más clientes</h3>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-x"
@click="showClienteSelectorTemp = false"
/>
</div>
</template>
<ClienteSelector
:clientes="clientes"
:selected-ids="selectedClienteIds"
@update:selected-ids="handleClienteSelection"
/>
</UCard>
</Transition>
<!-- Filtros Card Colapsable -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<UCard v-show="!filtrosCollapsed" class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-filter" class="size-4 text-[#c08040]" />
<h3 class="text-sm font-semibold text-[var(--brand-text)]">Filtros Disponibles</h3>
</div>
<UButton
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-chevron-up"
@click="filtrosCollapsed = true"
>
Ocultar
</UButton>
</div>
</template>
<InformeIngresosFiltrosPanel
:clientes="clientes"
:selected-cliente-ids="selectedClienteIds"
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
:selected-tipos="selectedTipos"
:selected-estados="selectedEstados"
:selected-ubicaciones="selectedUbicaciones"
:selected-calidades="selectedCalidades"
:tipos-options="tiposCafeOptions"
:estados-options="estadosOptions"
:ubicaciones-options="ubicacionesOptions"
:calidades-options="calidadesOptions"
:include-anulados="includeAnulados"
:no-filter="noFilter"
@update:selected-cliente-ids="selectedClienteIds = $event"
@update:selected-preset="selectedPreset = $event"
@update:fecha-desde="fechaDesde = $event"
@update:fecha-hasta="fechaHasta = $event"
@update:selected-tipos="selectedTipos = $event"
@update:selected-estados="selectedEstados = $event"
@update:selected-ubicaciones="selectedUbicaciones = $event"
@update:selected-calidades="selectedCalidades = $event"
@update:include-anulados="includeAnulados = $event"
@update:no-filter="noFilter = $event"
@hide-filtros="filtrosCollapsed = true"
/>
</UCard>
</Transition>
<!-- Totales por Café -->
<UCard v-show="pageSections.totalesCafe" class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold brand-section-title">Totales por Café</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
{{ showMonetaryView ? 'Vista de valores monetarios' : 'Vista de quintales (qq) y libras (lb)' }}
</p>
</div>
<UButton
size="sm"
color="neutral"
variant="outline"
@click="showMonetaryView = !showMonetaryView"
>
<template #leading>
<UIcon :name="showMonetaryView ? 'i-lucide-scale' : 'i-lucide-dollar-sign'" />
</template>
{{ showMonetaryView ? 'Ver qq/lb' : 'Ver Monetario' }}
</UButton>
</div>
</template>
<IngresosTotalesIngresoCompra v-if="!showMonetaryView" :metrics="ingresosMetrics" />
<IngresosTotalesMonetarios v-else :metrics="ingresosMetrics" />
</UCard>
<!-- Totales Netos de Verde -->
<IngresosTotalesVerde v-if="hasVerdeData" v-show="pageSections.totalesVerde" :metrics="ingresosMetrics" />
<!-- Backdrop para fullscreen -->
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isTableFullscreen"
class="fixed inset-0 bg-black/80 z-40 backdrop-blur-sm"
@click="toggleTableFullscreen"
/>
</Transition>
<!-- Vista Tabla según tab activo -->
<UCard
v-show="pageSections.tablaIngresos"
:class="[
'brand-card border border-transparent transition-all duration-300',
isTableFullscreen ? 'fixed inset-4 z-50 rounded-lg overflow-auto' : ''
]"
>
<template #header>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div>
<h2 :class="['text-xl font-bold', tableTitleClass]">{{ tableTitle }}</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
{{ tableDescription }}
</p>
</div>
<UButton
:icon="isTableFullscreen ? 'i-lucide-minimize' : 'i-lucide-maximize'"
color="neutral"
variant="ghost"
size="sm"
@click="toggleTableFullscreen"
:title="isTableFullscreen ? 'Salir de pantalla completa' : 'Pantalla completa'"
/>
</div>
<div ref="viewSelectorRef" class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-12">
<button
v-for="option in viewOptions"
:key="option.value"
@click="selectedView = option.value"
:class="[
'relative inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg border transition-all duration-200',
selectedView === option.value
? `${option.borderColor} ${option.bgColor} shadow-lg ${option.shadowColor}`
: 'border-gray-600/30 bg-gray-700/20 hover:bg-gray-700/30'
]"
>
<UIcon
:name="option.icon"
:class="[
'relative z-10 w-5 h-5',
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
'text-gray-400'
]"
/>
<span
:class="[
'relative z-10 font-medium text-base whitespace-nowrap',
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
'text-gray-400'
]"
>
{{ option.label }}
</span>
<!-- Badge con contador -->
<span
:class="[
'relative z-10 inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full text-xs font-bold transition-all',
selectedView === option.value && option.color === 'cyan' ? 'bg-cyan-500/30 text-cyan-300 border border-cyan-400/50' :
selectedView === option.value && option.color === 'yellow' ? 'bg-yellow-500/30 text-yellow-300 border border-yellow-400/50' :
option.color === 'cyan' ? 'bg-cyan-500/20 text-cyan-400/80 border border-cyan-500/30' :
option.color === 'yellow' ? 'bg-yellow-500/20 text-yellow-400/80 border border-yellow-500/30' :
'bg-gray-500/20 text-gray-400 border border-gray-500/30'
]"
>
{{ getViewCount(option.value) }}
</span>
</button>
</div>
</div>
</div>
</template>
<!-- Vista de Ingresos (con expansión de clientes) -->
<IngresosVistaTablaIngresos
v-if="selectedView === 'ingresos'"
:records="ingresosFiltrados"
:clientes="clientes"
/>
<!-- Vista de Clientes (con expansión de ingresos) -->
<ClientesVistaTablaClientes
v-else-if="selectedView === 'clientes'"
:records="clientesFiltrados"
:ingresos="ingresosFiltrados"
/>
</UCard>
<!-- Top 10 Clientes -->
<IngresosTopClientes v-show="pageSections.top10Clientes" :ingresos="ingresosFiltrados" :clientes="clientesFiltrados" />
<!-- Sección de Gráficas: Acumuladores -->
<div v-show="pageSections.graficas" 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 v-show="pageSections.graficas" 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>
</div>
</template>
<script setup lang="ts">
import { useTableDataStore } from '~/stores/tableDataFactory'
import { useMetadataStore } from '~/stores/metadata'
import { useIngresosMetrics } from '~/composables/useIngresosMetrics'
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
// Define page metadata
definePageMeta({
layout: 'informe',
title: 'Informe Ingresos'
})
// Obtener estado colapsado y visibilidad de secciones desde el layout
const { filtrosCollapsed, metadatosCollapsed, pageSections, setFiltrosResumen, setDatasourceCounts, setFilteredResults, setActiveFilters, setMetadatosNeedUpdate } = useInformeLayout()
// Computed properties invertidos para switches (ON = visible, OFF = oculto)
const showFiltros = computed({
get: () => !filtrosCollapsed.value,
set: (value) => { filtrosCollapsed.value = !value }
})
const showMetadatos = computed({
get: () => !metadatosCollapsed.value,
set: (value) => { metadatosCollapsed.value = !value }
})
// View modes - now only 2 options
type ViewMode = 'ingresos' | 'clientes'
const selectedView = ref<ViewMode>('ingresos')
const viewOptions = [
{
value: 'ingresos' as ViewMode,
label: 'Ingresos',
icon: 'i-lucide-trending-up',
color: 'cyan',
gradient: 'from-cyan-500 to-cyan-600',
borderColor: 'border-cyan-500/50',
bgColor: 'bg-cyan-500/10',
shadowColor: 'shadow-cyan-500/20'
},
{
value: 'clientes' as ViewMode,
label: 'Clientes',
icon: 'i-lucide-users',
color: 'yellow',
gradient: 'from-yellow-500 to-yellow-600',
borderColor: 'border-yellow-500/50',
bgColor: 'bg-yellow-500/10',
shadowColor: 'shadow-yellow-500/20'
}
]
// Dynamic table title and description
const tableTitle = computed(() => {
switch (selectedView.value) {
case 'ingresos':
return 'Tabla de Ingresos'
case 'clientes':
return 'Tabla de Clientes'
default:
return 'Tabla de Datos'
}
})
const tableTitleClass = computed(() => {
switch (selectedView.value) {
case 'ingresos':
return 'text-cyan-400'
case 'clientes':
return 'text-yellow-500'
default:
return 'brand-section-title'
}
})
const tableDescription = computed(() => {
switch (selectedView.value) {
case 'ingresos':
return `${ingresosFiltrados.value.length} ingresos (expandibles para ver cliente)`
case 'clientes': {
const clientesConIngresos = clientesFiltrados.value.filter(c =>
ingresosFiltrados.value.some(i => i.cliente_id === c.id)
).length
return `${clientesConIngresos} clientes con ingresos (expandibles para ver detalles)`
}
default:
return 'Selecciona una vista'
}
})
// Ref for view selector element
const viewSelectorRef = ref<HTMLElement | null>(null)
// Watch for view changes and scroll to keep the selector in view
watch(selectedView, (newView, oldView) => {
nextTick(() => {
if (viewSelectorRef.value) {
viewSelectorRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
})
interface ClienteRecord extends Record<string, unknown> {
id: number
created_at: string
updated_at: string
name: string
cedula?: number
ubicacion?: string
grupo_estudio?: string
empleado?: boolean
avatar_url?: string
telefono?: string
idciat?: number
}
// Initialize stores
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
const clientesStore = useTableDataStore<ClienteRecord>('clientes')
// Reactive data from stores
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
const clientes = computed(() => clientesStore.allRecords as ClienteRecord[])
// -------------------------------
// Filtros con persistencia en cookies
// -------------------------------
const includeAnulados = useCookie<boolean>('informe-ingresos-anulados', { default: () => false })
const noFilter = useCookie<boolean>('informe-ingresos-no-filter', { default: () => false })
type PresetValue =
| '' | 'custom' | 'hoy' | 'semana' | 'mes' | 'ytd'
| 'cosecha-20-21' | 'cosecha-21-22' | 'cosecha-22-23'
| 'cosecha-23-24' | 'cosecha-24-25' | 'cosecha-25-26'
const selectedPreset = useCookie<PresetValue>('informe-ingresos-preset', { default: () => 'hoy' })
const fechaDesde = useCookie<string | null>('informe-ingresos-fecha-desde', { default: () => null })
const fechaHasta = useCookie<string | null>('informe-ingresos-fecha-hasta', { default: () => null })
const selectedClienteIds = useCookie<number[]>('informe-ingresos-clientes', { default: () => [] })
// Filtros avanzados
const selectedTipos = useCookie<string[]>('informe-ingresos-tipos', { default: () => [] })
const selectedEstados = useCookie<string[]>('informe-ingresos-estados', { default: () => [] })
const selectedUbicaciones = useCookie<string[]>('informe-ingresos-ubicaciones', { default: () => [] })
const selectedCalidades = useCookie<string[]>('informe-ingresos-calidades', { default: () => [] })
// Debounce para manejar cambios de filtros
let filterChangeTimeout: ReturnType<typeof setTimeout> | null = null
// Watch para actualizar filtrados cuando cambien los filtros o los datos
watch([ingresos, selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstados, selectedUbicaciones, selectedCalidades, includeAnulados], () => {
// Cancelar timeout previo si existe
if (filterChangeTimeout) {
clearTimeout(filterChangeTimeout)
}
// Actualizar noFilter si es necesario
const hasAnyFilter = selectedClienteIds.value.length > 0 ||
fechaDesde.value !== null ||
fechaHasta.value !== null ||
selectedTipos.value.length > 0 ||
selectedEstados.value.length > 0 ||
selectedUbicaciones.value.length > 0 ||
selectedCalidades.value.length > 0 ||
includeAnulados.value
if (hasAnyFilter && noFilter.value) {
noFilter.value = false
}
// Debounce para evitar múltiples actualizaciones rápidas
filterChangeTimeout = setTimeout(() => {
updateFiltrados()
}, 100)
}, { immediate: true })
// Opciones para filtros avanzados
const tiposCafeOptions = [
{ value: 'uva', label: 'Uva' },
{ value: 'oreado', label: 'Oreado' },
{ value: 'mojado', label: 'Mojado' },
{ value: 'verde', label: 'Verde' }
]
const estadosOptions = [
{ value: 'pagado', label: 'Pagado' },
{ value: 'pendiente', label: 'Pendiente' }
]
// Ubicaciones dinámicas basadas en los clientes
const ubicacionesOptions = computed(() => {
const ubicaciones = new Set<string>()
clientes.value?.forEach(c => {
if (c.ubicacion) {
ubicaciones.add(c.ubicacion)
}
})
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
.map(v => tiposCafeOptions.find(o => o.value === v)?.label)
.filter(Boolean)
.join(', ')
})
const selectedEstadosLabels = computed(() => {
return selectedEstados.value
.map(v => estadosOptions.find(o => o.value === v)?.label)
.filter(Boolean)
.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 ||
selectedCalidades.value.length > 0
})
// Clear advanced filters
function clearAdvancedFilters() {
selectedTipos.value = []
selectedEstados.value = []
selectedUbicaciones.value = []
selectedCalidades.value = []
}
async function onToggleAnulados(newValue: boolean | 'indeterminate') {
if (newValue === true) {
// Pedir confirmación al activar
const confirmed = confirm(
'⚠️ ADVERTENCIA\n\n' +
'Está a punto de incluir registros ANULADOS en los cálculos.\n\n' +
'Esto puede afectar significativamente los resultados financieros y métricas.\n\n' +
'¿Está seguro de que desea continuar?'
)
if (!confirmed) {
// Si cancela, revertir el cambio
includeAnulados.value = false
console.log('User cancelled including anulados')
} else {
console.log('User confirmed including anulados')
}
} else {
// Al desactivar, no pedir confirmación
console.log('Anulados disabled')
}
}
const rangoLegible = computed(() => {
if (!fechaDesde.value && !fechaHasta.value) return 'Sin filtro de fecha'
const f = fechaDesde.value ?? '—'
const t = fechaHasta.value ?? '—'
return `${f}${t}`
})
function isAnulado(row: any): boolean {
const estado = (row?.estado ?? '').toString().toLowerCase()
const fechaAn = row?.fecha_anulado ?? null
return estado === 'anulado' || !!fechaAn
}
function isWithinDate(row: any, from?: string | null, to?: string | null): boolean {
// Intentar con 'fecha' primero, luego con 'created_at'
const fechaStr = row?.fecha || row?.created_at
if (!fechaStr) return false
const fecha = new Date(fechaStr)
if (isNaN(fecha.getTime())) return false
if (from) {
const fd = new Date(from + 'T00:00:00-06:00')
if (fecha < fd) return false
}
if (to) {
const td = new Date(to + 'T23:59:59-06:00')
if (fecha > td) return false
}
return true
}
function isClienteSelected(clienteId: number): boolean {
// Si no hay clientes seleccionados, mostrar todos
if (selectedClienteIds.value.length === 0) return true
// Si hay clientes seleccionados, filtrar por ellos
return selectedClienteIds.value.includes(clienteId)
}
function matchesTipoCafe(ingreso: IngresoRecord): boolean {
if (selectedTipos.value.length === 0) return true
return selectedTipos.value.includes(ingreso.tipo)
}
function matchesEstado(ingreso: IngresoRecord): boolean {
if (selectedEstados.value.length === 0) return true
return selectedEstados.value.includes(ingreso.estado)
}
function matchesUbicacion(ingreso: IngresoRecord): boolean {
if (selectedUbicaciones.value.length === 0) return true
const cliente = clientes.value?.find(c => c.id === ingreso.cliente_id)
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 []
return (clientes.value ?? []).filter(c => selectedClienteIds.value.includes(c.id))
})
// Remove a cliente from selection
function removeCliente(clienteId: number) {
selectedClienteIds.value = selectedClienteIds.value.filter(id => id !== clienteId)
}
// Helper para calcular ingresos filtrados
function calculateIngresosFiltrados() {
return (ingresos.value ?? [])
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
.filter(r => isClienteSelected(r.cliente_id))
.filter(r => matchesTipoCafe(r))
.filter(r => matchesEstado(r))
.filter(r => matchesUbicacion(r))
.filter(r => matchesCalidad(r))
}
// Usar shallowRef para controlar cuando se actualiza
const ingresosFiltrados = shallowRef<IngresoRecord[]>([])
// Función para actualizar filtrados de forma controlada
async function updateFiltrados() {
// Activar loading
isFilteringData.value = true
// Esperar a que el DOM se actualice
await nextTick()
// Dar dos frames para asegurar que el loading se renderice
await new Promise(resolve => requestAnimationFrame(() => {
requestAnimationFrame(resolve)
}))
// Ahora sí, calcular los filtrados
ingresosFiltrados.value = calculateIngresosFiltrados()
// Esperar un frame más para que Vue procese los cambios
await new Promise(resolve => requestAnimationFrame(resolve))
// Desactivar loading
isFilteringData.value = false
}
const clientesFiltrados = computed((): ClienteRecord[] => {
// Si hay clientes seleccionados, filtrar por ellos
if (selectedClienteIds.value.length === 0) return clientes.value ?? []
return (clientes.value ?? []).filter(c => selectedClienteIds.value.includes(c.id))
})
// Métricos basados en filtrados
const ingresosMetrics = useIngresosMetrics(ingresosFiltrados)
// Detectar si hay filtros activos (incluyendo "Sin filtros")
const hasFiltrosActivos = computed(() => {
return selectedClienteIds.value.length > 0 ||
fechaDesde.value !== null ||
fechaHasta.value !== null ||
selectedTipos.value.length > 0 ||
selectedEstados.value.length > 0 ||
selectedUbicaciones.value.length > 0 ||
selectedCalidades.value.length > 0 ||
includeAnulados.value ||
noFilter.value
})
// Watch para mostrar automáticamente el panel de filtros si no hay barras visibles
watch([hasFiltrosActivos, filtrosCollapsed], () => {
// Si no hay filtros activos Y el panel de filtros está colapsado, expandirlo
if (!hasFiltrosActivos.value && filtrosCollapsed.value) {
filtrosCollapsed.value = false
}
})
// Toggle para vista de totales
const showMonetaryView = ref(false)
// Toggle para fullscreen de tabla
const isTableFullscreen = ref(false)
function toggleTableFullscreen() {
isTableFullscreen.value = !isTableFullscreen.value
// Prevenir scroll del body cuando está en fullscreen
if (isTableFullscreen.value) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
// Estado temporal para mostrar selector de clientes desde el botón "+"
const showClienteSelectorTemp = ref(false)
function handleClienteSelection(newIds: number[]) {
selectedClienteIds.value = newIds
// Cerrar el selector temporal cuando se hace una selección
if (newIds.length > selectedClienteIds.value.length) {
nextTick(() => {
showClienteSelectorTemp.value = false
})
}
}
// Atajo de teclado para salir de fullscreen con Escape
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isTableFullscreen.value) {
toggleTableFullscreen()
}
}
// Cleanup cuando se desmonta el componente
onUnmounted(() => {
window.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
// Verificar si hay datos de verde para mostrar la sección
const hasVerdeData = computed(() => {
return ingresosMetrics.totalLbNetoVerde.value > 0 ||
ingresosMetrics.inversionVerdeHastaFecha.value > 0 ||
ingresosMetrics.totalLbNetoCompradoVerde.value > 0
})
// Función para obtener el conteo de registros según la vista
function getViewCount(view: ViewMode): number {
switch (view) {
case 'ingresos':
return ingresosFiltrados.value.length
case 'clientes':
// Solo contar clientes que tienen ingresos en los datos filtrados
return clientesFiltrados.value.filter(c =>
ingresosFiltrados.value.some(i => i.cliente_id === c.id)
).length
default:
return 0
}
}
// Loading and error states
const loading = computed(() => ingresosStore.isLoading || clientesStore.isLoading)
const error = computed(() => ingresosStore.error || clientesStore.error)
const loadingProgress = ref(0)
const isFilteringData = ref(false)
// Metadatos desde el store de metadata
const metadataStore = useMetadataStore()
const ingresosMetadata = computed(() => {
const meta = metadataStore.metadata.find((t: any) => t.table === 'vista_detalle_ingresos')
return meta ? { ...meta, name: 'ingresos' } : null
})
const clientesMetadata = computed(() => {
const meta = metadataStore.metadata.find((t: any) => t.table === 'clientes')
return meta ? { ...meta, name: 'clientes' } : null
})
// Detectar si los metadatos necesitan actualización
const metadatosNeedUpdate = computed(() => {
// Comparar count de ingresos
if (ingresosMetadata.value && ingresosMetadata.value.count) {
if (ingresos.value.length !== ingresosMetadata.value.count) {
return true
}
}
// Comparar count de clientes
if (clientesMetadata.value && clientesMetadata.value.count) {
if (clientes.value.length !== clientesMetadata.value.count) {
return true
}
}
return false
})
// Actualizar el estado de warning en el layout
watch(metadatosNeedUpdate, (needsUpdate) => {
setMetadatosNeedUpdate(needsUpdate)
}, { immediate: true })
// Load data on mount
onMounted(async () => {
try {
// Cargar metadatos primero
// if (metadataStore.metadata.length === 0) {
// await (metadataStore as any).loadMetadata()
// }
// Cache primero para UX
// await Promise.all([
// ingresosStore.loadFromCache(),
// clientesStore.loadFromCache()
// ])
// Si falta data, cargar en lotes
// if (!ingresosStore.hasData || !clientesStore.hasData) {
// loadingProgress.value = 0
// let ingresosProgress = 0
// let clientesProgress = 0
// await Promise.all([
// ingresosStore.loadAllDataInBatches((progress) => {
// ingresosProgress = progress
// loadingProgress.value = (ingresosProgress + clientesProgress) / 2
// }),
// clientesStore.loadAllDataInBatches((progress) => {
// clientesProgress = progress
// loadingProgress.value = (ingresosProgress + clientesProgress) / 2
// })
// ])
// loadingProgress.value = 0
// }
} catch (err) {
console.error('Error loading data:', err)
} finally {
// Re-evaluar presets dinámicos (hoy, semana, mes, ytd) al montar
const dynamicPresets = ['hoy', 'semana', 'mes', 'ytd']
if (selectedPreset.value && dynamicPresets.includes(selectedPreset.value)) {
// Forzar re-evaluación del preset para obtener fechas actualizadas
const currentPreset = selectedPreset.value
selectedPreset.value = ''
nextTick(() => {
selectedPreset.value = currentPreset as PresetValue
})
}
// Si no hay preset, usar 'hoy' por defecto
if (!selectedPreset.value) {
selectedPreset.value = 'hoy'
}
includeAnulados.value = false
// Clear advanced filters on mount
selectedTipos.value = []
selectedEstados.value = []
selectedUbicaciones.value = []
}
// Listener para escape key en fullscreen
window.addEventListener('keydown', handleEscape)
// Enviar counts de datasources al layout
setDatasourceCounts({
ingresos: ingresos.value.length,
clientes: clientes.value.length
})
// Watch para actualizar counts de datasources cuando cambien
watch([ingresos, clientes], () => {
setDatasourceCounts({
ingresos: ingresos.value.length,
clientes: clientes.value.length
})
})
// Watch para actualizar resultados filtrados en el navbar
watch([ingresosFiltrados, clientesFiltrados], () => {
setFilteredResults({
ingresos: ingresosFiltrados.value.length,
clientes: clientesFiltrados.value.length
})
}, { immediate: true })
// Actualizar resumen de filtros cuando cambien
watch([selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstados, selectedUbicaciones, selectedCalidades, includeAnulados], () => {
const filtrosActivos: string[] = []
if (selectedClienteIds.value.length > 0) {
filtrosActivos.push(`${selectedClienteIds.value.length} cliente${selectedClienteIds.value.length > 1 ? 's' : ''}`)
}
if (fechaDesde.value || fechaHasta.value) {
filtrosActivos.push('rango de fechas')
}
if (selectedTipos.value.length > 0) {
filtrosActivos.push(`${selectedTipos.value.length} tipo${selectedTipos.value.length > 1 ? 's' : ''}`)
}
if (selectedEstados.value.length > 0) {
filtrosActivos.push(`${selectedEstados.value.length} estado${selectedEstados.value.length > 1 ? 's' : ''}`)
}
if (selectedUbicaciones.value.length > 0) {
filtrosActivos.push(`${selectedUbicaciones.value.length} ubicación${selectedUbicaciones.value.length > 1 ? 'es' : ''}`)
}
if (selectedCalidades.value.length > 0) {
filtrosActivos.push(`${selectedCalidades.value.length} calidad${selectedCalidades.value.length > 1 ? 'es' : ''}`)
}
if (includeAnulados.value) {
filtrosActivos.push('incluye anulados')
}
if (filtrosActivos.length > 0) {
setFiltrosResumen(
filtrosActivos.length,
filtrosActivos.join(', '),
ingresosFiltrados.value.length
)
} else {
setFiltrosResumen(0, '', ingresosFiltrados.value.length)
}
}, { immediate: true })
// Actualizar filtros activos detallados para el navbar
watch([selectedClienteIds, fechaDesde, fechaHasta, selectedTipos, selectedEstados, selectedUbicaciones, selectedCalidades, includeAnulados, clientes], () => {
const filters: any[] = []
// Clientes seleccionados (mostrar cada cliente)
if (selectedClienteIds.value.length > 0) {
selectedClienteIds.value.forEach(id => {
const cliente = clientes.value?.find(c => c.id === id)
if (cliente) {
filters.push({
type: 'cliente',
label: `👤 ${cliente.name}`,
value: id,
onRemove: () => {
selectedClienteIds.value = selectedClienteIds.value.filter(cid => cid !== id)
}
})
}
})
}
// Rango de fechas
if (fechaDesde.value || fechaHasta.value) {
filters.push({
type: 'fechas',
label: `📅 ${fechaDesde.value || '—'}${fechaHasta.value || '—'}`,
value: { desde: fechaDesde.value, hasta: fechaHasta.value },
onRemove: () => {
fechaDesde.value = null
fechaHasta.value = null
selectedPreset.value = ''
}
})
}
// Tipos de café
if (selectedTipos.value.length > 0) {
selectedTipos.value.forEach(tipo => {
const tipoObj = tiposCafeOptions.find(t => t.value === tipo)
if (tipoObj) {
filters.push({
type: 'tipo',
label: `${tipoObj.label}`,
value: tipo,
onRemove: () => {
selectedTipos.value = selectedTipos.value.filter(t => t !== tipo)
}
})
}
})
}
// Estados
if (selectedEstados.value.length > 0) {
selectedEstados.value.forEach(estado => {
const estadoObj = estadosOptions.find(e => e.value === estado)
if (estadoObj) {
filters.push({
type: 'estado',
label: `${estadoObj.label}`,
value: estado,
onRemove: () => {
selectedEstados.value = selectedEstados.value.filter(e => e !== estado)
}
})
}
})
}
// Ubicaciones
if (selectedUbicaciones.value.length > 0) {
selectedUbicaciones.value.forEach(ubicacion => {
filters.push({
type: 'ubicacion',
label: `📍 ${ubicacion}`,
value: ubicacion,
onRemove: () => {
selectedUbicaciones.value = selectedUbicaciones.value.filter(u => u !== ubicacion)
}
})
})
}
// Calidades
if (selectedCalidades.value.length > 0) {
selectedCalidades.value.forEach(calidad => {
filters.push({
type: 'calidad',
label: `${calidad}`,
value: calidad,
onRemove: () => {
selectedCalidades.value = selectedCalidades.value.filter(c => c !== calidad)
}
})
})
}
// Incluir anulados
if (includeAnulados.value) {
filters.push({
type: 'anulados',
label: '⚠️ Con anulados',
value: true,
onRemove: () => {
includeAnulados.value = false
}
})
}
setActiveFilters(filters)
}, { immediate: true })
})
</script>
<style scoped>
.cliente-card-enter-active,
.cliente-card-leave-active {
transition: all 0.3s ease;
}
.cliente-card-enter-from {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
.cliente-card-leave-to {
opacity: 0;
transform: scale(0.9) translateY(10px);
}
.cliente-card-move {
transition: transform 0.3s ease;
}
</style>