cuenta-cliente en proceso
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
221
nuxt4-app/app/pages/cuenta-cliente.vue
Normal file
221
nuxt4-app/app/pages/cuenta-cliente.vue
Normal 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>
|
||||
Reference in New Issue
Block a user