cuenta-cliente en proceso
This commit is contained in:
@@ -3,30 +3,27 @@
|
|||||||
<!-- Selector de clientes -->
|
<!-- Selector de clientes -->
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="text-xs text-[var(--brand-text-muted)] block mb-1">Seleccionar clientes</label>
|
<label class="text-xs text-[var(--brand-text-muted)] block mb-1">Seleccionar clientes</label>
|
||||||
<USelectMenu
|
<UInputMenu
|
||||||
v-model="selectedClientes"
|
v-model="selectedClientesObjects"
|
||||||
:options="clienteOptions"
|
v-model:open="isOpen"
|
||||||
|
v-model:search-term="searchTerm"
|
||||||
|
:items="clienteOptions"
|
||||||
multiple
|
multiple
|
||||||
searchable
|
:filter-fields="['name', 'ubicacion']"
|
||||||
searchable-placeholder="Buscar cliente..."
|
placeholder="Escribe al menos 3 caracteres para buscar..."
|
||||||
placeholder="Todos los clientes"
|
:disabled="props.clientes.length === 0"
|
||||||
value-attribute="id"
|
icon="i-lucide-users"
|
||||||
option-attribute="name"
|
|
||||||
:ui="{
|
:ui="{
|
||||||
wrapper: 'w-full',
|
trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200'
|
||||||
input: 'w-full'
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #label>
|
<template #item-label="{ item }">
|
||||||
<span v-if="selectedClientes.length === 0">Todos los clientes</span>
|
<span class="font-medium">{{ item.name }}</span>
|
||||||
<span v-else-if="selectedClientes.length === 1">
|
<span v-if="item.ubicacion" class="text-xs text-[var(--brand-text-muted)] ml-2">
|
||||||
{{ clienteOptions.find(c => c.id === selectedClientes[0])?.name }}
|
{{ item.ubicacion }}
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ selectedClientes.length }} clientes seleccionados
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</USelectMenu>
|
</UInputMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-end">
|
<div class="flex items-end">
|
||||||
@@ -42,7 +39,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch, toRaw } from 'vue'
|
||||||
|
|
||||||
interface Cliente {
|
interface Cliente {
|
||||||
id: number
|
id: number
|
||||||
@@ -63,20 +60,60 @@ const emit = defineEmits<{
|
|||||||
'update:selectedIds': [value: number[]]
|
'update:selectedIds': [value: number[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Reactive reference para el v-model
|
const isOpen = ref(false)
|
||||||
const selectedClientes = computed({
|
const searchTerm = ref('')
|
||||||
get: () => props.selectedIds,
|
|
||||||
set: (value) => emit('update:selectedIds', value)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
// Opciones procesadas para UInputMenu
|
||||||
const clienteOptions = computed(() => {
|
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() !== '')
|
.filter(c => c.name && c.name.trim() !== '')
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.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() {
|
function clearSelection() {
|
||||||
emit('update:selectedIds', [])
|
emit('update:selectedIds', [])
|
||||||
|
searchTerm.value = ''
|
||||||
console.log('Cliente selection cleared')
|
console.log('Cliente selection cleared')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,4 +121,9 @@ function clearSelection() {
|
|||||||
watch(() => props.selectedIds, (newIds) => {
|
watch(() => props.selectedIds, (newIds) => {
|
||||||
console.log('Selected cliente IDs:', newIds)
|
console.log('Selected cliente IDs:', newIds)
|
||||||
}, { deep: true })
|
}, { 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>
|
</script>
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
|||||||
to: '/panorama',
|
to: '/panorama',
|
||||||
active: route.path === '/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',
|
label: 'Explorador de datos',
|
||||||
icon: 'i-lucide-table',
|
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