Files
analiticaNucleo/nuxt4-app/app/pages/cuenta-cliente.vue
2025-10-01 00:15:06 -06:00

917 lines
33 KiB
Vue

<template>
<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 Cards de Ingresos y Clientes -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<MetadatosCard v-if="ingresosMetadata" :metadata="ingresosMetadata" />
<MetadatosCard v-if="clientesMetadata" :metadata="clientesMetadata" />
</div>
<!-- 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>
<!-- 🔻 Card de Filtros -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-bold brand-section-title">Filtros y Configuraciones</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
Filtros aplicados a ingresos por fecha y cliente
</p>
</div>
<div class="flex items-center gap-2">
<UCheckbox v-model="includeAnulados" label="Incluir anulados" @update:model-value="onToggleAnulados" />
</div>
</div>
<!-- Alerta roja cuando incluye anulados -->
<UAlert
v-if="includeAnulados"
color="error"
variant="solid"
icon="i-lucide-alert-triangle"
title="Incluir anulados activado"
description="Los cálculos incluyen registros anulados. Esto puede afectar los resultados financieros."
/>
</div>
</template>
<div class="flex flex-col gap-6">
<!-- Fila 1: Selector de Clientes -->
<div class="flex items-end justify-between gap-4">
<div class="flex-1">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
Clientes
</h3>
<ClienteSelector
:clientes="clientes"
:selected-ids="selectedClienteIds"
@update:selected-ids="selectedClienteIds = $event"
/>
</div>
<UButton
v-if="selectedClienteIds.length > 0"
size="sm"
variant="ghost"
color="neutral"
@click="selectedClienteIds = []"
class="shrink-0"
>
<template #leading>
<UIcon name="i-lucide-x" />
</template>
Limpiar
</UButton>
</div>
<!-- Fila 2: Selector de Rango de Fechas -->
<div class="flex items-end justify-between gap-4">
<div class="flex-1">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
Rango de Fechas
</h3>
<DateRangeSelector
:selected-preset="selectedPreset"
:fecha-desde="fechaDesde"
:fecha-hasta="fechaHasta"
@update:selected-preset="selectedPreset = $event"
@update:fecha-desde="fechaDesde = $event"
@update:fecha-hasta="fechaHasta = $event"
/>
</div>
<UButton
v-if="fechaDesde || fechaHasta"
size="sm"
variant="ghost"
color="neutral"
@click="() => { fechaDesde = null; fechaHasta = null; selectedPreset = '' }"
class="shrink-0"
>
<template #leading>
<UIcon name="i-lucide-x" />
</template>
Limpiar
</UButton>
</div>
<!-- Fila 3: Filtros Avanzados -->
<div class="flex items-end justify-between gap-4">
<div class="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Tipos de Café -->
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
Tipos de Café
</h3>
<UInputMenu
v-model="selectedTipos"
:items="tiposCafeOptions"
value-key="value"
multiple
placeholder="Todos los tipos"
size="sm"
icon="i-lucide-coffee"
class="w-full"
/>
</div>
<!-- Estados -->
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
Estados
</h3>
<UInputMenu
v-model="selectedEstados"
:items="estadosOptions"
value-key="value"
multiple
placeholder="Todos los estados"
size="sm"
icon="i-lucide-circle-check-big"
class="w-full"
/>
</div>
<!-- Ubicaciones -->
<div class="flex flex-col">
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
Ubicaciones
</h3>
<UInputMenu
v-model="selectedUbicaciones"
:items="ubicacionesOptions"
value-key="value"
multiple
placeholder="Todas las ubicaciones"
size="sm"
icon="i-lucide-map-pin"
class="w-full"
/>
</div>
</div>
<UButton
v-if="hasAdvancedFilters"
size="sm"
variant="ghost"
color="neutral"
@click="clearAdvancedFilters"
class="shrink-0"
>
<template #leading>
<UIcon name="i-lucide-x" />
</template>
Limpiar
</UButton>
</div>
</div>
<template #footer>
<div class="space-y-3">
<!-- Main Stats - Highlighted -->
<div class="flex flex-wrap items-center gap-4 p-3 rounded-lg bg-gradient-to-r from-[var(--brand-primary)]/10 to-[var(--brand-primary)]/5 border border-[var(--brand-primary)]/20">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-calendar-range" class="w-4 h-4 text-[var(--brand-primary)]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">
{{ rangoLegible }}
</span>
</div>
<div class="h-6 w-px bg-[var(--brand-border)]" />
<div class="flex items-center gap-2">
<UIcon name="i-lucide-trending-up" class="w-4 h-4 text-cyan-400" />
<span class="text-sm font-medium text-[var(--brand-text)]">
Ingresos:
</span>
<span class="text-sm font-bold text-cyan-400">
{{ ingresosFiltrados.length }}
</span>
<span class="text-xs text-[var(--brand-text-muted)]">
/ {{ ingresos.length }}
</span>
</div>
<div class="h-6 w-px bg-[var(--brand-border)]" />
<div class="flex items-center gap-2">
<UIcon name="i-lucide-users" class="w-4 h-4 text-yellow-500" />
<span class="text-sm font-medium text-[var(--brand-text)]">
Clientes:
</span>
<span class="text-sm font-bold text-yellow-500">
{{ selectedClienteIds.length > 0 ? selectedClienteIds.length : clientesFiltrados.length }}
</span>
<span class="text-xs text-[var(--brand-text-muted)]">
/ {{ clientes.length }}
</span>
</div>
</div>
<!-- Advanced Filters -->
<div v-if="hasAdvancedFilters" class="flex flex-wrap gap-2 items-center text-xs">
<span class="text-[var(--brand-primary)] font-semibold">Filtros activos:</span>
<span v-if="selectedTipos.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
<UIcon name="i-lucide-coffee" class="w-3 h-3" />
{{ selectedTiposLabels }}
</span>
<span v-if="selectedEstados.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
<UIcon name="i-lucide-circle-check-big" class="w-3 h-3" />
{{ selectedEstadosLabels }}
</span>
<span v-if="selectedUbicaciones.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
<UIcon name="i-lucide-map-pin" class="w-3 h-3" />
{{ selectedUbicaciones.length }} ubicaciones
</span>
</div>
</div>
</template>
</UCard>
<!-- Totales por Café -->
<UCard 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" :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
: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 brand-section-title">{{ 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 :ingresos="ingresosFiltrados" :clientes="clientesFiltrados" />
</template>
</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: 'dashboard',
title: 'Analizador Ingresos-Clientes'
})
// 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 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[]>([])
// 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 }))
})
// 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(', ')
})
// Check if advanced filters are active
const hasAdvancedFilters = computed(() => {
return selectedTipos.value.length > 0 ||
selectedEstados.value.length > 0 ||
selectedUbicaciones.value.length > 0
})
// Clear advanced filters
function clearAdvancedFilters() {
selectedTipos.value = []
selectedEstados.value = []
selectedUbicaciones.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
}
// 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))
})
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
})
// 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)
})
</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>