1182 lines
41 KiB
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>
|