From b8d0c67659b6a543ced56e9f9ac947140f5addf6 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Wed, 1 Oct 2025 00:15:06 -0600 Subject: [PATCH] mejoras UX exquisitas --- nuxt4-app/app/assets/css/main.css | 37 +++ .../app/components/ingresos/TopClientes.vue | 281 ++++++++++++++++++ .../VistaTablaIngresosConClientes.vue | 64 +++- nuxt4-app/app/pages/cuenta-cliente.vue | 156 +++++++++- 4 files changed, 529 insertions(+), 9 deletions(-) create mode 100644 nuxt4-app/app/components/ingresos/TopClientes.vue diff --git a/nuxt4-app/app/assets/css/main.css b/nuxt4-app/app/assets/css/main.css index a5ac597..b22756a 100644 --- a/nuxt4-app/app/assets/css/main.css +++ b/nuxt4-app/app/assets/css/main.css @@ -71,3 +71,40 @@ body { .brand-section-title { color: var(--brand-primary); } + +/* Custom Scrollbar Styles */ +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--brand-primary-strong) var(--brand-surface); +} + +/* WebKit browsers (Chrome, Safari, Edge) */ +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: var(--brand-surface); + border-radius: 4px; +} + +*::-webkit-scrollbar-thumb { + background: var(--brand-primary-strong); + border-radius: 4px; + border: 2px solid var(--brand-surface); +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--brand-primary); +} + +*::-webkit-scrollbar-thumb:active { + background: var(--brand-accent); +} + +/* Scrollbar corner (when both scrollbars are visible) */ +*::-webkit-scrollbar-corner { + background: var(--brand-surface); +} diff --git a/nuxt4-app/app/components/ingresos/TopClientes.vue b/nuxt4-app/app/components/ingresos/TopClientes.vue new file mode 100644 index 0000000..41f5d8e --- /dev/null +++ b/nuxt4-app/app/components/ingresos/TopClientes.vue @@ -0,0 +1,281 @@ + + + + diff --git a/nuxt4-app/app/components/ingresos/VistaTablaIngresosConClientes.vue b/nuxt4-app/app/components/ingresos/VistaTablaIngresosConClientes.vue index 9c0f890..682cff5 100644 --- a/nuxt4-app/app/components/ingresos/VistaTablaIngresosConClientes.vue +++ b/nuxt4-app/app/components/ingresos/VistaTablaIngresosConClientes.vue @@ -54,8 +54,24 @@ const data = computed<(IngresoWithChildren | ClienteWithChildren)[]>(() => { // Clientes as parent, ingresos as children const clientesData = props.clientes.map(cliente => { const clienteIngresos = props.ingresos.filter(i => i.cliente_id === cliente.id) + + // Agregar campos agregados al cliente + const peso_seco = clienteIngresos.reduce((sum, i) => sum + (i.peso_seco || 0), 0) + const peso_neto = clienteIngresos.reduce((sum, i) => sum + (i.peso_neto || 0), 0) + + // Precio promedio ponderado (por peso_neto) + const totalPrecioXPeso = clienteIngresos.reduce((sum, i) => sum + (i.precio * i.peso_neto), 0) + const precio = peso_neto > 0 ? totalPrecioXPeso / peso_neto : 0 + + // Estados únicos + const estados = [...new Set(clienteIngresos.map(i => i.estado))] + return { ...cliente, + peso_seco, + peso_neto, + precio, + estado: estados.length > 0 ? estados.join(', ') : '', children: clienteIngresos } }) @@ -164,6 +180,14 @@ const columns: TableColumn[] = [ cell: ({ row }) => { const original = row.original as any const isIngreso = 'tipo' in original + const isCliente = 'name' in original && !('tipo' in original) + + // Si es cliente parent con peso_seco agregado, mostrarlo + if (isCliente && row.depth === 0 && original.peso_seco !== undefined) { + const peso = Number.parseFloat(original.peso_seco || 0) + return h('div', { class: 'text-right font-medium text-yellow-500' }, peso.toFixed(2)) + } + if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—') const peso = Number.parseFloat(row.getValue('peso_seco') || 0) @@ -176,6 +200,14 @@ const columns: TableColumn[] = [ cell: ({ row }) => { const original = row.original as any const isIngreso = 'tipo' in original + const isCliente = 'name' in original && !('tipo' in original) + + // Si es cliente parent con peso_neto agregado, mostrarlo + if (isCliente && row.depth === 0 && original.peso_neto !== undefined) { + const peso = Number.parseFloat(original.peso_neto || 0) + return h('div', { class: 'text-right font-medium text-yellow-500' }, peso.toFixed(2)) + } + if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—') const peso = Number.parseFloat(row.getValue('peso_neto') || 0) @@ -188,6 +220,21 @@ const columns: TableColumn[] = [ cell: ({ row }) => { const original = row.original as any const isIngreso = 'tipo' in original + const isCliente = 'name' in original && !('tipo' in original) + + // Si es cliente parent con precio agregado, mostrarlo + if (isCliente && row.depth === 0 && original.precio !== undefined) { + const precio = Number.parseFloat(original.precio || 0) + const formatted = new Intl.NumberFormat('es-HN', { + style: 'currency', + currency: 'HNL', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(precio).replace('HNL', 'L') + + return h('div', { class: 'text-right font-medium text-yellow-500' }, formatted) + } + if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—') const precio = Number.parseFloat(row.getValue('precio') || 0) @@ -210,7 +257,22 @@ const columns: TableColumn[] = [ const isIngreso = 'tipo' in original if (isCliente) { - // Cliente row - show if empleado + // Cliente row (parent) - show aggregated estados + const isParent = row.depth === 0 + + if (isParent && original.estado) { + // Si tiene campo estado agregado, mostrarlo como badges múltiples + const estados = original.estado.split(', ') + return h('div', { class: 'flex flex-wrap gap-1' }, estados.map((estado: string) => + h('span', { + class: estado === 'pagado' + ? 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-green-500/20 text-green-300 border border-green-400/30 text-xs' + : 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-yellow-500/20 text-yellow-300 border border-yellow-400/30 text-xs' + }, estado === 'pagado' ? '✓ Pagado' : '⏳ Pendiente') + )) + } + + // Cliente row (child) - show if empleado return original.empleado ? h('span', { class: 'inline-flex items-center gap-1 px-2 py-0.5 rounded bg-blue-500/20 text-blue-300 border border-blue-400/30 text-xs' }, [ h('span', '💼'), diff --git a/nuxt4-app/app/pages/cuenta-cliente.vue b/nuxt4-app/app/pages/cuenta-cliente.vue index b77a33e..465c33f 100644 --- a/nuxt4-app/app/pages/cuenta-cliente.vue +++ b/nuxt4-app/app/pages/cuenta-cliente.vue @@ -265,19 +265,78 @@ - - - - + + + + + + + + + + + +
+ + + + +
@@ -381,7 +459,7 @@ import type { IngresoRecord } from '~/composables/useIngresosMetrics' // Define page metadata definePageMeta({ layout: 'dashboard', - title: 'Cuenta Cliente' + title: 'Analizador Ingresos-Clientes' }) // View modes with explicit hierarchy @@ -688,6 +766,65 @@ const clientesFiltrados = computed((): ClienteRecord[] => { // 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) @@ -751,6 +888,9 @@ onMounted(async () => { selectedEstados.value = [] selectedUbicaciones.value = [] } + + // Listener para escape key en fullscreen + window.addEventListener('keydown', handleEscape) })