mejoras UX exquisitas
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user