1084 lines
37 KiB
Vue
1084 lines
37 KiB
Vue
<template>
|
|
<div class="flex flex-col">
|
|
<!-- Toolbar con switches -->
|
|
<UDashboardToolbar>
|
|
<template #left>
|
|
<div class="flex items-center gap-4">
|
|
<USwitch
|
|
v-model="showFiltros"
|
|
checked-icon="i-lucide-eye"
|
|
unchecked-icon="i-lucide-eye-off"
|
|
label="Filtros"
|
|
size="sm"
|
|
/>
|
|
<USwitch
|
|
v-model="showMetadatos"
|
|
checked-icon="i-lucide-eye"
|
|
unchecked-icon="i-lucide-eye-off"
|
|
label="Metadatos"
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<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>
|
|
<!-- Metadatos 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"
|
|
>
|
|
<div v-show="!metadatosCollapsed" class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
|
|
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" />
|
|
</div>
|
|
</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">
|
|
<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"
|
|
@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"
|
|
/>
|
|
</UCard>
|
|
</Transition>
|
|
|
|
<!-- Clientes Seleccionados Cards -->
|
|
<div v-if="clientesSeleccionados.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<TransitionGroup name="cliente-card">
|
|
<ClientesClienteCard
|
|
v-for="cliente in clientesSeleccionados"
|
|
:key="cliente.id"
|
|
:cliente="cliente"
|
|
@remove="removeCliente(cliente.id)"
|
|
/>
|
|
</TransitionGroup>
|
|
</div>
|
|
|
|
<!-- 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 md:grid-cols-4 gap-4">
|
|
<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-3 rounded-lg border transition-all duration-200',
|
|
selectedView === option.value
|
|
? `${option.borderColor} ${option.bgColor} shadow-lg ${option.shadowColor} scale-105`
|
|
: 'border-gray-600/30 bg-gray-700/20 hover:bg-gray-700/30 hover:scale-102'
|
|
]"
|
|
>
|
|
<!-- Gradient background for combined views when selected -->
|
|
<div
|
|
v-if="option.color === 'gradient' && selectedView === option.value"
|
|
:class="[
|
|
'absolute inset-0 rounded-lg opacity-20 bg-gradient-to-r',
|
|
option.gradient
|
|
]"
|
|
/>
|
|
|
|
<UIcon
|
|
:name="option.icon"
|
|
:class="[
|
|
'relative z-10',
|
|
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
|
|
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
|
|
selectedView === option.value && option.color === 'gradient' ? 'text-white' :
|
|
'text-gray-400'
|
|
]"
|
|
/>
|
|
|
|
<span
|
|
:class="[
|
|
'relative z-10 font-medium text-sm whitespace-nowrap',
|
|
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
|
|
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
|
|
selectedView === option.value && option.color === 'gradient' ? 'text-white' :
|
|
'text-gray-400'
|
|
]"
|
|
>
|
|
{{ option.label }}
|
|
</span>
|
|
|
|
<!-- Badge con contador -->
|
|
<span
|
|
:class="[
|
|
'relative z-10 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 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' :
|
|
selectedView === option.value && option.color === 'gradient' ? 'bg-white/20 text-white border border-white/30' :
|
|
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' :
|
|
option.color === 'gradient' ? 'bg-gradient-to-r from-cyan-500/20 to-yellow-500/20 text-gray-300 border border-gray-500/30' :
|
|
'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
|
]"
|
|
>
|
|
{{ getViewCount(option.value) }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Toggle for clientes without ingresos -->
|
|
<div v-if="selectedView === 'clientes-ingresos'" class="flex items-center gap-2 px-2">
|
|
<UCheckbox
|
|
v-model="includeClientesWithoutIngresos"
|
|
label="Incluir clientes sin ingresos"
|
|
/>
|
|
<span class="text-xs text-[var(--brand-text-muted)]">
|
|
({{ includeClientesWithoutIngresos ? 'Mostrando todos los clientes' : 'Solo clientes con ingresos' }})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Single view: Ingresos -->
|
|
<IngresosVistaTablaIngresos
|
|
v-if="selectedView === 'ingresos-only'"
|
|
:records="ingresosFiltrados"
|
|
/>
|
|
|
|
<!-- Single view: Clientes -->
|
|
<ClientesVistaTablaClientes
|
|
v-else-if="selectedView === 'clientes-only'"
|
|
:records="clientesFiltrados"
|
|
/>
|
|
|
|
<!-- Combined view: Ingresos → Clientes -->
|
|
<IngresosVistaTablaIngresosConClientes
|
|
v-else-if="selectedView === 'ingresos-clientes'"
|
|
:ingresos="ingresosFiltrados"
|
|
:clientes="clientesFiltrados"
|
|
primary-view="ingresos"
|
|
/>
|
|
|
|
<!-- Combined view: Clientes → Ingresos -->
|
|
<IngresosVistaTablaIngresosConClientes
|
|
v-else-if="selectedView === 'clientes-ingresos'"
|
|
:ingresos="ingresosFiltrados"
|
|
:clientes="clientesFiltrados"
|
|
primary-view="clientes"
|
|
:include-clientes-without-ingresos="includeClientesWithoutIngresos"
|
|
/>
|
|
</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 with explicit hierarchy
|
|
type ViewMode = 'ingresos-only' | 'clientes-only' | 'ingresos-clientes' | 'clientes-ingresos'
|
|
const selectedView = ref<ViewMode>('ingresos-only')
|
|
|
|
// Toggle for including clients without ingresos in clientes-ingresos view
|
|
const includeClientesWithoutIngresos = ref(false)
|
|
|
|
const viewOptions = [
|
|
{
|
|
value: 'ingresos-only' 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-only' 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'
|
|
},
|
|
{
|
|
value: 'ingresos-clientes' as ViewMode,
|
|
label: 'Ingresos → Clientes',
|
|
icon: 'i-lucide-git-branch',
|
|
color: 'gradient',
|
|
gradient: 'from-cyan-500 via-cyan-400 to-yellow-500',
|
|
borderColor: 'border-cyan-500/50',
|
|
bgColor: 'bg-gradient-to-r from-cyan-500/10 to-yellow-500/10',
|
|
shadowColor: 'shadow-cyan-500/20'
|
|
},
|
|
{
|
|
value: 'clientes-ingresos' as ViewMode,
|
|
label: 'Clientes → Ingresos',
|
|
icon: 'i-lucide-git-merge',
|
|
color: 'gradient',
|
|
gradient: 'from-yellow-500 via-yellow-400 to-cyan-500',
|
|
borderColor: 'border-yellow-500/50',
|
|
bgColor: 'bg-gradient-to-r from-yellow-500/10 to-cyan-500/10',
|
|
shadowColor: 'shadow-yellow-500/20'
|
|
}
|
|
]
|
|
|
|
// Dynamic table title and description
|
|
const tableTitle = computed(() => {
|
|
switch (selectedView.value) {
|
|
case 'ingresos-only':
|
|
return 'Tabla de Ingresos'
|
|
case 'clientes-only':
|
|
return 'Tabla de Clientes'
|
|
case 'ingresos-clientes':
|
|
return 'Vista Combinada: Ingresos con Clientes'
|
|
case 'clientes-ingresos':
|
|
return 'Vista Combinada: Clientes con Ingresos'
|
|
default:
|
|
return 'Tabla de Datos'
|
|
}
|
|
})
|
|
|
|
const tableTitleClass = computed(() => {
|
|
switch (selectedView.value) {
|
|
case 'ingresos-only':
|
|
return 'text-cyan-400'
|
|
case 'clientes-only':
|
|
return 'text-yellow-500'
|
|
case 'ingresos-clientes':
|
|
case 'clientes-ingresos':
|
|
return 'bg-gradient-to-r from-cyan-400 to-yellow-500 bg-clip-text text-transparent'
|
|
default:
|
|
return 'brand-section-title'
|
|
}
|
|
})
|
|
|
|
const tableDescription = computed(() => {
|
|
switch (selectedView.value) {
|
|
case 'ingresos-only':
|
|
return `Mostrando ${ingresosFiltrados.value.length} registros de ingresos`
|
|
case 'clientes-only':
|
|
return `Mostrando ${clientesFiltrados.value.length} clientes`
|
|
case 'ingresos-clientes':
|
|
return `${ingresosFiltrados.value.length} ingresos, cada uno con su cliente relacionado`
|
|
case 'clientes-ingresos': {
|
|
const clientesConIngresos = clientesFiltrados.value.filter(c =>
|
|
ingresosFiltrados.value.some(i => i.cliente_id === c.id)
|
|
).length
|
|
if (includeClientesWithoutIngresos.value) {
|
|
return `${clientesFiltrados.value.length} clientes (${clientesConIngresos} con ingresos, ${clientesFiltrados.value.length - clientesConIngresos} sin ingresos)`
|
|
}
|
|
return `${clientesConIngresos} clientes con ingresos relacionados`
|
|
}
|
|
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' })
|
|
}
|
|
})
|
|
|
|
// Reset the toggle when switching away from clientes-ingresos view
|
|
if (oldView === 'clientes-ingresos' && newView !== 'clientes-ingresos') {
|
|
includeClientesWithoutIngresos.value = false
|
|
}
|
|
})
|
|
|
|
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
|
|
// -------------------------------
|
|
const includeAnulados = ref(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 = ref<PresetValue>('cosecha-25-26')
|
|
const fechaDesde = ref<string | null>(null)
|
|
const fechaHasta = ref<string | null>(null)
|
|
const selectedClienteIds = ref<number[]>([])
|
|
|
|
// Filtros avanzados
|
|
const selectedTipos = ref<string[]>([])
|
|
const selectedEstados = ref<string[]>([])
|
|
const selectedUbicaciones = ref<string[]>([])
|
|
const selectedCalidades = ref<string[]>([])
|
|
|
|
// 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 {
|
|
const created = row?.created_at ? new Date(row.created_at) : null
|
|
if (!created || isNaN(created.getTime())) return false
|
|
if (from) {
|
|
const fd = new Date(from + 'T00:00:00-06:00')
|
|
if (created < fd) return false
|
|
}
|
|
if (to) {
|
|
const td = new Date(to + 'T23:59:59-06:00')
|
|
if (created > 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)
|
|
}
|
|
|
|
// Filtrados que alimentan los métricos
|
|
const ingresosFiltrados = computed(() => {
|
|
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))
|
|
})
|
|
|
|
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)
|
|
|
|
// 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 = ''
|
|
}
|
|
}
|
|
|
|
// 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-only':
|
|
return ingresosFiltrados.value.length
|
|
case 'clientes-only':
|
|
return clientesFiltrados.value.length
|
|
case 'ingresos-clientes':
|
|
return ingresosFiltrados.value.length
|
|
case 'clientes-ingresos':
|
|
// Contar solo clientes con ingresos si el toggle está OFF
|
|
if (!includeClientesWithoutIngresos.value) {
|
|
return clientesFiltrados.value.filter(c =>
|
|
ingresosFiltrados.value.some(i => i.cliente_id === c.id)
|
|
).length
|
|
}
|
|
return clientesFiltrados.value.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)
|
|
|
|
// 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 {
|
|
// Default preset: cosecha 25-26
|
|
selectedPreset.value = 'cosecha-25-26'
|
|
includeAnulados.value = false
|
|
|
|
// Clear advanced filters
|
|
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>
|