cuenta-cliente en proceso

This commit is contained in:
2025-09-30 16:15:57 -06:00
parent aeaf30d1d1
commit a1be886cb4
3 changed files with 294 additions and 25 deletions

View File

@@ -3,30 +3,27 @@
<!-- Selector de clientes -->
<div class="flex-1">
<label class="text-xs text-[var(--brand-text-muted)] block mb-1">Seleccionar clientes</label>
<USelectMenu
v-model="selectedClientes"
:options="clienteOptions"
<UInputMenu
v-model="selectedClientesObjects"
v-model:open="isOpen"
v-model:search-term="searchTerm"
:items="clienteOptions"
multiple
searchable
searchable-placeholder="Buscar cliente..."
placeholder="Todos los clientes"
value-attribute="id"
option-attribute="name"
:filter-fields="['name', 'ubicacion']"
placeholder="Escribe al menos 3 caracteres para buscar..."
:disabled="props.clientes.length === 0"
icon="i-lucide-users"
:ui="{
wrapper: 'w-full',
input: 'w-full'
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
}"
>
<template #label>
<span v-if="selectedClientes.length === 0">Todos los clientes</span>
<span v-else-if="selectedClientes.length === 1">
{{ clienteOptions.find(c => c.id === selectedClientes[0])?.name }}
</span>
<span v-else>
{{ selectedClientes.length }} clientes seleccionados
<template #item-label="{ item }">
<span class="font-medium">{{ item.name }}</span>
<span v-if="item.ubicacion" class="text-xs text-[var(--brand-text-muted)] ml-2">
{{ item.ubicacion }}
</span>
</template>
</USelectMenu>
</UInputMenu>
</div>
<div class="flex items-end">
@@ -42,7 +39,7 @@
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { computed, ref, watch, toRaw } from 'vue'
interface Cliente {
id: number
@@ -63,20 +60,60 @@ const emit = defineEmits<{
'update:selectedIds': [value: number[]]
}>()
// Reactive reference para el v-model
const selectedClientes = computed({
get: () => props.selectedIds,
set: (value) => emit('update:selectedIds', value)
})
const isOpen = ref(false)
const searchTerm = ref('')
// Opciones procesadas para UInputMenu
const clienteOptions = computed(() => {
return props.clientes
// Si el searchTerm tiene menos de 3 caracteres, retornar array vacío
if (searchTerm.value.length < 3 && searchTerm.value.length > 0) {
return []
}
const filtered = props.clientes
.map(c => toRaw(c))
.filter(c => c.name && c.name.trim() !== '')
.sort((a, b) => a.name.localeCompare(b.name))
.map(c => ({
id: c.id,
name: c.name,
ubicacion: c.ubicacion || '',
label: c.name // InputMenu requiere label
}))
console.log('Search term:', searchTerm.value)
console.log('Total clientes:', props.clientes.length)
console.log('Filtered cliente options:', filtered.length)
return filtered
})
// v-model para UInputMenu trabaja con objetos completos cuando es multiple
const selectedClientesObjects = computed({
get: () => {
// Para el get, siempre mostrar los seleccionados independientemente del searchTerm
const allOptions = props.clientes
.map(c => toRaw(c))
.filter(c => c.name && c.name.trim() !== '')
.map(c => ({
id: c.id,
name: c.name,
ubicacion: c.ubicacion || '',
label: c.name
}))
return allOptions.filter(c => props.selectedIds.includes(c.id))
},
set: (value: typeof clienteOptions.value) => {
const ids = value.map(c => c.id)
emit('update:selectedIds', ids)
console.log('Selected IDs updated:', ids)
}
})
function clearSelection() {
emit('update:selectedIds', [])
searchTerm.value = ''
console.log('Cliente selection cleared')
}
@@ -84,4 +121,9 @@ function clearSelection() {
watch(() => props.selectedIds, (newIds) => {
console.log('Selected cliente IDs:', newIds)
}, { deep: true })
// Watch para ver cuando cambian los clientes
watch(() => props.clientes, (newClientes) => {
console.log('Clientes prop changed, length:', newClientes?.length)
}, { deep: true, immediate: true })
</script>

View File

@@ -124,6 +124,12 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
to: '/panorama',
active: route.path === '/panorama'
},
{
label: 'Cuenta Cliente',
icon: 'i-lucide-user-circle',
to: '/cuenta-cliente',
active: route.path === '/cuenta-cliente'
},
{
label: 'Explorador de datos',
icon: 'i-lucide-table',

View File

@@ -0,0 +1,221 @@
<template>
<div class="flex flex-col gap-8">
<!-- 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>
<!-- 🔻 Card de Filtros -->
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex flex-col 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>
</template>
<div class="flex flex-col gap-4">
<!-- Selector de Clientes -->
<div>
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-2">
Clientes
</h3>
<ClienteSelector
:clientes="clientes"
:selected-ids="selectedClienteIds"
@update:selected-ids="selectedClienteIds = $event"
/>
</div>
<!-- Selector de Rango de Fechas -->
<div>
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-2">
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>
</div>
<template #footer>
<div class="text-xs text-[var(--brand-text-muted)]">
Rango activo: {{ rangoLegible }} · Ingresos considerados: {{ ingresosFiltrados.length }}/{{ ingresos.length }}
<span v-if="selectedClienteIds.length > 0"> · Clientes seleccionados: {{ selectedClienteIds.length }}</span>
</div>
</template>
</UCard>
<!-- Totales de Ingreso y Compra -->
<IngresosTotalesIngresoCompra :metrics="ingresosMetrics" />
</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: 'Cuenta Cliente'
})
// Initialize stores
const ingresosStore = useTableDataStore<IngresoRecord>('ingresos')
const clientesStore = useTableDataStore<any>('clientes')
// Reactive data from stores
const ingresos = computed(() => ingresosStore.allRecords as IngresoRecord[])
const clientes = computed(() => clientesStore.allRecords)
// -------------------------------
// Filtros
// -------------------------------
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[]>([])
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 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)
}
// Filtrados que alimentan los métricos
const ingresosFiltrados = computed(() => {
return (ingresos.value ?? [])
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
.filter(r => isClienteSelected(r.cliente_id))
})
// Métricos basados en filtrados
const ingresosMetrics = useIngresosMetrics(ingresosFiltrados)
// 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'
}
})
</script>