All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 48s
- Agregadas nuevas variables CSS --brand-cosecha-2, --brand-cosecha-3, --brand-cosecha-4 para gráficas - Reemplazado --brand-bg-secondary (no existía) por --brand-surface en todos los componentes - Actualizados arrays de colores en componentes de comparativa de cosechas - Eliminados colores RGB/hex hardcodeados en tooltips y badges - Todos los componentes ahora respetan el sistema de temas dinámico
584 lines
20 KiB
Vue
584 lines
20 KiB
Vue
<!-- 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">
|
|
{{ viewModeTitle }}
|
|
</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>
|
|
|
|
<!-- Estadísticas de ranking -->
|
|
<div class="flex items-center gap-2 mt-2 text-xs flex-wrap">
|
|
<div v-if="rankingStats.clientesArriba > 0" class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-purple-500/10 border border-purple-500/30">
|
|
<UIcon name="i-lucide-arrow-up" class="w-3 h-3 text-purple-400" />
|
|
<span class="text-purple-300 font-semibold">{{ rankingStats.clientesArriba }}</span>
|
|
<span class="text-[var(--brand-text-muted)]">arriba</span>
|
|
<span class="text-purple-400 font-mono">({{ rankingStats.porcentajeArriba.toFixed(1) }}%)</span>
|
|
</div>
|
|
|
|
<div v-if="rankingStats.clientesAbajo > 0" class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-gray-500/10 border border-gray-500/30">
|
|
<UIcon name="i-lucide-arrow-down" class="w-3 h-3 text-[var(--brand-text-muted)]" />
|
|
<span class="text-gray-300 font-semibold">{{ rankingStats.clientesAbajo }}</span>
|
|
<span class="text-[var(--brand-text-muted)]">abajo</span>
|
|
<span class="text-[var(--brand-text-muted)] font-mono">({{ rankingStats.porcentajeAbajo.toFixed(1) }}%)</span>
|
|
</div>
|
|
|
|
<div class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-[var(--brand-primary)]/10 border border-[var(--brand-primary)]/30">
|
|
<UIcon name="i-lucide-users" class="w-3 h-3 text-[var(--brand-primary)]" />
|
|
<span class="text-[var(--brand-primary)] font-semibold">{{ rankingStats.totalClientes }}</span>
|
|
<span class="text-[var(--brand-text-muted)]">total</span>
|
|
</div>
|
|
|
|
<!-- Acumulado mostrado -->
|
|
<div class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-green-500/10 border border-green-500/30">
|
|
<UIcon name="i-lucide-trending-up" class="w-3 h-3 text-green-400" />
|
|
<span class="text-green-400 font-semibold">{{ formatValue(acumulados.mostrados) }}</span>
|
|
<span class="text-[var(--brand-text-muted)]">visible</span>
|
|
<span class="text-green-400 font-mono">({{ acumulados.porcentajeMostrados.toFixed(1) }}%)</span>
|
|
</div>
|
|
|
|
<!-- Acumulado oculto -->
|
|
<div v-if="acumulados.ocultos > 0" class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-orange-500/10 border border-orange-500/30">
|
|
<UIcon name="i-lucide-trending-down" class="w-3 h-3 text-orange-400" />
|
|
<span class="text-orange-400 font-semibold">{{ formatValue(acumulados.ocultos) }}</span>
|
|
<span class="text-[var(--brand-text-muted)]">{{ viewMode === 'top' ? 'abajo' : viewMode === 'bottom' ? 'arriba' : 'fuera' }}</span>
|
|
<span class="text-orange-400 font-mono">({{ acumulados.porcentajeOcultos.toFixed(1) }}%)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<UInput
|
|
v-model.number="cantidadClientes"
|
|
type="number"
|
|
:min="1"
|
|
:max="100"
|
|
size="sm"
|
|
class="w-16"
|
|
placeholder="10"
|
|
/>
|
|
<div class="flex items-center gap-1 border border-[var(--brand-border)] rounded-md p-0.5">
|
|
<UButton
|
|
icon="i-lucide-arrow-down-wide-narrow"
|
|
:color="viewMode === 'top' ? 'primary' : 'neutral'"
|
|
:variant="viewMode === 'top' ? 'soft' : 'ghost'"
|
|
size="xs"
|
|
@click="viewMode = 'top'"
|
|
title="Top N"
|
|
>
|
|
Top
|
|
</UButton>
|
|
<UButton
|
|
icon="i-lucide-minus"
|
|
:color="viewMode === 'average' ? 'warning' : 'neutral'"
|
|
:variant="viewMode === 'average' ? 'soft' : 'ghost'"
|
|
size="xs"
|
|
@click="viewMode = 'average'"
|
|
title="Average N*2+1"
|
|
>
|
|
Avg
|
|
</UButton>
|
|
<UButton
|
|
icon="i-lucide-arrow-up-wide-narrow"
|
|
:color="viewMode === 'bottom' ? 'error' : 'neutral'"
|
|
:variant="viewMode === 'bottom' ? 'soft' : 'ghost'"
|
|
size="xs"
|
|
@click="viewMode = 'bottom'"
|
|
title="Bottom N"
|
|
>
|
|
Bot
|
|
</UButton>
|
|
</div>
|
|
<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-surface)] to-transparent hover:border-[var(--brand-primary)]/30 transition-all"
|
|
>
|
|
<!-- Ranking Badge -->
|
|
<div
|
|
:class="getRankingBadgeClass(index)"
|
|
>
|
|
{{ 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">{{ viewModeTitle }} - {{ 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 viewMode = ref<'top' | 'average' | 'bottom'>('top')
|
|
const cantidadClientes = ref(10)
|
|
|
|
const categoriaActual = computed(() => {
|
|
return categorias.find(c => c.value === categoriaSeleccionada.value) ?? categorias[0]
|
|
})
|
|
|
|
const viewModeTitle = computed(() => {
|
|
const cantidad = viewMode.value === 'average' ? cantidadClientes.value * 2 + 1 : cantidadClientes.value
|
|
switch (viewMode.value) {
|
|
case 'top':
|
|
return `Top ${cantidadClientes.value} Clientes`
|
|
case 'average':
|
|
return `Average ${cantidad} Clientes`
|
|
case 'bottom':
|
|
return `Bottom ${cantidadClientes.value} Clientes`
|
|
default:
|
|
return `Top ${cantidadClientes.value} Clientes`
|
|
}
|
|
})
|
|
|
|
interface TopClienteItem {
|
|
cliente: ClienteRecord
|
|
value: number
|
|
}
|
|
|
|
interface RankingStats {
|
|
totalClientes: number
|
|
clientesArriba: number
|
|
clientesAbajo: number
|
|
porcentajeArriba: number
|
|
porcentajeAbajo: number
|
|
}
|
|
|
|
function getRankingBadgeClass(index: number): string {
|
|
const baseClass = 'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm'
|
|
|
|
if (viewMode.value === 'average') {
|
|
// En modo average, el índice central es el oro - DESTACADO
|
|
const centralIndex = cantidadClientes.value
|
|
if (index === centralIndex) {
|
|
return `${baseClass} bg-yellow-500/30 text-yellow-400 border-2 border-yellow-500/70 shadow-lg shadow-yellow-500/50 scale-110`
|
|
}
|
|
|
|
// Posiciones antes del central: versiones más poderosas (degradado de morado a azul)
|
|
if (index < centralIndex) {
|
|
const normalizedPosition = index / centralIndex
|
|
|
|
if (normalizedPosition < 0.2) {
|
|
return `${baseClass} bg-purple-600/40 text-purple-200 border-2 border-purple-500/70 shadow-lg shadow-purple-500/30`
|
|
} else if (normalizedPosition < 0.4) {
|
|
return `${baseClass} bg-purple-500/35 text-purple-300 border-2 border-purple-400/60 shadow-md`
|
|
} else if (normalizedPosition < 0.6) {
|
|
return `${baseClass} bg-indigo-500/30 text-indigo-300 border-2 border-indigo-400/55 shadow`
|
|
} else if (normalizedPosition < 0.8) {
|
|
return `${baseClass} bg-blue-500/25 text-blue-300 border-2 border-blue-400/50`
|
|
} else {
|
|
return `${baseClass} bg-cyan-500/20 text-cyan-400 border border-cyan-400/45`
|
|
}
|
|
}
|
|
|
|
// Posiciones después del central: versiones más básicas (degradado de gris a casi invisible)
|
|
if (index > centralIndex) {
|
|
const distanceFromCenter = index - centralIndex
|
|
const normalizedDistance = distanceFromCenter / cantidadClientes.value
|
|
|
|
if (normalizedDistance < 0.2) {
|
|
return `${baseClass} bg-gray-400/20 text-gray-300 border border-gray-400/40`
|
|
} else if (normalizedDistance < 0.4) {
|
|
return `${baseClass} bg-gray-500/15 text-[var(--brand-text-muted)] border border-gray-500/35`
|
|
} else if (normalizedDistance < 0.6) {
|
|
return `${baseClass} bg-gray-600/10 text-gray-500 border border-gray-600/25`
|
|
} else if (normalizedDistance < 0.8) {
|
|
return `${baseClass} bg-gray-700/8 text-gray-600 border border-gray-700/20`
|
|
} else {
|
|
return `${baseClass} bg-gray-800/5 text-gray-700 border border-gray-800/15`
|
|
}
|
|
}
|
|
}
|
|
|
|
// Modo Top o Bottom (lógica normal)
|
|
if (index === 0) {
|
|
return `${baseClass} bg-yellow-500/20 text-yellow-400 border-2 border-yellow-500/50`
|
|
}
|
|
if (index === 1) {
|
|
return `${baseClass} bg-gray-400/20 text-gray-300 border-2 border-gray-400/50`
|
|
}
|
|
if (index === 2) {
|
|
return `${baseClass} bg-orange-500/20 text-orange-400 border-2 border-orange-500/50`
|
|
}
|
|
|
|
return `${baseClass} bg-[var(--brand-primary)]/10 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30`
|
|
}
|
|
|
|
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 descendente (mayor a menor) siempre
|
|
const sorted = results.sort((a, b) => b.value - a.value)
|
|
|
|
let finalResults: TopClienteItem[] = []
|
|
|
|
// Seleccionar registros según el modo
|
|
switch (viewMode.value) {
|
|
case 'top':
|
|
finalResults = sorted.slice(0, cantidadClientes.value)
|
|
break
|
|
|
|
case 'bottom':
|
|
// Tomar los últimos N (inverso)
|
|
finalResults = sorted.slice(-cantidadClientes.value).reverse()
|
|
break
|
|
|
|
case 'average':
|
|
// Tomar N*2+1 clientes centrados (posición central es el oro)
|
|
const totalClientes = sorted.length
|
|
const totalAverage = cantidadClientes.value * 2 + 1
|
|
if (totalClientes < totalAverage) {
|
|
// Si hay menos del total, mostrar todos
|
|
finalResults = sorted
|
|
} else {
|
|
// Calcular el índice central
|
|
const midIndex = Math.floor(totalClientes / 2)
|
|
// Tomar N antes, el central, y N después
|
|
const startIndex = Math.max(0, midIndex - cantidadClientes.value)
|
|
finalResults = sorted.slice(startIndex, startIndex + totalAverage)
|
|
}
|
|
break
|
|
|
|
default:
|
|
finalResults = sorted.slice(0, cantidadClientes.value)
|
|
}
|
|
|
|
// Debug log
|
|
console.log(`${viewMode.value.toUpperCase()} Clientes - Categoría:`, categoriaActual.value.label)
|
|
console.log(`${viewMode.value.toUpperCase()} Clientes - Top 3:`, finalResults.slice(0, 3).map(s => ({
|
|
name: s.cliente.name,
|
|
value: s.value,
|
|
unit: categoriaActual.value.unit
|
|
})))
|
|
|
|
return finalResults
|
|
})
|
|
|
|
// Acumulados para header y footer
|
|
const acumulados = computed(() => {
|
|
// 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 allResults: TopClienteItem[] = []
|
|
|
|
clienteMap.forEach((ingresos, clienteId) => {
|
|
const cliente = props.clientes.find(c => c.id === clienteId)
|
|
if (!cliente) return
|
|
|
|
const value = categoriaActual.value.calculate(ingresos)
|
|
allResults.push({ cliente, value })
|
|
})
|
|
|
|
// Ordenar descendente
|
|
const sorted = allResults.sort((a, b) => b.value - a.value)
|
|
|
|
// Calcular acumulados según el modo
|
|
let acumuladoMostrados = 0
|
|
let acumuladoOcultos = 0
|
|
|
|
if (viewMode.value === 'top') {
|
|
// Top N: sumar los primeros N
|
|
const topItems = sorted.slice(0, cantidadClientes.value)
|
|
const bottomItems = sorted.slice(cantidadClientes.value)
|
|
|
|
acumuladoMostrados = topItems.reduce((sum, item) => sum + item.value, 0)
|
|
acumuladoOcultos = bottomItems.reduce((sum, item) => sum + item.value, 0)
|
|
} else if (viewMode.value === 'bottom') {
|
|
// Bottom N: sumar los últimos N
|
|
const bottomItems = sorted.slice(-cantidadClientes.value)
|
|
const topItems = sorted.slice(0, -cantidadClientes.value)
|
|
|
|
acumuladoMostrados = bottomItems.reduce((sum, item) => sum + item.value, 0)
|
|
acumuladoOcultos = topItems.reduce((sum, item) => sum + item.value, 0)
|
|
} else if (viewMode.value === 'average') {
|
|
// Average N*2+1: sumar los del medio
|
|
const totalClientes = sorted.length
|
|
const totalAverage = cantidadClientes.value * 2 + 1
|
|
if (totalClientes < totalAverage) {
|
|
acumuladoMostrados = sorted.reduce((sum, item) => sum + item.value, 0)
|
|
acumuladoOcultos = 0
|
|
} else {
|
|
const midIndex = Math.floor(totalClientes / 2)
|
|
const startIndex = Math.max(0, midIndex - cantidadClientes.value)
|
|
const avgItems = sorted.slice(startIndex, startIndex + totalAverage)
|
|
const otherItems = [...sorted.slice(0, startIndex), ...sorted.slice(startIndex + totalAverage)]
|
|
|
|
acumuladoMostrados = avgItems.reduce((sum, item) => sum + item.value, 0)
|
|
acumuladoOcultos = otherItems.reduce((sum, item) => sum + item.value, 0)
|
|
}
|
|
}
|
|
|
|
const totalAcumulado = acumuladoMostrados + acumuladoOcultos
|
|
const porcentajeMostrados = totalAcumulado > 0 ? (acumuladoMostrados / totalAcumulado) * 100 : 0
|
|
const porcentajeOcultos = totalAcumulado > 0 ? (acumuladoOcultos / totalAcumulado) * 100 : 0
|
|
|
|
return {
|
|
mostrados: acumuladoMostrados,
|
|
ocultos: acumuladoOcultos,
|
|
porcentajeMostrados,
|
|
porcentajeOcultos
|
|
}
|
|
})
|
|
|
|
const rankingStats = computed((): RankingStats => {
|
|
// Agrupar todos los clientes primero para obtener el total
|
|
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)
|
|
})
|
|
|
|
const totalClientes = clienteMap.size
|
|
|
|
let clientesArriba = 0
|
|
let clientesAbajo = 0
|
|
|
|
if (viewMode.value === 'top') {
|
|
// En Top N, todos los demás están abajo
|
|
clientesArriba = 0
|
|
clientesAbajo = Math.max(0, totalClientes - cantidadClientes.value)
|
|
} else if (viewMode.value === 'bottom') {
|
|
// En Bottom N, todos los demás están arriba
|
|
clientesArriba = Math.max(0, totalClientes - cantidadClientes.value)
|
|
clientesAbajo = 0
|
|
} else if (viewMode.value === 'average') {
|
|
// En Average N*2+1, calcular cuántos hay arriba y abajo
|
|
const totalAverage = cantidadClientes.value * 2 + 1
|
|
if (totalClientes <= totalAverage) {
|
|
clientesArriba = 0
|
|
clientesAbajo = 0
|
|
} else {
|
|
const midIndex = Math.floor(totalClientes / 2)
|
|
const startIndex = Math.max(0, midIndex - cantidadClientes.value)
|
|
const endIndex = startIndex + totalAverage
|
|
|
|
clientesArriba = startIndex
|
|
clientesAbajo = Math.max(0, totalClientes - endIndex)
|
|
}
|
|
}
|
|
|
|
const porcentajeArriba = totalClientes > 0 ? (clientesArriba / totalClientes) * 100 : 0
|
|
const porcentajeAbajo = totalClientes > 0 ? (clientesAbajo / totalClientes) * 100 : 0
|
|
|
|
return {
|
|
totalClientes,
|
|
clientesArriba,
|
|
clientesAbajo,
|
|
porcentajeArriba,
|
|
porcentajeAbajo
|
|
}
|
|
})
|
|
|
|
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>
|