mejoras UX exquisitas
This commit is contained in:
@@ -71,3 +71,40 @@ body {
|
|||||||
.brand-section-title {
|
.brand-section-title {
|
||||||
color: var(--brand-primary);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
281
nuxt4-app/app/components/ingresos/TopClientes.vue
Normal file
281
nuxt4-app/app/components/ingresos/TopClientes.vue
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<!-- nuxt4-app/app/components/ingresos/TopClientes.vue -->
|
||||||
|
<template>
|
||||||
|
<UCard class="brand-card border border-transparent">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="text-xl font-bold brand-section-title">
|
||||||
|
{{ isBottomMode ? 'Bottom 10 Clientes' : 'Top 10 Clientes' }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||||
|
Clasificados por: <span class="font-semibold text-[var(--brand-primary)]">{{ categoriaActual.label }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
:icon="isBottomMode ? 'i-lucide-arrow-up-wide-narrow' : 'i-lucide-arrow-down-wide-narrow'"
|
||||||
|
:color="isBottomMode ? 'error' : 'primary'"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
@click="isBottomMode = !isBottomMode"
|
||||||
|
:title="isBottomMode ? 'Mostrar Top 10' : 'Mostrar Bottom 10'"
|
||||||
|
>
|
||||||
|
{{ isBottomMode ? 'Bottom 10' : 'Top 10' }}
|
||||||
|
</UButton>
|
||||||
|
<UInputMenu
|
||||||
|
v-model="categoriaSeleccionada"
|
||||||
|
:items="categorias"
|
||||||
|
label-key="label"
|
||||||
|
value-key="value"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-filter"
|
||||||
|
placeholder="Seleccionar métrica"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in topClientes"
|
||||||
|
:key="item.cliente.id"
|
||||||
|
class="flex items-center gap-4 p-3 rounded-lg border border-[var(--brand-border)] bg-gradient-to-r from-[var(--brand-bg-secondary)] to-transparent hover:border-[var(--brand-primary)]/30 transition-all"
|
||||||
|
>
|
||||||
|
<!-- Ranking Badge -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm',
|
||||||
|
index === 0 ? 'bg-yellow-500/20 text-yellow-400 border-2 border-yellow-500/50' :
|
||||||
|
index === 1 ? 'bg-gray-400/20 text-gray-300 border-2 border-gray-400/50' :
|
||||||
|
index === 2 ? 'bg-orange-500/20 text-orange-400 border-2 border-orange-500/50' :
|
||||||
|
'bg-[var(--brand-primary)]/10 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cliente Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-semibold text-[var(--brand-text)] truncate">
|
||||||
|
{{ item.cliente.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-[var(--brand-text-muted)] flex items-center gap-2">
|
||||||
|
<span v-if="item.cliente.cedula" class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-credit-card" class="w-3 h-3" />
|
||||||
|
{{ item.cliente.cedula }}
|
||||||
|
</span>
|
||||||
|
<span v-if="item.cliente.ubicacion" class="inline-flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-map-pin" class="w-3 h-3" />
|
||||||
|
{{ item.cliente.ubicacion }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metric Value -->
|
||||||
|
<div class="flex-shrink-0 text-right min-w-[120px]">
|
||||||
|
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-0.5">
|
||||||
|
{{ categoriaActual.label }}
|
||||||
|
</div>
|
||||||
|
<div class="font-bold text-lg text-[var(--brand-primary)]">
|
||||||
|
{{ formatValue(item.value) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-[var(--brand-text-muted)]">
|
||||||
|
{{ categoriaActual.unit }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-if="topClientes.length === 0" class="text-center py-8 text-[var(--brand-text-muted)]">
|
||||||
|
<UIcon name="i-lucide-users-round" class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No hay datos disponibles para esta categoría</p>
|
||||||
|
<p class="text-xs mt-1">{{ isBottomMode ? 'Bottom 10' : 'Top 10' }} - {{ categoriaActual.label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
||||||
|
|
||||||
|
interface ClienteRecord {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cedula?: number
|
||||||
|
ubicacion?: string
|
||||||
|
telefono?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ingresos: IngresoRecord[]
|
||||||
|
clientes: ClienteRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
interface Categoria {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
unit: string
|
||||||
|
calculate: (ingresos: IngresoRecord[]) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
const categorias: Categoria[] = [
|
||||||
|
{
|
||||||
|
value: 'total-ingresos',
|
||||||
|
label: 'Total de Ingresos',
|
||||||
|
unit: 'registros',
|
||||||
|
calculate: (ingresos) => ingresos.length
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'peso-seco',
|
||||||
|
label: 'Peso Seco Total',
|
||||||
|
unit: 'qq',
|
||||||
|
calculate: (ingresos) => ingresos.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'peso-neto',
|
||||||
|
label: 'Peso Neto Total',
|
||||||
|
unit: 'lb',
|
||||||
|
calculate: (ingresos) => ingresos.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'inversion-total',
|
||||||
|
label: 'Inversión Total',
|
||||||
|
unit: 'L',
|
||||||
|
calculate: (ingresos) => {
|
||||||
|
return ingresos.reduce((sum, i) => {
|
||||||
|
if (i.tipo === 'verde' || i.tipo === 'uva') {
|
||||||
|
return sum + (i.precio * i.peso_neto)
|
||||||
|
}
|
||||||
|
if (i.tipo === 'oreado' || i.tipo === 'mojado') {
|
||||||
|
return sum + ((i.precio / 2) * i.peso_seco)
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'peso-uva',
|
||||||
|
label: 'Peso Uva Total',
|
||||||
|
unit: 'lb',
|
||||||
|
calculate: (ingresos) => {
|
||||||
|
return ingresos
|
||||||
|
.filter(i => i.tipo === 'uva')
|
||||||
|
.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'peso-oreado',
|
||||||
|
label: 'Peso Oreado Total',
|
||||||
|
unit: 'qq',
|
||||||
|
calculate: (ingresos) => {
|
||||||
|
return ingresos
|
||||||
|
.filter(i => i.tipo === 'oreado')
|
||||||
|
.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'peso-mojado',
|
||||||
|
label: 'Peso Mojado Total',
|
||||||
|
unit: 'qq',
|
||||||
|
calculate: (ingresos) => {
|
||||||
|
return ingresos
|
||||||
|
.filter(i => i.tipo === 'mojado')
|
||||||
|
.reduce((sum, i) => sum + (i.peso_seco || 0), 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'peso-verde',
|
||||||
|
label: 'Peso Verde Total',
|
||||||
|
unit: 'lb',
|
||||||
|
calculate: (ingresos) => {
|
||||||
|
return ingresos
|
||||||
|
.filter(i => i.tipo === 'verde')
|
||||||
|
.reduce((sum, i) => sum + (i.peso_neto || 0), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoriaSeleccionada = ref('total-ingresos')
|
||||||
|
const isBottomMode = ref(false)
|
||||||
|
|
||||||
|
const categoriaActual = computed(() => {
|
||||||
|
return categorias.find(c => c.value === categoriaSeleccionada.value) || categorias[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
interface TopClienteItem {
|
||||||
|
cliente: ClienteRecord
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const topClientes = computed((): TopClienteItem[] => {
|
||||||
|
// Agrupar ingresos por cliente
|
||||||
|
const clienteMap = new Map<number, IngresoRecord[]>()
|
||||||
|
|
||||||
|
props.ingresos.forEach(ingreso => {
|
||||||
|
const clienteId = ingreso.cliente_id
|
||||||
|
if (!clienteMap.has(clienteId)) {
|
||||||
|
clienteMap.set(clienteId, [])
|
||||||
|
}
|
||||||
|
clienteMap.get(clienteId)!.push(ingreso)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calcular métrica para cada cliente
|
||||||
|
const results: TopClienteItem[] = []
|
||||||
|
|
||||||
|
clienteMap.forEach((ingresos, clienteId) => {
|
||||||
|
const cliente = props.clientes.find(c => c.id === clienteId)
|
||||||
|
if (!cliente) return
|
||||||
|
|
||||||
|
// Calcular el valor usando la función de la categoría actual
|
||||||
|
const value = categoriaActual.value.calculate(ingresos)
|
||||||
|
|
||||||
|
results.push({ cliente, value })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ordenar según el modo (Top o Bottom)
|
||||||
|
const sorted = results
|
||||||
|
.sort((a, b) => {
|
||||||
|
// En modo Bottom, invertir el orden (ascendente)
|
||||||
|
return isBottomMode.value ? a.value - b.value : b.value - a.value
|
||||||
|
})
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
const mode = isBottomMode.value ? 'Bottom' : 'Top'
|
||||||
|
console.log(`${mode} Clientes - Categoría:`, categoriaActual.value.label)
|
||||||
|
console.log(`${mode} Clientes - ${mode} 3:`, sorted.slice(0, 3).map(s => ({
|
||||||
|
name: s.cliente.name,
|
||||||
|
value: s.value,
|
||||||
|
unit: categoriaActual.value.unit
|
||||||
|
})))
|
||||||
|
|
||||||
|
return sorted
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatValue(value: number): string {
|
||||||
|
const unit = categoriaActual.value.unit
|
||||||
|
|
||||||
|
if (unit === 'L') {
|
||||||
|
// Formato monetario
|
||||||
|
return new Intl.NumberFormat('es-HN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'HNL',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(value).replace('HNL', 'L')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unit === 'registros') {
|
||||||
|
return value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formato numérico con decimales
|
||||||
|
return value.toLocaleString('es-HN', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -54,8 +54,24 @@ const data = computed<(IngresoWithChildren | ClienteWithChildren)[]>(() => {
|
|||||||
// Clientes as parent, ingresos as children
|
// Clientes as parent, ingresos as children
|
||||||
const clientesData = props.clientes.map(cliente => {
|
const clientesData = props.clientes.map(cliente => {
|
||||||
const clienteIngresos = props.ingresos.filter(i => i.cliente_id === cliente.id)
|
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 {
|
return {
|
||||||
...cliente,
|
...cliente,
|
||||||
|
peso_seco,
|
||||||
|
peso_neto,
|
||||||
|
precio,
|
||||||
|
estado: estados.length > 0 ? estados.join(', ') : '',
|
||||||
children: clienteIngresos
|
children: clienteIngresos
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -164,6 +180,14 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const original = row.original as any
|
const original = row.original as any
|
||||||
const isIngreso = 'tipo' in original
|
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' }, '—')
|
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
|
||||||
|
|
||||||
const peso = Number.parseFloat(row.getValue('peso_seco') || 0)
|
const peso = Number.parseFloat(row.getValue('peso_seco') || 0)
|
||||||
@@ -176,6 +200,14 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const original = row.original as any
|
const original = row.original as any
|
||||||
const isIngreso = 'tipo' in original
|
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' }, '—')
|
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
|
||||||
|
|
||||||
const peso = Number.parseFloat(row.getValue('peso_neto') || 0)
|
const peso = Number.parseFloat(row.getValue('peso_neto') || 0)
|
||||||
@@ -188,6 +220,21 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const original = row.original as any
|
const original = row.original as any
|
||||||
const isIngreso = 'tipo' in original
|
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' }, '—')
|
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
|
||||||
|
|
||||||
const precio = Number.parseFloat(row.getValue('precio') || 0)
|
const precio = Number.parseFloat(row.getValue('precio') || 0)
|
||||||
@@ -210,7 +257,22 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
|||||||
const isIngreso = 'tipo' in original
|
const isIngreso = 'tipo' in original
|
||||||
|
|
||||||
if (isCliente) {
|
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
|
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', { 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', '💼'),
|
h('span', '💼'),
|
||||||
|
|||||||
@@ -265,19 +265,78 @@
|
|||||||
</template>
|
</template>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Totales de Ingreso y Compra -->
|
<!-- Totales por Café -->
|
||||||
<IngresosTotalesIngresoCompra :metrics="ingresosMetrics" />
|
|
||||||
|
|
||||||
<!-- Vista Tabla según tab activo -->
|
|
||||||
<UCard class="brand-card border border-transparent">
|
<UCard class="brand-card border border-transparent">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold brand-section-title">{{ tableTitle }}</h2>
|
<h2 class="text-xl font-bold brand-section-title">Totales por Café</h2>
|
||||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||||
{{ tableDescription }}
|
{{ showMonetaryView ? 'Vista de valores monetarios' : 'Vista de quintales (qq) y libras (lb)' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" :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
|
||||||
|
: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 brand-section-title">{{ 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 ref="viewSelectorRef" class="flex flex-col gap-3">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<button
|
<button
|
||||||
@@ -322,6 +381,22 @@
|
|||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Badge con contador -->
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'relative z-10 inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 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' :
|
||||||
|
selectedView === option.value && option.color === 'gradient' ? 'bg-white/20 text-white border border-white/30' :
|
||||||
|
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' :
|
||||||
|
option.color === 'gradient' ? 'bg-gradient-to-r from-cyan-500/20 to-yellow-500/20 text-gray-300 border border-gray-500/30' :
|
||||||
|
'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ getViewCount(option.value) }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,6 +443,9 @@
|
|||||||
:include-clientes-without-ingresos="includeClientesWithoutIngresos"
|
:include-clientes-without-ingresos="includeClientesWithoutIngresos"
|
||||||
/>
|
/>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Top 10 Clientes -->
|
||||||
|
<IngresosTopClientes :ingresos="ingresosFiltrados" :clientes="clientesFiltrados" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -381,7 +459,7 @@ import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
|||||||
// Define page metadata
|
// Define page metadata
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'dashboard',
|
layout: 'dashboard',
|
||||||
title: 'Cuenta Cliente'
|
title: 'Analizador Ingresos-Clientes'
|
||||||
})
|
})
|
||||||
|
|
||||||
// View modes with explicit hierarchy
|
// View modes with explicit hierarchy
|
||||||
@@ -688,6 +766,65 @@ const clientesFiltrados = computed((): ClienteRecord[] => {
|
|||||||
// Métricos basados en filtrados
|
// Métricos basados en filtrados
|
||||||
const ingresosMetrics = useIngresosMetrics(ingresosFiltrados)
|
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
|
// Loading and error states
|
||||||
const loading = computed(() => ingresosStore.isLoading || clientesStore.isLoading)
|
const loading = computed(() => ingresosStore.isLoading || clientesStore.isLoading)
|
||||||
const error = computed(() => ingresosStore.error || clientesStore.error)
|
const error = computed(() => ingresosStore.error || clientesStore.error)
|
||||||
@@ -751,6 +888,9 @@ onMounted(async () => {
|
|||||||
selectedEstados.value = []
|
selectedEstados.value = []
|
||||||
selectedUbicaciones.value = []
|
selectedUbicaciones.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listener para escape key en fullscreen
|
||||||
|
window.addEventListener('keydown', handleEscape)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user