mejroas ui/ux cuentas-cliente
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold brand-section-title">Vista Tabla de Clientes</h2>
|
||||
<h2 class="text-xl font-bold text-yellow-500">Vista Tabla de Clientes</h2>
|
||||
<p class="text-sm text-[var(--brand-text-muted)] mt-1">
|
||||
{{ props.records.length }} clientes registrados
|
||||
</p>
|
||||
@@ -51,6 +51,9 @@
|
||||
:global-filter="globalFilter"
|
||||
sticky
|
||||
class="h-96"
|
||||
:ui="{
|
||||
thead: 'bg-yellow-500/20 [&>tr>th]:text-white [&>tr>th]:font-semibold'
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Table Footer -->
|
||||
@@ -192,7 +195,7 @@ const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
variant: 'ghost',
|
||||
label: upperFirst(column),
|
||||
icon: isSorted ? (isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow') : 'i-lucide-arrow-up-down',
|
||||
class: '-mx-2.5',
|
||||
class: '-mx-2.5 text-white hover:text-yellow-100',
|
||||
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
|
||||
})
|
||||
},
|
||||
|
||||
@@ -5,23 +5,93 @@
|
||||
<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' }}
|
||||
{{ 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-gray-400" />
|
||||
<span class="text-gray-300 font-semibold">{{ rankingStats.clientesAbajo }}</span>
|
||||
<span class="text-[var(--brand-text-muted)]">abajo</span>
|
||||
<span class="text-gray-400 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">
|
||||
<UButton
|
||||
:icon="isBottomMode ? 'i-lucide-arrow-up-wide-narrow' : 'i-lucide-arrow-down-wide-narrow'"
|
||||
:color="isBottomMode ? 'error' : 'primary'"
|
||||
variant="soft"
|
||||
<UInput
|
||||
v-model.number="cantidadClientes"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="100"
|
||||
size="sm"
|
||||
@click="isBottomMode = !isBottomMode"
|
||||
:title="isBottomMode ? 'Mostrar Top 10' : 'Mostrar Bottom 10'"
|
||||
>
|
||||
{{ isBottomMode ? 'Bottom 10' : 'Top 10' }}
|
||||
</UButton>
|
||||
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"
|
||||
@@ -43,13 +113,7 @@
|
||||
>
|
||||
<!-- 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'
|
||||
]"
|
||||
:class="getRankingBadgeClass(index)"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
@@ -89,7 +153,7 @@
|
||||
<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>
|
||||
<p class="text-xs mt-1">{{ viewModeTitle }} - {{ categoriaActual.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
@@ -199,10 +263,25 @@ const categorias: Categoria[] = [
|
||||
]
|
||||
|
||||
const categoriaSeleccionada = ref('total-ingresos')
|
||||
const isBottomMode = ref(false)
|
||||
const viewMode = ref<'top' | 'average' | 'bottom'>('top')
|
||||
const cantidadClientes = ref(10)
|
||||
|
||||
const categoriaActual = computed(() => {
|
||||
return categorias.find(c => c.value === categoriaSeleccionada.value) || categorias[0]
|
||||
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 {
|
||||
@@ -210,6 +289,74 @@ interface TopClienteItem {
|
||||
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-gray-400 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[]>()
|
||||
@@ -235,24 +382,179 @@ const topClientes = computed((): TopClienteItem[] => {
|
||||
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)
|
||||
// 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
|
||||
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 => ({
|
||||
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 sorted
|
||||
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 {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold brand-section-title">Vista Tabla de Ingresos</h2>
|
||||
<h2 class="text-xl font-bold text-cyan-400">Vista Tabla de Ingresos</h2>
|
||||
<p class="text-sm text-[var(--brand-text-muted)] mt-1">
|
||||
{{ props.records.length }} registros filtrados
|
||||
</p>
|
||||
@@ -51,6 +51,9 @@
|
||||
:global-filter="globalFilter"
|
||||
sticky
|
||||
class="h-96"
|
||||
:ui="{
|
||||
thead: 'bg-cyan-400/20 [&>tr>th]:text-white [&>tr>th]:font-semibold'
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Table Footer -->
|
||||
@@ -196,7 +199,7 @@ const tableColumns = computed((): TableColumn<Record<string, unknown>>[] => {
|
||||
variant: 'ghost',
|
||||
label: upperFirst(column),
|
||||
icon: isSorted ? (isSorted === 'asc' ? 'i-lucide-arrow-up-narrow-wide' : 'i-lucide-arrow-down-wide-narrow') : 'i-lucide-arrow-up-down',
|
||||
class: '-mx-2.5',
|
||||
class: '-mx-2.5 text-white hover:text-cyan-100',
|
||||
onClick: () => tableColumn.toggleSorting(tableColumn.getIsSorted() === 'asc')
|
||||
})
|
||||
},
|
||||
@@ -334,6 +337,67 @@ function formatCellValue(value: unknown, column: string): any {
|
||||
})
|
||||
}
|
||||
|
||||
// created_at - fecha en bold y un poco más grande
|
||||
if (column === 'created_at' && typeof value === 'string' && value.includes('T')) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
const formattedDate = dateFormat.value === 'long'
|
||||
? date.toLocaleDateString('es-HN', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: date.toLocaleDateString('es-HN')
|
||||
return h('span', { class: 'font-bold text-base' }, formattedDate)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// fecha_pagado - fecha en verde
|
||||
if (column === 'fecha_pagado' && typeof value === 'string' && value.includes('T')) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
const formattedDate = dateFormat.value === 'long'
|
||||
? date.toLocaleDateString('es-HN', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: date.toLocaleDateString('es-HN')
|
||||
return h('span', { class: 'text-green-500 font-semibold' }, formattedDate)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// pagado_id - ID en verde
|
||||
if (column === 'pagado_id' && typeof value === 'number') {
|
||||
return h(UBadge, {
|
||||
label: `P-${value}`,
|
||||
color: 'success',
|
||||
variant: 'subtle',
|
||||
size: 'md',
|
||||
class: 'rounded-full'
|
||||
})
|
||||
}
|
||||
|
||||
// fecha_anulado - fecha en rojo
|
||||
if (column === 'fecha_anulado' && typeof value === 'string' && value.includes('T')) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
const formattedDate = dateFormat.value === 'long'
|
||||
? date.toLocaleDateString('es-HN', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: date.toLocaleDateString('es-HN')
|
||||
return h('span', { class: 'text-red-500 font-semibold' }, formattedDate)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// anulador_id - ID en rojo
|
||||
if (column === 'anulador_id' && typeof value === 'number') {
|
||||
return h(UBadge, {
|
||||
label: `A-${value}`,
|
||||
color: 'error',
|
||||
variant: 'subtle',
|
||||
size: 'md',
|
||||
class: 'rounded-full'
|
||||
})
|
||||
}
|
||||
|
||||
// precio - formato lempiras con decimales y comas
|
||||
if (column === 'precio' && typeof value === 'number') {
|
||||
return `L. ${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
@@ -349,7 +413,7 @@ function formatCellValue(value: unknown, column: string): any {
|
||||
return value.toLocaleString('en-US', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// Formatear fechas
|
||||
// Formatear fechas (genérico para otras fechas)
|
||||
if (typeof value === 'string' && value.includes('T')) {
|
||||
try {
|
||||
const date = new Date(value)
|
||||
|
||||
@@ -85,10 +85,21 @@ const data = computed<(IngresoWithChildren | ClienteWithChildren)[]>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
// Compute table UI for thead background
|
||||
const tableUi = computed(() => {
|
||||
const bgClass = props.primaryView === 'ingresos'
|
||||
? 'bg-cyan-400/20'
|
||||
: 'bg-yellow-500/20'
|
||||
|
||||
return {
|
||||
thead: `${bgClass} [&>tr>th]:text-white [&>tr>th]:font-semibold`
|
||||
}
|
||||
})
|
||||
|
||||
const columns = computed((): TableColumn<IngresoWithChildren | ClienteWithChildren>[] => [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: '#',
|
||||
header: () => h('span', { class: 'text-white font-semibold' }, '#'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
@@ -127,7 +138,7 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Fecha',
|
||||
header: () => h('span', { class: 'text-white font-semibold' }, 'Fecha'),
|
||||
cell: ({ row }) => {
|
||||
const created = row.getValue('created_at') as string | undefined
|
||||
if (!created) return '—'
|
||||
@@ -143,7 +154,7 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Info',
|
||||
header: () => h('span', { class: 'text-white font-semibold' }, 'Info'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isCliente = 'name' in original && !('tipo' in original)
|
||||
@@ -176,7 +187,7 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'peso_seco',
|
||||
header: () => h('div', { class: 'text-right' }, 'Peso Seco (qq)'),
|
||||
header: () => h('div', { class: 'text-right text-white font-semibold' }, 'Peso Seco (qq)'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
@@ -196,7 +207,7 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'peso_neto',
|
||||
header: () => h('div', { class: 'text-right' }, 'Peso Neto (lb)'),
|
||||
header: () => h('div', { class: 'text-right text-white font-semibold' }, 'Peso Neto (lb)'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
@@ -216,7 +227,7 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'precio',
|
||||
header: () => h('div', { class: 'text-right' }, 'Precio'),
|
||||
header: () => h('div', { class: 'text-right text-white font-semibold' }, 'Precio'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
@@ -250,7 +261,7 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: 'estado',
|
||||
header: 'Estado',
|
||||
header: () => h('span', { class: 'text-white font-semibold' }, 'Estado'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isCliente = 'name' in original && !('tipo' in original)
|
||||
@@ -293,7 +304,7 @@ const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
return h('span', { class: 'text-gray-500 text-xs' }, '—')
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const expanded = ref<Record<string, boolean>>({})
|
||||
|
||||
@@ -344,6 +355,7 @@ function previousPage() {
|
||||
class="flex-1"
|
||||
:ui="{
|
||||
base: 'border-separate border-spacing-0',
|
||||
thead: tableUi.thead,
|
||||
tbody: '[&>tr]:last:[&>td]:border-b-0',
|
||||
tr: 'group',
|
||||
td: 'empty:p-0 group-has-[td:not(:empty)]:border-b border-default'
|
||||
|
||||
@@ -125,10 +125,10 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
||||
active: route.path === '/panorama'
|
||||
},
|
||||
{
|
||||
label: 'Cuenta Cliente',
|
||||
icon: 'i-lucide-user-circle',
|
||||
to: '/cuenta-cliente',
|
||||
active: route.path === '/cuenta-cliente'
|
||||
label: 'Informe Ingresos',
|
||||
icon: 'i-lucide-file-bar-chart',
|
||||
to: '/informe-ingresos',
|
||||
active: route.path === '/informe-ingresos'
|
||||
},
|
||||
{
|
||||
label: 'Explorador de datos',
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
<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>
|
||||
<h2 :class="['text-xl font-bold', tableTitleClass]">{{ tableTitle }}</h2>
|
||||
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
|
||||
{{ tableDescription }}
|
||||
</p>
|
||||
@@ -528,6 +528,20 @@ const tableTitle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const tableTitleClass = computed(() => {
|
||||
switch (selectedView.value) {
|
||||
case 'ingresos-only':
|
||||
return 'text-cyan-400'
|
||||
case 'clientes-only':
|
||||
return 'text-yellow-500'
|
||||
case 'ingresos-clientes':
|
||||
case 'clientes-ingresos':
|
||||
return 'bg-gradient-to-r from-cyan-400 to-yellow-500 bg-clip-text text-transparent'
|
||||
default:
|
||||
return 'brand-section-title'
|
||||
}
|
||||
})
|
||||
|
||||
const tableDescription = computed(() => {
|
||||
switch (selectedView.value) {
|
||||
case 'ingresos-only':
|
||||
Reference in New Issue
Block a user