cuenta-cliente tabla pro implementada
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
<!-- nuxt4-app/app/components/ingresos/VistaTablaIngresosConClientes.vue -->
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import type { TableColumn } from '@nuxt/ui'
|
||||
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
||||
|
||||
const UButton = resolveComponent('UButton')
|
||||
|
||||
interface ClienteRecord {
|
||||
id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
name: string
|
||||
cedula?: number
|
||||
ubicacion?: string
|
||||
grupo_estudio?: string
|
||||
empleado?: boolean
|
||||
avatar_url?: string
|
||||
telefono?: string
|
||||
idciat?: number
|
||||
}
|
||||
|
||||
interface IngresoWithChildren extends IngresoRecord {
|
||||
children?: ClienteRecord[]
|
||||
}
|
||||
|
||||
interface ClienteWithChildren extends ClienteRecord {
|
||||
children?: IngresoRecord[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ingresos: IngresoRecord[]
|
||||
clientes: ClienteRecord[]
|
||||
primaryView: 'ingresos' | 'clientes'
|
||||
includeClientesWithoutIngresos?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
includeClientesWithoutIngresos: false
|
||||
})
|
||||
|
||||
// Map data based on primary view
|
||||
const data = computed<(IngresoWithChildren | ClienteWithChildren)[]>(() => {
|
||||
if (props.primaryView === 'ingresos') {
|
||||
// Ingresos as parent, cliente as child
|
||||
return props.ingresos.map(ingreso => {
|
||||
const cliente = props.clientes.find(c => c.id === ingreso.cliente_id)
|
||||
return {
|
||||
...ingreso,
|
||||
children: cliente ? [cliente] : []
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Clientes as parent, ingresos as children
|
||||
const clientesData = props.clientes.map(cliente => {
|
||||
const clienteIngresos = props.ingresos.filter(i => i.cliente_id === cliente.id)
|
||||
return {
|
||||
...cliente,
|
||||
children: clienteIngresos
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out clientes without ingresos if toggle is OFF
|
||||
if (!props.includeClientesWithoutIngresos) {
|
||||
return clientesData.filter(cliente => cliente.children && cliente.children.length > 0)
|
||||
}
|
||||
|
||||
return clientesData
|
||||
}
|
||||
})
|
||||
|
||||
const columns: TableColumn<IngresoWithChildren | ClienteWithChildren>[] = [
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: '#',
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
const isCliente = 'name' in original && !('tipo' in original)
|
||||
|
||||
// Parent row always has button
|
||||
const isParent = row.depth === 0
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
paddingLeft: `${row.depth * 2}rem`
|
||||
},
|
||||
class: 'flex items-center gap-2'
|
||||
},
|
||||
[
|
||||
isParent && h(UButton, {
|
||||
color: 'neutral',
|
||||
variant: 'outline',
|
||||
size: 'xs',
|
||||
icon: row.getIsExpanded() ? 'i-lucide-minus' : 'i-lucide-plus',
|
||||
class: !row.getCanExpand() && 'invisible',
|
||||
ui: {
|
||||
base: 'p-0 rounded-sm',
|
||||
leadingIcon: 'size-4'
|
||||
},
|
||||
onClick: row.getToggleExpandedHandler()
|
||||
}),
|
||||
h('span', {
|
||||
class: isCliente ? 'text-yellow-500 font-semibold' : isIngreso ? 'text-cyan-400 font-semibold' : ''
|
||||
}, String(row.getValue('id')))
|
||||
]
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Fecha',
|
||||
cell: ({ row }) => {
|
||||
const created = row.getValue('created_at') as string | undefined
|
||||
if (!created) return '—'
|
||||
return new Date(created).toLocaleString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Info',
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isCliente = 'name' in original && !('tipo' in original)
|
||||
const isIngreso = 'tipo' in original
|
||||
|
||||
// If it's a cliente
|
||||
if (isCliente) {
|
||||
return h('div', { class: 'flex flex-col gap-1' }, [
|
||||
h('span', { class: 'font-semibold text-yellow-500' }, original.name),
|
||||
original.ubicacion && h('span', { class: 'text-xs text-gray-400' }, `📍 ${original.ubicacion}`),
|
||||
original.telefono && h('span', { class: 'text-xs text-gray-400' }, `📞 ${original.telefono}`),
|
||||
original.cedula && h('span', { class: 'text-xs text-gray-400' }, `🆔 ${original.cedula}`)
|
||||
])
|
||||
}
|
||||
|
||||
// If it's an ingreso
|
||||
if (isIngreso) {
|
||||
return h('div', { class: 'flex items-center gap-2' }, [
|
||||
h('span', {
|
||||
class: original.tipo === 'uva' ? 'text-purple-400' :
|
||||
original.tipo === 'oreado' ? 'text-orange-400' :
|
||||
original.tipo === 'mojado' ? 'text-blue-400' : 'text-green-400'
|
||||
}, original.tipo?.toUpperCase()),
|
||||
h('span', { class: 'text-xs text-gray-500' }, `Cliente ID: ${original.cliente_id}`)
|
||||
])
|
||||
}
|
||||
|
||||
return h('span', { class: 'text-gray-500' }, '—')
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'peso_seco',
|
||||
header: () => h('div', { class: 'text-right' }, 'Peso Seco (qq)'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
|
||||
|
||||
const peso = Number.parseFloat(row.getValue('peso_seco') || 0)
|
||||
return h('div', { class: 'text-right font-medium text-cyan-400' }, peso.toFixed(2))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'peso_neto',
|
||||
header: () => h('div', { class: 'text-right' }, 'Peso Neto (lb)'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
|
||||
|
||||
const peso = Number.parseFloat(row.getValue('peso_neto') || 0)
|
||||
return h('div', { class: 'text-right font-medium text-cyan-400' }, peso.toFixed(2))
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'precio',
|
||||
header: () => h('div', { class: 'text-right' }, 'Precio'),
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isIngreso = 'tipo' in original
|
||||
if (!isIngreso) return h('div', { class: 'text-right text-gray-500' }, '—')
|
||||
|
||||
const precio = Number.parseFloat(row.getValue('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-cyan-400' }, formatted)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'estado',
|
||||
header: 'Estado',
|
||||
cell: ({ row }) => {
|
||||
const original = row.original as any
|
||||
const isCliente = 'name' in original && !('tipo' in original)
|
||||
const isIngreso = 'tipo' in original
|
||||
|
||||
if (isCliente) {
|
||||
// Cliente row - 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', '💼'),
|
||||
'Empleado'
|
||||
])
|
||||
: h('span', { class: 'text-gray-500 text-xs' }, '—')
|
||||
}
|
||||
|
||||
if (isIngreso) {
|
||||
const estado = row.getValue('estado') as string
|
||||
return 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')
|
||||
}
|
||||
|
||||
return h('span', { class: 'text-gray-500 text-xs' }, '—')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const expanded = ref<Record<string, boolean>>({})
|
||||
|
||||
// Pagination
|
||||
const currentPage = ref(1)
|
||||
const recordsPerPage = 50
|
||||
|
||||
const totalRecords = computed(() => data.value.length)
|
||||
const totalPages = computed(() => Math.ceil(totalRecords.value / recordsPerPage))
|
||||
|
||||
const startRecord = computed(() => {
|
||||
if (totalRecords.value === 0) return 0
|
||||
return (currentPage.value - 1) * recordsPerPage + 1
|
||||
})
|
||||
|
||||
const endRecord = computed(() => {
|
||||
const end = currentPage.value * recordsPerPage
|
||||
return Math.min(end, totalRecords.value)
|
||||
})
|
||||
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * recordsPerPage
|
||||
const end = start + recordsPerPage
|
||||
return data.value.slice(start, end)
|
||||
})
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
}
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<UTable
|
||||
v-model:expanded="expanded"
|
||||
:data="paginatedData"
|
||||
:columns="columns"
|
||||
:get-sub-rows="(row: any) => row.children"
|
||||
sticky
|
||||
class="flex-1"
|
||||
:ui="{
|
||||
base: 'border-separate border-spacing-0',
|
||||
tbody: '[&>tr]:last:[&>td]:border-b-0',
|
||||
tr: 'group',
|
||||
td: 'empty:p-0 group-has-[td:not(:empty)]:border-b border-default'
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Pagination Footer -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-t border-[var(--brand-border)]">
|
||||
<span class="text-sm text-[var(--brand-text-muted)]">
|
||||
Mostrando {{ startRecord }} - {{ endRecord }} de {{ totalRecords }} registros
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
icon="i-lucide-chevron-left"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage === 1"
|
||||
@click="previousPage"
|
||||
/>
|
||||
<span class="text-sm text-[var(--brand-text-muted)] px-2">
|
||||
Página {{ currentPage }} de {{ totalPages }}
|
||||
</span>
|
||||
<UButton
|
||||
icon="i-lucide-chevron-right"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="nextPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,39 +74,193 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Selector de Clientes -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-2">
|
||||
Clientes
|
||||
</h3>
|
||||
<ClienteSelector
|
||||
:clientes="clientes"
|
||||
:selected-ids="selectedClienteIds"
|
||||
@update:selected-ids="selectedClienteIds = $event"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Fila 1: Selector de Clientes -->
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
||||
Clientes
|
||||
</h3>
|
||||
<ClienteSelector
|
||||
:clientes="clientes"
|
||||
:selected-ids="selectedClienteIds"
|
||||
@update:selected-ids="selectedClienteIds = $event"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="selectedClienteIds.length > 0"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="selectedClienteIds = []"
|
||||
class="shrink-0"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-x" />
|
||||
</template>
|
||||
Limpiar
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Selector de Rango de Fechas -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-2">
|
||||
Rango de Fechas
|
||||
</h3>
|
||||
<DateRangeSelector
|
||||
:selected-preset="selectedPreset"
|
||||
:fecha-desde="fechaDesde"
|
||||
:fecha-hasta="fechaHasta"
|
||||
@update:selected-preset="selectedPreset = $event"
|
||||
@update:fecha-desde="fechaDesde = $event"
|
||||
@update:fecha-hasta="fechaHasta = $event"
|
||||
/>
|
||||
<!-- Fila 2: Selector de Rango de Fechas -->
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
||||
Rango de Fechas
|
||||
</h3>
|
||||
<DateRangeSelector
|
||||
:selected-preset="selectedPreset"
|
||||
:fecha-desde="fechaDesde"
|
||||
:fecha-hasta="fechaHasta"
|
||||
@update:selected-preset="selectedPreset = $event"
|
||||
@update:fecha-desde="fechaDesde = $event"
|
||||
@update:fecha-hasta="fechaHasta = $event"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="fechaDesde || fechaHasta"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="() => { fechaDesde = null; fechaHasta = null; selectedPreset = '' }"
|
||||
class="shrink-0"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-x" />
|
||||
</template>
|
||||
Limpiar
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Fila 3: Filtros Avanzados -->
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Tipos de Café -->
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
||||
Tipos de Café
|
||||
</h3>
|
||||
<UInputMenu
|
||||
v-model="selectedTipos"
|
||||
:items="tiposCafeOptions"
|
||||
value-key="value"
|
||||
multiple
|
||||
placeholder="Todos los tipos"
|
||||
size="sm"
|
||||
icon="i-lucide-coffee"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Estados -->
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
||||
Estados
|
||||
</h3>
|
||||
<UInputMenu
|
||||
v-model="selectedEstados"
|
||||
:items="estadosOptions"
|
||||
value-key="value"
|
||||
multiple
|
||||
placeholder="Todos los estados"
|
||||
size="sm"
|
||||
icon="i-lucide-circle-check-big"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ubicaciones -->
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-sm font-semibold text-[var(--brand-text-muted)] uppercase tracking-wide mb-3">
|
||||
Ubicaciones
|
||||
</h3>
|
||||
<UInputMenu
|
||||
v-model="selectedUbicaciones"
|
||||
:items="ubicacionesOptions"
|
||||
value-key="value"
|
||||
multiple
|
||||
placeholder="Todas las ubicaciones"
|
||||
size="sm"
|
||||
icon="i-lucide-map-pin"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="hasAdvancedFilters"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="clearAdvancedFilters"
|
||||
class="shrink-0"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-x" />
|
||||
</template>
|
||||
Limpiar
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="text-xs text-[var(--brand-text-muted)]">
|
||||
Rango activo: {{ rangoLegible }} · Ingresos considerados: {{ ingresosFiltrados.length }}/{{ ingresos.length }}
|
||||
<span v-if="selectedClienteIds.length > 0"> · Clientes seleccionados: {{ selectedClienteIds.length }}</span>
|
||||
<div class="space-y-3">
|
||||
<!-- Main Stats - Highlighted -->
|
||||
<div class="flex flex-wrap items-center gap-4 p-3 rounded-lg bg-gradient-to-r from-[var(--brand-primary)]/10 to-[var(--brand-primary)]/5 border border-[var(--brand-primary)]/20">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-calendar-range" class="w-4 h-4 text-[var(--brand-primary)]" />
|
||||
<span class="text-sm font-semibold text-[var(--brand-text)]">
|
||||
{{ rangoLegible }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-6 w-px bg-[var(--brand-border)]" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-trending-up" class="w-4 h-4 text-cyan-400" />
|
||||
<span class="text-sm font-medium text-[var(--brand-text)]">
|
||||
Ingresos:
|
||||
</span>
|
||||
<span class="text-sm font-bold text-cyan-400">
|
||||
{{ ingresosFiltrados.length }}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--brand-text-muted)]">
|
||||
/ {{ ingresos.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-6 w-px bg-[var(--brand-border)]" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-users" class="w-4 h-4 text-yellow-500" />
|
||||
<span class="text-sm font-medium text-[var(--brand-text)]">
|
||||
Clientes:
|
||||
</span>
|
||||
<span class="text-sm font-bold text-yellow-500">
|
||||
{{ selectedClienteIds.length > 0 ? selectedClienteIds.length : clientesFiltrados.length }}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--brand-text-muted)]">
|
||||
/ {{ clientes.length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters -->
|
||||
<div v-if="hasAdvancedFilters" class="flex flex-wrap gap-2 items-center text-xs">
|
||||
<span class="text-[var(--brand-primary)] font-semibold">Filtros activos:</span>
|
||||
<span v-if="selectedTipos.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
|
||||
<UIcon name="i-lucide-coffee" class="w-3 h-3" />
|
||||
{{ selectedTiposLabels }}
|
||||
</span>
|
||||
<span v-if="selectedEstados.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
|
||||
<UIcon name="i-lucide-circle-check-big" class="w-3 h-3" />
|
||||
{{ selectedEstadosLabels }}
|
||||
</span>
|
||||
<span v-if="selectedUbicaciones.length > 0" class="inline-flex items-center gap-1 px-2 py-1 rounded bg-[var(--brand-primary)]/20 text-[var(--brand-primary)] border border-[var(--brand-primary)]/30">
|
||||
<UIcon name="i-lucide-map-pin" class="w-3 h-3" />
|
||||
{{ selectedUbicaciones.length }} ubicaciones
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
@@ -124,14 +278,95 @@
|
||||
{{ tableDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div ref="tabsRef">
|
||||
<UTabs v-model="activeTab" :items="tabItems" :content="false" variant="link" size="sm" class="flex-1" />
|
||||
<div ref="viewSelectorRef" class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button
|
||||
v-for="option in viewOptions"
|
||||
:key="option.value"
|
||||
@click="selectedView = option.value"
|
||||
:class="[
|
||||
'relative inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-all duration-200',
|
||||
selectedView === option.value
|
||||
? `${option.borderColor} ${option.bgColor} shadow-lg ${option.shadowColor} scale-105`
|
||||
: 'border-gray-600/30 bg-gray-700/20 hover:bg-gray-700/30 hover:scale-102'
|
||||
]"
|
||||
>
|
||||
<!-- Gradient background for combined views when selected -->
|
||||
<div
|
||||
v-if="option.color === 'gradient' && selectedView === option.value"
|
||||
:class="[
|
||||
'absolute inset-0 rounded-lg opacity-20 bg-gradient-to-r',
|
||||
option.gradient
|
||||
]"
|
||||
/>
|
||||
|
||||
<UIcon
|
||||
:name="option.icon"
|
||||
:class="[
|
||||
'relative z-10',
|
||||
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
|
||||
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
|
||||
selectedView === option.value && option.color === 'gradient' ? 'text-white' :
|
||||
'text-gray-400'
|
||||
]"
|
||||
/>
|
||||
|
||||
<span
|
||||
:class="[
|
||||
'relative z-10 font-medium text-sm whitespace-nowrap',
|
||||
selectedView === option.value && option.color === 'cyan' ? 'text-cyan-400' :
|
||||
selectedView === option.value && option.color === 'yellow' ? 'text-yellow-500' :
|
||||
selectedView === option.value && option.color === 'gradient' ? 'text-white' :
|
||||
'text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toggle for clientes without ingresos -->
|
||||
<div v-if="selectedView === 'clientes-ingresos'" class="flex items-center gap-2 px-2">
|
||||
<UCheckbox
|
||||
v-model="includeClientesWithoutIngresos"
|
||||
label="Incluir clientes sin ingresos"
|
||||
/>
|
||||
<span class="text-xs text-[var(--brand-text-muted)]">
|
||||
({{ includeClientesWithoutIngresos ? 'Mostrando todos los clientes' : 'Solo clientes con ingresos' }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<IngresosVistaTablaIngresos v-if="activeTab === 'ingresos'" :records="ingresosFiltrados" />
|
||||
<ClientesVistaTablaClientes v-else-if="activeTab === 'clientes'" :records="clientesFiltrados" />
|
||||
<!-- Single view: Ingresos -->
|
||||
<IngresosVistaTablaIngresos
|
||||
v-if="selectedView === 'ingresos-only'"
|
||||
:records="ingresosFiltrados"
|
||||
/>
|
||||
|
||||
<!-- Single view: Clientes -->
|
||||
<ClientesVistaTablaClientes
|
||||
v-else-if="selectedView === 'clientes-only'"
|
||||
:records="clientesFiltrados"
|
||||
/>
|
||||
|
||||
<!-- Combined view: Ingresos → Clientes -->
|
||||
<IngresosVistaTablaIngresosConClientes
|
||||
v-else-if="selectedView === 'ingresos-clientes'"
|
||||
:ingresos="ingresosFiltrados"
|
||||
:clientes="clientesFiltrados"
|
||||
primary-view="ingresos"
|
||||
/>
|
||||
|
||||
<!-- Combined view: Clientes → Ingresos -->
|
||||
<IngresosVistaTablaIngresosConClientes
|
||||
v-else-if="selectedView === 'clientes-ingresos'"
|
||||
:ingresos="ingresosFiltrados"
|
||||
:clientes="clientesFiltrados"
|
||||
primary-view="clientes"
|
||||
:include-clientes-without-ingresos="includeClientesWithoutIngresos"
|
||||
/>
|
||||
</UCard>
|
||||
</template>
|
||||
</div>
|
||||
@@ -142,7 +377,6 @@ import { useTableDataStore } from '~/stores/tableDataFactory'
|
||||
import { useMetadataStore } from '~/stores/metadata'
|
||||
import { useIngresosMetrics } from '~/composables/useIngresosMetrics'
|
||||
import type { IngresoRecord } from '~/composables/useIngresosMetrics'
|
||||
import type { TabsItem } from '@nuxt/ui'
|
||||
|
||||
// Define page metadata
|
||||
definePageMeta({
|
||||
@@ -150,45 +384,109 @@ definePageMeta({
|
||||
title: 'Cuenta Cliente'
|
||||
})
|
||||
|
||||
// Tabs
|
||||
const activeTab = ref<'ingresos' | 'clientes'>('ingresos')
|
||||
// View modes with explicit hierarchy
|
||||
type ViewMode = 'ingresos-only' | 'clientes-only' | 'ingresos-clientes' | 'clientes-ingresos'
|
||||
const selectedView = ref<ViewMode>('ingresos-only')
|
||||
|
||||
const tabItems: TabsItem[] = [
|
||||
// Toggle for including clients without ingresos in clientes-ingresos view
|
||||
const includeClientesWithoutIngresos = ref(false)
|
||||
|
||||
const viewOptions = [
|
||||
{
|
||||
value: 'ingresos-only' as ViewMode,
|
||||
label: 'Ingresos',
|
||||
value: 'ingresos',
|
||||
icon: 'i-lucide-trending-up'
|
||||
icon: 'i-lucide-trending-up',
|
||||
color: 'cyan',
|
||||
gradient: 'from-cyan-500 to-cyan-600',
|
||||
borderColor: 'border-cyan-500/50',
|
||||
bgColor: 'bg-cyan-500/10',
|
||||
shadowColor: 'shadow-cyan-500/20'
|
||||
},
|
||||
{
|
||||
value: 'clientes-only' as ViewMode,
|
||||
label: 'Clientes',
|
||||
value: 'clientes',
|
||||
icon: 'i-lucide-users'
|
||||
icon: 'i-lucide-users',
|
||||
color: 'yellow',
|
||||
gradient: 'from-yellow-500 to-yellow-600',
|
||||
borderColor: 'border-yellow-500/50',
|
||||
bgColor: 'bg-yellow-500/10',
|
||||
shadowColor: 'shadow-yellow-500/20'
|
||||
},
|
||||
{
|
||||
value: 'ingresos-clientes' as ViewMode,
|
||||
label: 'Ingresos → Clientes',
|
||||
icon: 'i-lucide-git-branch',
|
||||
color: 'gradient',
|
||||
gradient: 'from-cyan-500 via-cyan-400 to-yellow-500',
|
||||
borderColor: 'border-cyan-500/50',
|
||||
bgColor: 'bg-gradient-to-r from-cyan-500/10 to-yellow-500/10',
|
||||
shadowColor: 'shadow-cyan-500/20'
|
||||
},
|
||||
{
|
||||
value: 'clientes-ingresos' as ViewMode,
|
||||
label: 'Clientes → Ingresos',
|
||||
icon: 'i-lucide-git-merge',
|
||||
color: 'gradient',
|
||||
gradient: 'from-yellow-500 via-yellow-400 to-cyan-500',
|
||||
borderColor: 'border-yellow-500/50',
|
||||
bgColor: 'bg-gradient-to-r from-yellow-500/10 to-cyan-500/10',
|
||||
shadowColor: 'shadow-yellow-500/20'
|
||||
}
|
||||
]
|
||||
|
||||
// Dynamic table title and description
|
||||
const tableTitle = computed(() => {
|
||||
return activeTab.value === 'ingresos' ? 'Tabla de Ingresos' : 'Tabla de Clientes'
|
||||
})
|
||||
|
||||
const tableDescription = computed(() => {
|
||||
if (activeTab.value === 'ingresos') {
|
||||
return `Mostrando ${ingresosFiltrados.value.length} registros de ingresos`
|
||||
} else {
|
||||
return `Mostrando ${clientesFiltrados.value.length} clientes`
|
||||
switch (selectedView.value) {
|
||||
case 'ingresos-only':
|
||||
return 'Tabla de Ingresos'
|
||||
case 'clientes-only':
|
||||
return 'Tabla de Clientes'
|
||||
case 'ingresos-clientes':
|
||||
return 'Vista Combinada: Ingresos con Clientes'
|
||||
case 'clientes-ingresos':
|
||||
return 'Vista Combinada: Clientes con Ingresos'
|
||||
default:
|
||||
return 'Tabla de Datos'
|
||||
}
|
||||
})
|
||||
|
||||
// Ref for tabs element
|
||||
const tabsRef = ref<HTMLElement | null>(null)
|
||||
const tableDescription = computed(() => {
|
||||
switch (selectedView.value) {
|
||||
case 'ingresos-only':
|
||||
return `Mostrando ${ingresosFiltrados.value.length} registros de ingresos`
|
||||
case 'clientes-only':
|
||||
return `Mostrando ${clientesFiltrados.value.length} clientes`
|
||||
case 'ingresos-clientes':
|
||||
return `${ingresosFiltrados.value.length} ingresos, cada uno con su cliente relacionado`
|
||||
case 'clientes-ingresos': {
|
||||
const clientesConIngresos = clientesFiltrados.value.filter(c =>
|
||||
ingresosFiltrados.value.some(i => i.cliente_id === c.id)
|
||||
).length
|
||||
if (includeClientesWithoutIngresos.value) {
|
||||
return `${clientesFiltrados.value.length} clientes (${clientesConIngresos} con ingresos, ${clientesFiltrados.value.length - clientesConIngresos} sin ingresos)`
|
||||
}
|
||||
return `${clientesConIngresos} clientes con ingresos relacionados`
|
||||
}
|
||||
default:
|
||||
return 'Selecciona una vista'
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for tab changes and scroll to keep the tabs in view
|
||||
watch(activeTab, () => {
|
||||
// Ref for view selector element
|
||||
const viewSelectorRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Watch for view changes and scroll to keep the selector in view
|
||||
watch(selectedView, (newView, oldView) => {
|
||||
nextTick(() => {
|
||||
if (tabsRef.value) {
|
||||
tabsRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
if (viewSelectorRef.value) {
|
||||
viewSelectorRef.value.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
|
||||
// Reset the toggle when switching away from clientes-ingresos view
|
||||
if (oldView === 'clientes-ingresos' && newView !== 'clientes-ingresos') {
|
||||
includeClientesWithoutIngresos.value = false
|
||||
}
|
||||
})
|
||||
|
||||
interface ClienteRecord extends Record<string, unknown> {
|
||||
@@ -228,6 +526,64 @@ const fechaDesde = ref<string | null>(null)
|
||||
const fechaHasta = ref<string | null>(null)
|
||||
const selectedClienteIds = ref<number[]>([])
|
||||
|
||||
// Filtros avanzados
|
||||
const selectedTipos = ref<string[]>([])
|
||||
const selectedEstados = ref<string[]>([])
|
||||
const selectedUbicaciones = ref<string[]>([])
|
||||
|
||||
// Opciones para filtros avanzados
|
||||
const tiposCafeOptions = [
|
||||
{ value: 'uva', label: 'Uva' },
|
||||
{ value: 'oreado', label: 'Oreado' },
|
||||
{ value: 'mojado', label: 'Mojado' },
|
||||
{ value: 'verde', label: 'Verde' }
|
||||
]
|
||||
|
||||
const estadosOptions = [
|
||||
{ value: 'pagado', label: 'Pagado' },
|
||||
{ value: 'pendiente', label: 'Pendiente' }
|
||||
]
|
||||
|
||||
// Ubicaciones dinámicas basadas en los clientes
|
||||
const ubicacionesOptions = computed(() => {
|
||||
const ubicaciones = new Set<string>()
|
||||
clientes.value?.forEach(c => {
|
||||
if (c.ubicacion) {
|
||||
ubicaciones.add(c.ubicacion)
|
||||
}
|
||||
})
|
||||
return Array.from(ubicaciones).sort().map(u => ({ value: u, label: u }))
|
||||
})
|
||||
|
||||
// Labels for selected filters
|
||||
const selectedTiposLabels = computed(() => {
|
||||
return selectedTipos.value
|
||||
.map(v => tiposCafeOptions.find(o => o.value === v)?.label)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
const selectedEstadosLabels = computed(() => {
|
||||
return selectedEstados.value
|
||||
.map(v => estadosOptions.find(o => o.value === v)?.label)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
// Check if advanced filters are active
|
||||
const hasAdvancedFilters = computed(() => {
|
||||
return selectedTipos.value.length > 0 ||
|
||||
selectedEstados.value.length > 0 ||
|
||||
selectedUbicaciones.value.length > 0
|
||||
})
|
||||
|
||||
// Clear advanced filters
|
||||
function clearAdvancedFilters() {
|
||||
selectedTipos.value = []
|
||||
selectedEstados.value = []
|
||||
selectedUbicaciones.value = []
|
||||
}
|
||||
|
||||
async function onToggleAnulados(newValue: boolean | 'indeterminate') {
|
||||
if (newValue === true) {
|
||||
// Pedir confirmación al activar
|
||||
@@ -285,6 +641,22 @@ function isClienteSelected(clienteId: number): boolean {
|
||||
return selectedClienteIds.value.includes(clienteId)
|
||||
}
|
||||
|
||||
function matchesTipoCafe(ingreso: IngresoRecord): boolean {
|
||||
if (selectedTipos.value.length === 0) return true
|
||||
return selectedTipos.value.includes(ingreso.tipo)
|
||||
}
|
||||
|
||||
function matchesEstado(ingreso: IngresoRecord): boolean {
|
||||
if (selectedEstados.value.length === 0) return true
|
||||
return selectedEstados.value.includes(ingreso.estado)
|
||||
}
|
||||
|
||||
function matchesUbicacion(ingreso: IngresoRecord): boolean {
|
||||
if (selectedUbicaciones.value.length === 0) return true
|
||||
const cliente = clientes.value?.find(c => c.id === ingreso.cliente_id)
|
||||
return cliente?.ubicacion ? selectedUbicaciones.value.includes(cliente.ubicacion) : false
|
||||
}
|
||||
|
||||
// Get selected clientes for display cards
|
||||
const clientesSeleccionados = computed((): ClienteRecord[] => {
|
||||
if (selectedClienteIds.value.length === 0) return []
|
||||
@@ -302,6 +674,9 @@ const ingresosFiltrados = computed(() => {
|
||||
.filter(r => (includeAnulados.value ? true : !isAnulado(r)))
|
||||
.filter(r => isWithinDate(r, fechaDesde.value, fechaHasta.value))
|
||||
.filter(r => isClienteSelected(r.cliente_id))
|
||||
.filter(r => matchesTipoCafe(r))
|
||||
.filter(r => matchesEstado(r))
|
||||
.filter(r => matchesUbicacion(r))
|
||||
})
|
||||
|
||||
const clientesFiltrados = computed((): ClienteRecord[] => {
|
||||
@@ -370,6 +745,11 @@ onMounted(async () => {
|
||||
// Default preset: cosecha 25-26
|
||||
selectedPreset.value = 'cosecha-25-26'
|
||||
includeAnulados.value = false
|
||||
|
||||
// Clear advanced filters
|
||||
selectedTipos.value = []
|
||||
selectedEstados.value = []
|
||||
selectedUbicaciones.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user